Skip to main content

falcon_library/
zip_preview.rs

1//! The preview picture inside the zip containers of Sketch, XMind, Keynote
2//! and IDML files (and SketchUp files saved as a zip). Used at import when
3//! QuickLook has no render (no generator for the app is installed).
4//!
5//! The archive is untrusted. Entries are only matched by name, never used as
6//! paths. At most a few entries are read, each at most [`MAX_ENTRY`] bytes,
7//! and anything malformed means "no preview".
8
9use std::io::Read;
10use std::path::Path;
11
12use image::DynamicImage;
13
14/// The most bytes read from one entry, whatever the zip header claims.
15pub const MAX_ENTRY: u64 = 16 << 20;
16/// The largest preview picture accepted, per side.
17const MAX_SIDE: u32 = 16_384;
18/// The most memory the picture decoder may use.
19const MAX_ALLOC: u64 = 256 << 20;
20
21/// The entries that hold a file's preview, best first.
22pub 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
35/// The most extra entries [`found`] returns.
36const MAX_FOUND: usize = 8;
37/// The IDML entry that carries an XMP thumbnail.
38const 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
44/// More entries to try after [`entries`], found by their names in the
45/// archive. For Keynote: a preview or QuickLook thumbnail under the
46/// package's own `<name>.key/` folder, then `Data/st-*` slide pictures
47/// (also under that folder), in name order. For IDML and zipped SketchUp
48/// files: any `thumbnail*` or `preview*` picture. At most [`MAX_FOUND`]
49/// are returned, none of them already in [`entries`].
50fn 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            // The part of a name below an optional `<name>.key/` folder.
55            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
96/// The bytes of the file entry `name`; `None` when it is absent, a
97/// directory, or over [`MAX_ENTRY`].
98fn 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    // `take` bounds the read even when the header lies about the size.
105    entry.take(MAX_ENTRY + 1).read_to_end(&mut bytes).ok()?;
106    (bytes.len() as u64 <= MAX_ENTRY).then_some(bytes)
107}
108
109/// The picture in the `xmpGImg:image` element of an IDML `metadata.xml`: a
110/// base64 body that may be split by `&#xA;` entities. `None` when the element
111/// is missing or the body is not a picture.
112fn 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("&#xA;", "").replace("&#xD;", "").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
125/// The preview picture of the file at `path`; `None` when it isn't a zip,
126/// has no such entry, or the entry is too big or not a picture.
127pub 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        // The wrong format's entry name is not used.
203        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("&#xA;")
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("&#xA;"));
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        // Only IDML looks there.
243        assert!(preview(&p, "skp").is_none());
244        // A named picture wins over the metadata.
245        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            // A cut entity, and a lone ampersand.
258            xmp(&good.replacen("&#xA;", "&#x", 1)),
259            xmp(&good.replacen("&#xA;", "&", 1)),
260            // No closing tag, no element, not UTF-8.
261            format!("<xmpGImg:image>{good}").into_bytes(),
262            b"<x:xmpmeta/>".to_vec(),
263            vec![0xFF, 0xFE, 0x00, 0x3C],
264            // Closing tag before the opening one.
265            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        // A truncated picture.
297        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        // Not a zip at all, empty, a missing file, and a cut-off zip.
301        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        // An entry over the cap, stored so that the file stays small only if
317        // it compresses: zeros deflate to almost nothing.
318        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        // A picture whose header claims a huge canvas is refused by the
323        // decoder's limits, not allocated.
324        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        // An end-of-directory record that claims 65535 entries.
330        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}