From 111b522d05bcc75bb4f15eaa91496eb5f9c6a54f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:41:10 +0000 Subject: [PATCH] feat(figcraft-format): add bounded native archive access --- Cargo.lock | 10 +++ crates/figcraft-format/Cargo.toml | 1 + crates/figcraft-format/src/lib.rs | 137 +++++++++++++++++++++++++++--- 3 files changed, 136 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf0e0bd47..fda532570 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1445,6 +1445,16 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-figcraft-format" +version = "0.1.0" +dependencies = [ + "craft-zip", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-figma-rest" version = "0.1.0" diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml index 3275fbcd6..22682034b 100644 --- a/crates/figcraft-format/Cargo.toml +++ b/crates/figcraft-format/Cargo.toml @@ -13,4 +13,5 @@ workspace = true [dependencies] serde = { workspace = true } serde_json = { workspace = true } +craft-zip = { workspace = true } thiserror = { workspace = true } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 5df23cf24..fd2c6ecf2 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -10,23 +10,37 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Header { pub version: Option, pub images: Option> } +pub struct Header { + pub version: Option, + pub images: Option>, +} -pub fn read_header(text: &str) -> Result { serde_json::from_str(text) } +pub fn read_header(text: &str) -> Result { + serde_json::from_str(text) +} impl<'de> Deserialize<'de> for Header { fn deserialize>(d: D) -> Result { struct V; impl<'de> Visitor<'de> for V { type Value = Header; - fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("a FigCraft document object") } + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a FigCraft document object") + } fn visit_map>(self, mut m: A) -> Result { let mut h = Header::default(); while let Some(k) = m.next_key::()? { match k.as_str() { "version" => h.version = m.next_value::()?.as_u64(), - "imageIndex" => h.images = m.next_value::()?.as_array().map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect()), - _ => { m.next_value::()?; } + "imageIndex" => { + h.images = m + .next_value::()? + .as_array() + .map(|a| a.iter().filter_map(|v| v.get("hash").and_then(Value::as_str).map(str::to_owned)).collect()) + } + _ => { + m.next_value::()?; + } } } Ok(h) @@ -41,16 +55,31 @@ impl<'de> Deserialize<'de> for Header { pub struct NewerVersion(pub u32); pub fn check_version(h: &Header) -> Result<(), NewerVersion> { - if let Some(v) = h.version && v > u64::from(VERSION) { return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX))); } + if let Some(v) = h.version + && v > u64::from(VERSION) + { + return Err(NewerVersion(u32::try_from(v).unwrap_or(u32::MAX))); + } Ok(()) } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct ImageIndexEntry { pub hash: String, pub width: u32, pub height: u32 } +pub struct ImageIndexEntry { + pub hash: String, + pub width: u32, + pub height: u32, +} pub fn write_document_json(doc: &D, images: &[ImageIndexEntry], w: W) -> Result<(), serde_json::Error> { #[derive(Serialize)] - struct Flat<'a, D: ?Sized> { format: &'static str, version: u32, #[serde(rename="imageIndex")] images: &'a [ImageIndexEntry], #[serde(flatten)] document: &'a D } + struct Flat<'a, D: ?Sized> { + format: &'static str, + version: u32, + #[serde(rename = "imageIndex")] + images: &'a [ImageIndexEntry], + #[serde(flatten)] + document: &'a D, + } Flat { format: FORMAT, version: VERSION, images, document: doc }.serialize(&mut serde_json::Serializer::new(w)) } @@ -60,7 +89,8 @@ mod tests { #[test] fn header_skips_app_fields() { let h = read_header(r#"{"version":1,"imageIndex":[{"hash":"abc"}],"nested":{"unused":true}}"#).unwrap(); - assert_eq!(h.version, Some(1)); assert_eq!(h.images, Some(vec!["abc".into()])); + assert_eq!(h.version, Some(1)); + assert_eq!(h.images, Some(vec!["abc".into()])); } #[test] fn newer_version_is_reported_before_document_decode() { @@ -69,9 +99,92 @@ mod tests { } #[test] fn writer_flattens_document_fields() { - let doc = serde_json::json!({"name":"example"}); let mut bytes=Vec::new(); + let doc = serde_json::json!({"name":"example"}); + let mut bytes = Vec::new(); write_document_json(&doc, &[], &mut bytes).unwrap(); - let v: Value=serde_json::from_slice(&bytes).unwrap(); - assert_eq!(v["format"], FORMAT); assert_eq!(v["version"], VERSION); assert_eq!(v["name"], "example"); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["format"], FORMAT); + assert_eq!(v["version"], VERSION); + assert_eq!(v["name"], "example"); + } +} + +/// Limits for the native ZIP package reader. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ArchiveLimits { + pub max_entries: u64, + pub max_total_bytes: u64, +} + +/// A bounded reader for a native `.figcraft` ZIP package. +pub struct Archive<'a> { + inner: craft_zip::Archive<'a>, +} + +impl<'a> Archive<'a> { + pub fn open(bytes: &'a [u8], limits: ArchiveLimits) -> Result { + let inner = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: limits.max_entries, max_total_bytes: limits.max_total_bytes })?; + Ok(Self { inner }) + } + + pub fn names(&self) -> impl Iterator> { + self.inner.names() + } + + pub fn has_duplicate_names(&self) -> bool { + self.inner.has_duplicate_names() + } + + pub fn contains(&self, name: &str) -> bool { + self.inner.contains(name) + } + + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + self.inner.read(name, max_bytes) + } + + pub fn budget_used(&self) -> u64 { + self.inner.budget().used() + } +} + +/// One member to write into a native ZIP package. +#[derive(Clone, Copy, Debug)] +pub struct Member<'a> { + pub name: &'a str, + pub bytes: &'a [u8], + pub compression: craft_zip::Compression, +} + +/// Write a deterministic native ZIP package using the shared ZIP writer. +pub fn write_archive<'a>(members: impl IntoIterator>) -> Result, craft_zip::Error> { + let mut writer = craft_zip::Writer::new(); + for member in members { + writer.add(member.name, member.bytes, member.compression)?; + } + writer.finish() +} + +#[cfg(test)] +mod archive_tests { + use super::*; + + #[test] + fn bounded_archive_reader_and_writer_round_trip_members() { + let bytes = + write_archive([Member { name: "document.json", bytes: br#"{"version":1}"#, compression: craft_zip::Compression::Deflated }]).unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap(); + assert!(archive.contains("document.json")); + assert_eq!(archive.read("document.json", 32).unwrap(), Some(br#"{"version":1}"#.to_vec())); + assert_eq!(archive.budget_used(), 13); + } + + #[test] + fn archive_reader_enforces_entry_and_total_limits() { + let bytes = write_archive([Member { name: "document.json", bytes: b"123456789", compression: craft_zip::Compression::Deflated }]).unwrap(); + let mut small_entry = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 100 }).unwrap(); + assert!(small_entry.read("document.json", 8).is_err()); + let mut small_total = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 8 }).unwrap(); + assert!(small_total.read("document.json", 100).is_err()); } }