From 8d7edd6d6decccb5b4a8661131bf66ac09628ec8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 15:51:13 +0000 Subject: [PATCH] fix(lottie): export embeds only files that are PNG, JPEG, GIF or WebP images --- crates/lottie/src/export.rs | 28 ++++++++++++++++++---------- crates/lottie/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/lottie/src/export.rs b/crates/lottie/src/export.rs index eeb6e2737..0bd7c3161 100644 --- a/crates/lottie/src/export.rs +++ b/crates/lottie/src/export.rs @@ -145,16 +145,18 @@ fn hex(c: [f32; 3]) -> String { format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2])) } -fn mime_of(path: &str) -> &'static str { - let lower = path.to_ascii_lowercase(); - if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { - "image/jpeg" - } else if lower.ends_with(".webp") { - "image/webp" - } else if lower.ends_with(".gif") { - "image/gif" +/// The image type `bytes` hold, by their signature; `None` for anything else. +fn image_mime(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG") { + Some("image/png") + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF8") { + Some("image/gif") + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + Some("image/webp") } else { - "image/png" + None } } @@ -286,7 +288,13 @@ impl Doc<'_> { self.ex.warn(format!("{}: cannot read image `{}` (layer exported as a null)", l.name, f.path)); return None; }; - let uri = format!("data:{};base64,{}", mime_of(&f.path), crate::base64::encode(&bytes)); + // What a project calls a still is embedded only when it is one: the path can name any + // file (an imported animation's asset), and the export would carry its bytes verbatim. + let Some(mime) = image_mime(&bytes) else { + self.ex.warn(format!("{}: `{}` is not a PNG, JPEG, GIF or WebP image (layer exported as a null)", l.name, f.path)); + return None; + }; + let uri = format!("data:{mime};base64,{}", crate::base64::encode(&bytes)); self.assets.push(json!({"id": id, "nm": it.name, "w": f.width, "h": f.height, "u": "", "p": uri, "e": 1})); self.done.insert(id.clone()); Some(id) diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 83c81e242..40b7df048 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -932,3 +932,22 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { assert_eq!(rc(&json)["r"]["k"], json!(5.0)); assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); } + +/// A footage item called a still whose file is not an image is not embedded. +#[test] +fn only_image_bytes_are_embedded() { + let (mut p, cid) = setup(); + let fid = p.add_item( + "secret.png", + Label::Lavender, + None, + ItemKind::Footage(Footage { path: "/virtual/secret.png".into(), kind: FootageKind::Still, width: 8, height: 8, has_video: true, ..Default::default() }), + ); + let c = comp(&p, cid); + let l = build::layer(&mut p, &c, "secret.png", LayerSource::Footage { item: fid }, (8, 8), None); + push(&mut p, cid, l); + let read = |path: &str| (path == "/virtual/secret.png").then(|| b"root:x:0:0:root:/root:/bin/sh\n".to_vec()); + let res = export_comp(&p, cid, &ExportOptions::default(), &read).unwrap(); + assert!(res.json["assets"].as_array().is_none_or(|a| a.is_empty()), "{}", res.json["assets"]); + assert!(res.warnings.iter().any(|w| w.contains("not a PNG")), "{:?}", res.warnings); +}