1use std::io::Read;
10use std::path::Path;
11
12use image::DynamicImage;
13
14pub const MAX_ENTRY: u64 = 16 << 20;
16const MAX_SIDE: u32 = 16_384;
18const MAX_ALLOC: u64 = 256 << 20;
20
21pub fn entries(ext: &str) -> &'static [&'static str] {
23 match ext {
24 "sketch" => &["previews/preview.png"],
25 "xmind" => &["Thumbnails/thumbnail.png"],
26 "key" => &["preview.jpg", "QuickLook/Thumbnail.jpg"],
27 _ => &[],
28 }
29}
30
31pub fn handles(ext: &str) -> bool {
32 !entries(ext).is_empty() || ext == "idml"
33}
34
35const MAX_FOUND: usize = 8;
37const IDML_METADATA: &str = "META-INF/metadata.xml";
39
40fn is_picture(name: &str) -> bool {
41 name.ends_with(".png") || name.ends_with(".jpg") || name.ends_with(".jpeg")
42}
43
44fn found(names: &[String], ext: &str) -> Vec<String> {
51 let mut out: Vec<String> = match ext {
52 "key" => {
53 const TAILS: [&str; 4] = ["preview.jpg", "preview.png", "quicklook/thumbnail.jpg", "quicklook/thumbnail.png"];
54 let below = |l: &str| -> String {
56 match l.split_once('/') {
57 Some((first, rest)) if first.ends_with(".key") => rest.to_owned(),
58 _ => l.to_owned(),
59 }
60 };
61 let mut previews: Vec<&String> = Vec::new();
62 let mut slides: Vec<&String> = Vec::new();
63 for n in names {
64 let l = n.to_ascii_lowercase();
65 let rest = below(&l);
66 if TAILS.contains(&rest.as_str()) {
67 previews.push(n);
68 } else if let Some(file) = rest.strip_prefix("data/")
69 && file.starts_with("st-")
70 && !file.contains('/')
71 && is_picture(file)
72 {
73 slides.push(n);
74 }
75 }
76 slides.sort();
77 previews.into_iter().chain(slides).cloned().collect()
78 }
79 "idml" | "skp" => names
80 .iter()
81 .filter(|n| {
82 let l = n.to_ascii_lowercase();
83 let name = l.rsplit('/').next().unwrap_or("");
84 (name.starts_with("thumbnail") || name.starts_with("preview")) && is_picture(name)
85 })
86 .cloned()
87 .collect(),
88 _ => Vec::new(),
89 };
90 out.retain(|n| !entries(ext).contains(&n.as_str()));
91 out.dedup();
92 out.truncate(MAX_FOUND);
93 out
94}
95
96fn read_entry<R: std::io::Read + std::io::Seek>(zip: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
99 let entry = zip.by_name(name).ok()?;
100 if entry.is_dir() || entry.size() > MAX_ENTRY {
101 return None;
102 }
103 let mut bytes = Vec::new();
104 entry.take(MAX_ENTRY + 1).read_to_end(&mut bytes).ok()?;
106 (bytes.len() as u64 <= MAX_ENTRY).then_some(bytes)
107}
108
109fn xmp_thumbnail(xml: &[u8]) -> Option<DynamicImage> {
113 use base64::Engine;
114 const OPEN: &str = "<xmpGImg:image>";
115 const CLOSE: &str = "</xmpGImg:image>";
116 let text = std::str::from_utf8(xml).ok()?;
117 let start = text.find(OPEN)?.checked_add(OPEN.len())?;
118 let body = text.get(start..)?;
119 let body = body.get(..body.find(CLOSE)?)?;
120 let cleaned: String = body.replace("
", "").replace("
", "").chars().filter(|c| !c.is_whitespace()).collect();
121 let bytes = base64::engine::general_purpose::STANDARD.decode(cleaned.as_bytes()).ok()?;
122 decode(&bytes)
123}
124
125pub fn preview(path: &Path, ext: &str) -> Option<DynamicImage> {
128 let file = std::fs::File::open(path).ok()?;
129 let mut zip = zip::ZipArchive::new(std::io::BufReader::new(file)).ok()?;
130 let names: Vec<String> = zip.file_names().filter_map(Result::ok).map(std::borrow::Cow::into_owned).collect();
131 let tried: Vec<String> = entries(ext).iter().map(|n| (*n).to_owned()).chain(found(&names, ext)).collect();
132 for name in &tried {
133 if let Some(img) = read_entry(&mut zip, name).and_then(|bytes| decode(&bytes)) {
134 return Some(img);
135 }
136 }
137 if ext == "idml" {
138 return read_entry(&mut zip, IDML_METADATA).and_then(|xml| xmp_thumbnail(&xml));
139 }
140 None
141}
142
143pub(crate) fn decode(bytes: &[u8]) -> Option<DynamicImage> {
144 let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
145 let mut limits = image::Limits::default();
146 limits.max_image_width = Some(MAX_SIDE);
147 limits.max_image_height = Some(MAX_SIDE);
148 limits.max_alloc = Some(MAX_ALLOC);
149 reader.limits(limits);
150 let mut decoder = reader.into_decoder().ok()?;
151 let orientation = image::ImageDecoder::orientation(&mut decoder).ok()?;
152 let mut img = DynamicImage::from_decoder(decoder).ok()?;
153 img.apply_orientation(orientation);
154 Some(img)
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use std::io::Write;
161
162 use image::GenericImageView;
163
164 fn png(w: u32, h: u32) -> Vec<u8> {
165 let mut out = Vec::new();
166 DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb([10, 200, 30])))
167 .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
168 .unwrap();
169 out
170 }
171
172 fn jpeg(w: u32, h: u32) -> Vec<u8> {
173 let mut out = Vec::new();
174 DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb([200, 20, 30])))
175 .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Jpeg)
176 .unwrap();
177 out
178 }
179
180 fn make_zip(dir: &Path, file: &str, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
181 let path = dir.join(file);
182 let mut z = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
183 for (name, data) in entries {
184 z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
185 z.write_all(data).unwrap();
186 }
187 z.finish().unwrap();
188 path
189 }
190
191 #[test]
192 fn sketch_xmind_and_key_previews_are_read() {
193 let dir = tempfile::tempdir().unwrap();
194 let p = make_zip(dir.path(), "a.sketch", &[("document.json", b"{}"), ("previews/preview.png", &png(30, 20))]);
195 assert_eq!(preview(&p, "sketch").unwrap().dimensions(), (30, 20));
196 let p = make_zip(dir.path(), "a.xmind", &[("Thumbnails/thumbnail.png", &png(8, 9))]);
197 assert_eq!(preview(&p, "xmind").unwrap().dimensions(), (8, 9));
198 let p = make_zip(dir.path(), "new.key", &[("preview.jpg", &jpeg(16, 12))]);
199 assert_eq!(preview(&p, "key").unwrap().dimensions(), (16, 12));
200 let p = make_zip(dir.path(), "old.key", &[("QuickLook/Thumbnail.jpg", &jpeg(12, 16))]);
201 assert_eq!(preview(&p, "key").unwrap().dimensions(), (12, 16));
202 assert!(preview(&p, "sketch").is_none());
204 }
205
206 #[test]
207 fn keynote_packages_and_idml_previews_are_found_by_name() {
208 let dir = tempfile::tempdir().unwrap();
209 let p = make_zip(dir.path(), "pkg.key", &[("Presentation.key/Index.zip", b"x"), ("Presentation.key/preview.jpg", &jpeg(14, 10))]);
210 assert_eq!(preview(&p, "key").unwrap().dimensions(), (14, 10));
211 let p = make_zip(dir.path(), "slides.key", &[("Data/st-b-2.jpg", &jpeg(9, 9)), ("Data/st-a-1.jpg", &jpeg(11, 7)), ("Data/other.jpg", &jpeg(5, 5))]);
212 assert_eq!(preview(&p, "key").unwrap().dimensions(), (11, 7), "the first slide picture by name");
213 let p = make_zip(dir.path(), "none.key", &[("Presentation.key/Index.zip", b"x"), ("Data/image.jpg", &jpeg(5, 5))]);
214 assert!(preview(&p, "key").is_none());
215 let p = make_zip(dir.path(), "a.idml", &[("designmap.xml", b"<x/>"), ("META-INF/Thumbnail.png", &png(33, 22))]);
216 assert_eq!(preview(&p, "idml").unwrap().dimensions(), (33, 22));
217 let p = make_zip(dir.path(), "none.idml", &[("designmap.xml", b"<x/>"), ("Resources/Graphic.xml", b"<x/>")]);
218 assert!(preview(&p, "idml").is_none());
219 let p = make_zip(dir.path(), "bad.idml", &[("preview.png", b"junk")]);
220 assert!(preview(&p, "idml").is_none());
221 assert!(handles("idml"));
222 }
223
224 fn xmp(body: &str) -> Vec<u8> {
225 format!("<?xml version=\"1.0\"?><x:xmpmeta><xmpGImg:format>JPEG</xmpGImg:format><xmpGImg:image>{body}</xmpGImg:image></x:xmpmeta>").into_bytes()
226 }
227
228 fn b64_split(bytes: &[u8]) -> String {
229 use base64::Engine;
230 let s = base64::engine::general_purpose::STANDARD.encode(bytes);
231 s.as_bytes().chunks(76).map(|c| std::str::from_utf8(c).unwrap()).collect::<Vec<_>>().join("
")
232 }
233
234 #[test]
235 fn idml_xmp_thumbnail_is_read() {
236 let dir = tempfile::tempdir().unwrap();
237 let body = b64_split(&jpeg(40, 30));
238 assert!(body.contains("
"));
239 let m = xmp(&body);
240 let p = make_zip(dir.path(), "x.idml", &[("designmap.xml", b"<x/>"), ("META-INF/metadata.xml", &m)]);
241 assert_eq!(preview(&p, "idml").unwrap().dimensions(), (40, 30));
242 assert!(preview(&p, "skp").is_none());
244 let p = make_zip(dir.path(), "y.idml", &[("META-INF/metadata.xml", &m), ("META-INF/Thumbnail.png", &png(7, 7))]);
246 assert_eq!(preview(&p, "idml").unwrap().dimensions(), (7, 7));
247 }
248
249 #[test]
250 fn idml_malformed_metadata_gives_nothing() {
251 let dir = tempfile::tempdir().unwrap();
252 let good = b64_split(&jpeg(40, 30));
253 let cases: Vec<Vec<u8>> = vec![
254 xmp("!!!! not base64 ????"),
255 xmp("QUJD"),
256 xmp(""),
257 xmp(&good.replacen("
", "&#x", 1)),
259 xmp(&good.replacen("
", "&", 1)),
260 format!("<xmpGImg:image>{good}").into_bytes(),
262 b"<x:xmpmeta/>".to_vec(),
263 vec![0xFF, 0xFE, 0x00, 0x3C],
264 format!("</xmpGImg:image><xmpGImg:image>{good}").into_bytes(),
266 b"<xmpGImg:image>".to_vec(),
267 ];
268 for (i, m) in cases.iter().enumerate() {
269 let p = make_zip(dir.path(), &format!("m{i}.idml"), &[("META-INF/metadata.xml", m)]);
270 assert!(preview(&p, "idml").is_none(), "case {i}");
271 }
272 }
273
274 #[test]
275 fn found_matches_whole_path_components() {
276 let names: Vec<String> = ["mydata/st-1.jpg", "Data/st-2.jpg", "Data/sub/st-3.jpg", "Data/st-4.txt", "x/preview.jpg", "Pkg.key/Data/st-5.png"]
277 .iter()
278 .map(|s| (*s).to_owned())
279 .collect();
280 assert_eq!(found(&names, "key"), ["Data/st-2.jpg", "Pkg.key/Data/st-5.png"]);
281 let names: Vec<String> = ["Pkg.key/preview.jpg", "preview.jpg", "other.key/x/preview.jpg"].iter().map(|s| (*s).to_owned()).collect();
282 assert_eq!(found(&names, "key"), ["Pkg.key/preview.jpg"], "the top-level one is in entries()");
283 let many: Vec<String> = (0..50).map(|i| format!("Data/st-{i:02}.jpg")).collect();
284 assert_eq!(found(&many, "key").len(), MAX_FOUND);
285 }
286
287 #[test]
288 fn missing_and_malformed_previews_give_nothing() {
289 let dir = tempfile::tempdir().unwrap();
290 let p = make_zip(dir.path(), "none.sketch", &[("document.json", b"{}")]);
291 assert!(preview(&p, "sketch").is_none());
292 let p = make_zip(dir.path(), "junk.sketch", &[("previews/preview.png", b"not a picture")]);
293 assert!(preview(&p, "sketch").is_none());
294 let p = make_zip(dir.path(), "dir.sketch", &[("previews/preview.png/", b"")]);
295 assert!(preview(&p, "sketch").is_none());
296 let full = png(64, 64);
298 let p = make_zip(dir.path(), "cut.sketch", &[("previews/preview.png", &full[..full.len() / 2])]);
299 assert!(preview(&p, "sketch").is_none());
300 let junk = dir.path().join("junk2.xmind");
302 std::fs::write(&junk, b"PK\x03\x04 and then garbage").unwrap();
303 assert!(preview(&junk, "xmind").is_none());
304 std::fs::write(&junk, b"").unwrap();
305 assert!(preview(&junk, "xmind").is_none());
306 assert!(preview(&dir.path().join("absent.key"), "key").is_none());
307 let good = make_zip(dir.path(), "g.key", &[("preview.jpg", &jpeg(16, 16))]);
308 let bytes = std::fs::read(&good).unwrap();
309 std::fs::write(dir.path().join("cutzip.key"), &bytes[..bytes.len() - 10]).unwrap();
310 assert!(preview(&dir.path().join("cutzip.key"), "key").is_none());
311 }
312
313 #[test]
314 fn hostile_zips_are_bounded() {
315 let dir = tempfile::tempdir().unwrap();
316 let big = vec![0u8; (MAX_ENTRY + 1024) as usize];
319 let p = make_zip(dir.path(), "big.sketch", &[("previews/preview.png", &big)]);
320 assert!(std::fs::metadata(&p).unwrap().len() < 1 << 20);
321 assert!(preview(&p, "sketch").is_none());
322 let mut huge = png(4, 4);
325 huge[16..20].copy_from_slice(&60_000u32.to_be_bytes());
326 huge[20..24].copy_from_slice(&60_000u32.to_be_bytes());
327 let p = make_zip(dir.path(), "huge.sketch", &[("previews/preview.png", &huge)]);
328 assert!(preview(&p, "sketch").is_none());
329 let mut eocd = b"PK\x05\x06\0\0\0\0".to_vec();
331 eocd.extend([0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0]);
332 let p = dir.path().join("many.xmind");
333 std::fs::write(&p, eocd).unwrap();
334 assert!(preview(&p, "xmind").is_none());
335 }
336}