From b7b9f8eb7c016cef2680cb65230eb7273723a7ef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 14:37:26 +0000 Subject: [PATCH] feat(lottie): share bounded dotLottie ZIP handling --- Cargo.lock | 1 + crates/lottie/Cargo.toml | 3 +- crates/lottie/src/lib.rs | 5 +- crates/lottie/src/zip.rs | 151 +++++++++++++++++++++++++++++++++++++++ crates/zip/src/write.rs | 94 +++++++++++++++++++++++- 5 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 crates/lottie/src/zip.rs diff --git a/Cargo.lock b/Cargo.lock index ed7a6649e..6511d9021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1764,6 +1764,7 @@ dependencies = [ name = "craft-lottie" version = "0.1.0" dependencies = [ + "craft-zip", "serde_json", "thiserror 2.0.21", ] diff --git a/crates/lottie/Cargo.toml b/crates/lottie/Cargo.toml index 0d934843c..945be4d54 100644 --- a/crates/lottie/Cargo.toml +++ b/crates/lottie/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-lottie" -description = "Lottie (Bodymovin) JSON: the lottie-spec 1.0 document model with a bounded reader and a writer, animated properties and their keyframes, ease tangents, bezier paths, gradients, value coercions and embedded image data URIs" +description = "Lottie (Bodymovin) JSON and dotLottie ZIP containers: the lottie-spec 1.0 document model, bounded readers and writers, animated properties, keyframes, ease tangents, bezier paths, gradients, value coercions and embedded image data URIs" version.workspace = true edition.workspace = true license.workspace = true @@ -11,5 +11,6 @@ repository.workspace = true workspace = true [dependencies] +craft-zip = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/lottie/src/lib.rs b/crates/lottie/src/lib.rs index 569063ebf..16c1c6e07 100644 --- a/crates/lottie/src/lib.rs +++ b/crates/lottie/src/lib.rs @@ -15,8 +15,8 @@ //! LottieFiles format documentation); no player or exporter source was used. //! //! Which layer, shape, effect and text feature maps to what in an application's document is the -//! application's work. dotLottie (`.lottie`) is a ZIP archive around the same JSON and is not -//! read here. +//! application's work. [`zip`] reads and writes the stored-entry ZIP container used by dotLottie; +//! selecting its manifest and mapping it to this document model remains the application's work. // The test documents are large `json!` literals. #![cfg_attr(test, recursion_limit = "512")] @@ -28,6 +28,7 @@ pub mod model; mod path; mod prop; mod value; +pub mod zip; pub use gradient::Gradient; pub use model::{Animated, Animation, Layer, LayerContent, Limits, Shape, ShapeKind}; diff --git a/crates/lottie/src/zip.rs b/crates/lottie/src/zip.rs new file mode 100644 index 000000000..05ebe0b70 --- /dev/null +++ b/crates/lottie/src/zip.rs @@ -0,0 +1,151 @@ +//! Bounded ZIP handling for dotLottie archives. +//! +//! The ZIP implementation delegates to `craft-zip`; this module applies dotLottie's relative +//! entry-name rule and caps archive expansion before returning entry bytes. + +use craft_zip::{Archive, Error, Writer}; +pub use craft_zip::{Compression, Limits}; + +/// Largest entry count accepted from a dotLottie archive. +pub const MAX_ENTRIES: u64 = 10_000; +/// Largest combined uncompressed payload returned from a dotLottie archive. +pub const MAX_TOTAL_BYTES: u64 = 1 << 30; + +/// Whether an entry name is a relative path inside the archive: not absolute (`/…`, `\\…`), no +/// drive (`C:…`) and no `..` component. +fn is_relative_name(name: &str) -> bool { + let drive = name.as_bytes().get(1) == Some(&b':'); + !name.starts_with(['/', '\\']) && !drive && !name.split(['/', '\\']).any(|c| c == "..") +} + +/// Read files from a dotLottie ZIP archive within [`MAX_ENTRIES`] and [`MAX_TOTAL_BYTES`]. +/// +/// Both stored and deflated entries are supported. Directories and names that escape the archive +/// root are skipped. Duplicate names and overlapping payloads are rejected because a consumer +/// must not guess which content a manifest or animation name identifies. +pub fn read_entries(data: &[u8]) -> Result)>, Error> { + read_entries_with_limits(data, Limits { max_entries: MAX_ENTRIES, max_total_bytes: MAX_TOTAL_BYTES }) +} + +/// Read a dotLottie archive under caller-provided entry and expansion limits. +pub fn read_entries_with_limits(data: &[u8], limits: Limits) -> Result)>, Error> { + let mut archive = Archive::open(data, limits)?; + if archive.has_duplicate_names() || archive.has_overlapping_files()? { + return Err(Error::NotZip("duplicate names or overlapping entry data".into())); + } + let mut out = Vec::new(); + for index in 0..archive.len() { + let (Some(name), Some(is_dir)) = (archive.entry_name(index)?, archive.entry_is_dir(index)?) else { continue }; + if is_dir || !is_relative_name(&name) { + continue; + } + if let Some(bytes) = archive.read(&name, limits.max_total_bytes)? { + out.push((name, bytes)); + } + } + Ok(out) +} + +/// Read the stored local-file entries in a ZIP written by [`store`]. This compatibility reader +/// retains its original behavior: compressed entries are skipped, an incomplete trailing entry +/// stops the walk while already-read entries are returned, and unsafe names are skipped. New code +/// should use [`read_entries`] for central-directory validation and deflate support. +pub fn read_stored(data: &[u8]) -> Vec<(String, Vec)> { + let u16_at = |p: usize| p.checked_add(2).and_then(|e| data.get(p..e)).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize); + let u32_at = |p: usize| p.checked_add(4).and_then(|e| data.get(p..e)).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize); + let mut out = Vec::new(); + let mut i = 0usize; + while u32_at(i) == Some(0x0403_4b50) { + let (Some(method), Some(size), Some(nlen), Some(xlen)) = ( + i.checked_add(8).and_then(u16_at), + i.checked_add(18).and_then(u32_at), + i.checked_add(26).and_then(u16_at), + i.checked_add(28).and_then(u16_at), + ) else { + break; + }; + let Some(name_start) = i.checked_add(30) else { break }; + let Some(name_end) = name_start.checked_add(nlen) else { break }; + let Some(body) = name_end.checked_add(xlen) else { break }; + let Some(body_end) = body.checked_add(size) else { break }; + let (Some(name), Some(bytes)) = (data.get(name_start..name_end), data.get(body..body_end)) else { break }; + let name = String::from_utf8_lossy(name).to_string(); + if method == 0 && is_relative_name(&name) { + out.push((name, bytes.to_vec())); + } + i = body_end; + } + out +} + +/// Write ZIP entries uncompressed, preserving input order and the legacy deterministic timestamp. +pub fn try_store(entries: &[(String, Vec)]) -> Result, Error> { + try_store_with_compression(entries, Compression::Stored) +} + +/// Write entries using the selected shared ZIP compression mode. +pub fn try_store_with_compression(entries: &[(String, Vec)], compression: Compression) -> Result, Error> { + let mut writer = Writer::legacy_utf8(); + for (name, bytes) in entries { + writer.add(name, bytes, compression)?; + } + writer.finish() +} + +/// Compatibility wrapper for the original infallible writer API. Use [`try_store`] when the +/// caller needs an actionable error for an invalid or unrepresentable archive. +pub fn store(entries: &[(String, Vec)]) -> Vec { + try_store(entries).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + + #[test] + fn stored_writer_roundtrips_and_keeps_entry_order() { + let entries = [("a.json".into(), b"{}".to_vec()), ("b/c.txt".into(), b"hello".to_vec())]; + let bytes = try_store(&entries).unwrap(); + assert_eq!(read_entries(&bytes).unwrap(), entries); + assert_eq!(bytes, store(&entries)); + } + + #[test] + fn deflated_entries_are_read_and_path_traversal_is_skipped() { + let mut writer = Writer::new(); + writer.add("animations/main.json", br#"{"w":1,"h":1}"#, Compression::Deflated).unwrap(); + writer.add("../outside.json", b"{}", Compression::Deflated).unwrap(); + let bytes = writer.finish().unwrap(); + assert_eq!(read_entries(&bytes).unwrap(), [("animations/main.json".into(), br#"{"w":1,"h":1}"#.to_vec())]); + assert!(read_entries_with_limits(&bytes, Limits { max_entries: 10, max_total_bytes: 4 }).is_err()); + } + + #[test] + fn malformed_and_truncated_archives_return_errors_without_panicking() { + let mut hostile = vec![0; 30]; + hostile[..4].copy_from_slice(&0x0403_4b50u32.to_le_bytes()); + hostile[8..10].copy_from_slice(&0u16.to_le_bytes()); + hostile[18..22].copy_from_slice(&u32::MAX.to_le_bytes()); + hostile[26..28].copy_from_slice(&u16::MAX.to_le_bytes()); + hostile[28..30].copy_from_slice(&u16::MAX.to_le_bytes()); + assert!(read_entries(&hostile).is_err()); + for end in 0..hostile.len() { + let _ = read_entries(&hostile[..end]); + } + } + + #[test] + fn legacy_stored_archive_bytes_and_partial_tail_are_compatible() { + const HEX: &str = "504b03041400000800000000210043bfa6a3020000000200000006000000612e6a736f6e7b7d504b03041400000800000000210086a61036050000000500000007000000622f632e74787468656c6c6f504b010214001400000800000000210043bfa6a30200000002000000060000000000000000000000000000000000612e6a736f6e504b010214001400000800000000210086a610360500000005000000070000000000000000000000000026000000622f632e747874504b0506000000000200020069000000500000000000"; + let legacy: Vec = HEX.as_bytes().chunks(2).map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()).collect(); + let entries = [("a.json".into(), b"{}".to_vec()), ("b/c.txt".into(), b"hello".to_vec())]; + assert_eq!(read_stored(&legacy), entries); + assert_eq!(read_entries(&legacy).unwrap(), entries); + assert_eq!(try_store(&entries).unwrap(), legacy); + let partial = &legacy[..50]; + assert_eq!(read_stored(partial), [("a.json".into(), b"{}".to_vec())]); + assert!(read_entries(partial).is_err()); + } +} diff --git a/crates/zip/src/write.rs b/crates/zip/src/write.rs index 2b32e1e74..e1fdfad9e 100644 --- a/crates/zip/src/write.rs +++ b/crates/zip/src/write.rs @@ -20,11 +20,12 @@ pub enum Compression { /// timestamp on every entry. Entries are written in the order they are added. pub struct Writer { zip: zip::ZipWriter>>, + legacy_utf8: bool, } impl Default for Writer { fn default() -> Writer { - Writer { zip: zip::ZipWriter::new(Cursor::new(Vec::new())) } + Writer { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), legacy_utf8: false } } } @@ -33,6 +34,14 @@ impl Writer { Writer::default() } + /// Writes the UTF-8 flag and version fields used by the previous dotLottie writer. + /// + /// This keeps stored archives byte-for-byte stable for existing users while still using the + /// shared writer for entry encoding and validation. + pub fn legacy_utf8() -> Writer { + Writer { legacy_utf8: true, ..Writer::default() } + } + /// Adds an entry from a byte slice. A name the archive already has is an error. pub fn add(&mut self, name: &str, data: &[u8], compression: Compression) -> Result<(), Error> { let (method, level) = match compression { @@ -70,6 +79,87 @@ impl Writer { /// The finished archive. pub fn finish(self) -> Result, Error> { - self.zip.finish().map(Cursor::into_inner).map_err(|e| Error::Write(e.to_string())) + let mut bytes = self.zip.finish().map(Cursor::into_inner).map_err(|e| Error::Write(e.to_string()))?; + if self.legacy_utf8 { + patch_legacy_utf8_headers(&mut bytes)?; + } + Ok(bytes) } } + +fn get_u16(bytes: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn get_u32(bytes: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +fn put_u32(bytes: &mut [u8], at: usize, value: u32) -> Result<(), Error> { + let dst = bytes + .get_mut(at..at.checked_add(4).ok_or_else(|| Error::Write("ZIP header offset overflow".into()))?) + .ok_or_else(|| Error::Write("short ZIP header".into()))?; + dst.copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +fn put_u16(bytes: &mut [u8], at: usize, value: u16) -> Result<(), Error> { + let dst = bytes + .get_mut(at..at.checked_add(2).ok_or_else(|| Error::Write("ZIP header offset overflow".into()))?) + .ok_or_else(|| Error::Write("short ZIP header".into()))?; + dst.copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +/// Patches the two header versions and UTF-8 bit to match the former dotLottie writer. +fn patch_legacy_utf8_headers(bytes: &mut [u8]) -> Result<(), Error> { + const EOCD: u32 = 0x0605_4b50; + const CENTRAL: u32 = 0x0201_4b50; + const LOCAL: u32 = 0x0403_4b50; + let eocd = bytes + .windows(4) + .rposition(|window| u32::from_le_bytes([window[0], window[1], window[2], window[3]]) == EOCD) + .ok_or_else(|| Error::Write("missing ZIP end record".into()))?; + let central_start = usize::try_from(get_u32(bytes, eocd + 16).ok_or_else(|| Error::Write("short ZIP end record".into()))?) + .map_err(|_| Error::Write("ZIP central directory offset overflow".into()))?; + let central_size = usize::try_from(get_u32(bytes, eocd + 12).ok_or_else(|| Error::Write("short ZIP end record".into()))?) + .map_err(|_| Error::Write("ZIP central directory size overflow".into()))?; + let central_end = + central_start.checked_add(central_size).filter(|end| *end <= eocd).ok_or_else(|| Error::Write("invalid ZIP central directory".into()))?; + let mut at = central_start; + while at < central_end { + if get_u32(bytes, at) != Some(CENTRAL) { + return Err(Error::Write("invalid ZIP central directory entry".into())); + } + let name = usize::from(get_u16(bytes, at + 28).ok_or_else(|| Error::Write("short ZIP entry".into()))?); + let extra = usize::from(get_u16(bytes, at + 30).ok_or_else(|| Error::Write("short ZIP entry".into()))?); + let comment = usize::from(get_u16(bytes, at + 32).ok_or_else(|| Error::Write("short ZIP entry".into()))?); + let length = 46usize + .checked_add(name) + .and_then(|n| n.checked_add(extra)) + .and_then(|n| n.checked_add(comment)) + .ok_or_else(|| Error::Write("ZIP entry size overflow".into()))?; + let next = at.checked_add(length).filter(|next| *next <= central_end).ok_or_else(|| Error::Write("short ZIP entry".into()))?; + let local = usize::try_from(get_u32(bytes, at + 42).ok_or_else(|| Error::Write("short ZIP entry".into()))?) + .map_err(|_| Error::Write("ZIP local-header offset overflow".into()))?; + if get_u32(bytes, local) != Some(LOCAL) { + return Err(Error::Write("invalid ZIP local header".into())); + } + // Version 45 is required by ZIP64; retain it. Ordinary entries use 2.0 to match dotLottie. + if get_u16(bytes, at + 6).is_some_and(|version| version < 45) { + put_u16(bytes, at + 6, 20)?; + put_u16(bytes, local + 4, 20)?; + } + put_u16(bytes, at + 4, 20)?; + put_u32(bytes, at + 38, 0)?; + let central_flags = get_u16(bytes, at + 8).ok_or_else(|| Error::Write("short ZIP entry".into()))? | 0x0800; + let local_flags = get_u16(bytes, local + 6).ok_or_else(|| Error::Write("short ZIP local header".into()))? | 0x0800; + put_u16(bytes, at + 8, central_flags)?; + put_u16(bytes, local + 6, local_flags)?; + at = next; + } + if at != central_end { + return Err(Error::Write("invalid ZIP central directory size".into())); + } + Ok(()) +}