mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Merge remote-tracking branch 'origin/main' into fix/dxf-spline-paths
This commit is contained in:
commit
256ebd30cd
5 files changed
+249
-5
No files matched your search
Generated
+1
@@ -1764,6 +1764,7 @@ dependencies = [
|
||||
name = "craft-lottie"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-zip",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Vec<(String, Vec<u8>)>, 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<Vec<(String, Vec<u8>)>, 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<u8>)> {
|
||||
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<u8>)]) -> Result<Vec<u8>, 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<u8>)], compression: Compression) -> Result<Vec<u8>, 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<u8>)]) -> Vec<u8> {
|
||||
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<u8> = 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());
|
||||
}
|
||||
}
|
||||
+92
-2
@@ -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<Cursor<Vec<u8>>>,
|
||||
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<Vec<u8>, 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<u16> {
|
||||
Some(u16::from_le_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn get_u32(bytes: &[u8], at: usize) -> Option<u32> {
|
||||
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(())
|
||||
}
|
||||
Reference in new issue
Block a user