From 8cdd25ce0a307bbe073e2af217d68068ec558f95 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:38:38 +0000 Subject: [PATCH 01/18] feat(figcraft-format): add native document header contract --- Cargo.lock | 9 ++++ Cargo.toml | 1 + crates/figcraft-format/Cargo.toml | 16 +++++++ crates/figcraft-format/src/lib.rs | 77 +++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 crates/figcraft-format/Cargo.toml create mode 100644 crates/figcraft-format/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 266217a1a..1bbfe7965 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-figcraft-format" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-font-catalog" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a32e1df27..b07dac4be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ craft-tiff = { path = "crates/tiff" } craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-vectorcraft-format = { path = "crates/vectorcraft-format" } +craft-figcraft-format = { path = "crates/figcraft-format" } craft-xml = { path = "crates/xml" } craft-video = { path = "crates/video" } craft-web-api = { path = "crates/web-api" } diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml new file mode 100644 index 000000000..3275fbcd6 --- /dev/null +++ b/crates/figcraft-format/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-figcraft-format" +description = "The native FigCraft document JSON and archive contract." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs new file mode 100644 index 000000000..5df23cf24 --- /dev/null +++ b/crates/figcraft-format/src/lib.rs @@ -0,0 +1,77 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! The neutral JSON header and version contract for FigCraft's native project format. +//! App-specific document fields remain a generic flattened payload. +use serde::de::{IgnoredAny, MapAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const EXTENSION: &str = "figcraft"; +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 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 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::()?; } + } + } + Ok(h) + } + } + d.deserialize_map(V) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("this file was saved by a newer FigCraft (format version {0})")] +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))); } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +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 } + Flat { format: FORMAT, version: VERSION, images, document: doc }.serialize(&mut serde_json::Serializer::new(w)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[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()])); + } + #[test] + fn newer_version_is_reported_before_document_decode() { + assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); + assert_eq!(check_version(&Header { version: Some(u64::MAX), images: None }), Err(NewerVersion(u32::MAX))); + } + #[test] + fn writer_flattens_document_fields() { + 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"); + } +} From 111b522d05bcc75bb4f15eaa91496eb5f9c6a54f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:41:10 +0000 Subject: [PATCH 02/18] 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()); } } From 30b1e426e0ef07e0606e0ab09ec540eca5665bb6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:49:46 +0000 Subject: [PATCH 03/18] feat(figcraft-format): stream native document JSON --- crates/figcraft-format/src/lib.rs | 57 +++++++++++++++++++++++++++++++ crates/zip/src/write.rs | 18 +++++----- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index fd2c6ecf2..08b9bcb9a 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -188,3 +188,60 @@ mod archive_tests { assert!(small_total.read("document.json", 100).is_err()); } } + +/// A native archive writer that keeps the streaming document serializer out of the consumer. +pub struct ArchiveWriter { + inner: craft_zip::Writer, +} + +impl ArchiveWriter { + pub fn new() -> Self { + Self { inner: craft_zip::Writer::new() } + } + + pub fn add(&mut self, name: &str, bytes: &[u8], compression: craft_zip::Compression) -> Result<(), craft_zip::Error> { + self.inner.add(name, bytes, compression) + } + + pub fn add_document_json(&mut self, name: &str, document: &D, images: &[ImageIndexEntry]) -> Result<(), ArchiveWriteError> { + self.inner + .add_with(name, craft_zip::Compression::Level(3), |w| write_document_json(document, images, w).map_err(std::io::Error::other)) + .map_err(ArchiveWriteError::Archive) + } + + pub fn finish(self) -> Result, craft_zip::Error> { + self.inner.finish() + } +} + +impl Default for ArchiveWriter { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ArchiveWriteError { + #[error("could not serialize native document JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("could not write native archive: {0}")] + Archive(#[from] craft_zip::Error), +} + +#[cfg(test)] +mod streaming_writer_tests { + use super::*; + + #[test] + fn native_writer_streams_flat_document_into_archive() { + let document = serde_json::json!({"name":"streamed"}); + let mut writer = ArchiveWriter::new(); + writer.add_document_json("document.json", &document, &[]).unwrap(); + let bytes = writer.finish().unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 4096 }).unwrap(); + let json = archive.read("document.json", 1024).unwrap().unwrap(); + let value: Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(value["name"], "streamed"); + assert_eq!(value["format"], FORMAT); + } +} diff --git a/crates/zip/src/write.rs b/crates/zip/src/write.rs index 2f5d7ca47..12838a3b8 100644 --- a/crates/zip/src/write.rs +++ b/crates/zip/src/write.rs @@ -33,22 +33,24 @@ impl Writer { Writer::default() } - /// Adds an entry. A name the archive already has is an error. + /// 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> { + self.add_with(name, compression, |writer| writer.write_all(data)) + } + + /// Adds an entry by streaming it into the compressor, so large generated entries need not + /// first be buffered in memory. A name the archive already has is an error. + pub fn add_with(&mut self, name: &str, compression: Compression, write: impl FnOnce(&mut dyn Write) -> std::io::Result<()>) -> Result<(), Error> { let (method, level) = match compression { Compression::Stored => (zip::CompressionMethod::Stored, None), Compression::Deflated => (zip::CompressionMethod::Deflated, Some(6)), Compression::Level(l) => (zip::CompressionMethod::Deflated, Some(i64::from(l.min(9)))), }; - let options = zip::write::SimpleFileOptions::default() - .compression_method(method) - .compression_level(level) - .last_modified_time(zip::DateTime::default()) - // Deflate can grow incompressible data a little, so ZIP64 well before 4 GiB. - .large_file(data.len() as u64 >= u64::from(u32::MAX) / 2); + let options = + zip::write::SimpleFileOptions::default().compression_method(method).compression_level(level).last_modified_time(zip::DateTime::default()); let fail = |e: &dyn std::fmt::Display| Error::Write(format!("{name}: {e}")); self.zip.start_file(name, options).map_err(|e| fail(&e))?; - self.zip.write_all(data).map_err(|e| fail(&e)) + write(&mut self.zip).map_err(|e| fail(&e)) } /// The finished archive. From 644e613b06ef267eed75b6fbdccfddc7217d599f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:52:59 +0000 Subject: [PATCH 04/18] feat(figcraft-format): expose native member sizes --- crates/figcraft-format/src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 08b9bcb9a..df61cd065 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -139,6 +139,14 @@ impl<'a> Archive<'a> { self.inner.contains(name) } + pub fn declared_size(&mut self, name: &str) -> Option { + self.inner.declared_size(name) + } + + pub fn compressed_size(&mut self, name: &str) -> Option { + self.inner.compressed_size(name) + } + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { self.inner.read(name, max_bytes) } From f8acbd5832f661f7ab53628b007d8a431d5b7a7e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:55:26 +0000 Subject: [PATCH 05/18] feat(figcraft-format): expose archive compression modes --- crates/figcraft-format/src/lib.rs | 3 +++ crates/zip/src/write.rs | 21 ++++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index df61cd065..19e5f1175 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -253,3 +253,6 @@ mod streaming_writer_tests { assert_eq!(value["format"], FORMAT); } } + +/// ZIP compression modes for native archive members. +pub use craft_zip::Compression; diff --git a/crates/zip/src/write.rs b/crates/zip/src/write.rs index 12838a3b8..2b32e1e74 100644 --- a/crates/zip/src/write.rs +++ b/crates/zip/src/write.rs @@ -35,7 +35,19 @@ impl Writer { /// 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> { - self.add_with(name, compression, |writer| writer.write_all(data)) + let (method, level) = match compression { + Compression::Stored => (zip::CompressionMethod::Stored, None), + Compression::Deflated => (zip::CompressionMethod::Deflated, Some(6)), + Compression::Level(l) => (zip::CompressionMethod::Deflated, Some(i64::from(l.min(9)))), + }; + let options = zip::write::SimpleFileOptions::default() + .compression_method(method) + .compression_level(level) + .last_modified_time(zip::DateTime::default()) + .large_file(data.len() as u64 >= u64::from(u32::MAX) / 2); + let fail = |e: &dyn std::fmt::Display| Error::Write(format!("{name}: {e}")); + self.zip.start_file(name, options).map_err(|e| fail(&e))?; + self.zip.write_all(data).map_err(|e| fail(&e)) } /// Adds an entry by streaming it into the compressor, so large generated entries need not @@ -46,8 +58,11 @@ impl Writer { Compression::Deflated => (zip::CompressionMethod::Deflated, Some(6)), Compression::Level(l) => (zip::CompressionMethod::Deflated, Some(i64::from(l.min(9)))), }; - let options = - zip::write::SimpleFileOptions::default().compression_method(method).compression_level(level).last_modified_time(zip::DateTime::default()); + let options = zip::write::SimpleFileOptions::default() + .compression_method(method) + .compression_level(level) + .last_modified_time(zip::DateTime::default()) + .large_file(true); let fail = |e: &dyn std::fmt::Display| Error::Write(format!("{name}: {e}")); self.zip.start_file(name, options).map_err(|e| fail(&e))?; write(&mut self.zip).map_err(|e| fail(&e)) From d6f65e29e024ab8ce788d89247ae6907b7f7838b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:58:38 +0000 Subject: [PATCH 06/18] feat(figcraft-format): support seekable native readers --- Cargo.lock | 1 + crates/figcraft-format/Cargo.toml | 1 + crates/figcraft-format/src/lib.rs | 106 ++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fda532570..d0e9f1e6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1453,6 +1453,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml index 22682034b..009e40c01 100644 --- a/crates/figcraft-format/Cargo.toml +++ b/crates/figcraft-format/Cargo.toml @@ -14,4 +14,5 @@ workspace = true serde = { workspace = true } serde_json = { workspace = true } craft-zip = { workspace = true } +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } thiserror = { workspace = true } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 19e5f1175..9e973475e 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -256,3 +256,109 @@ mod streaming_writer_tests { /// ZIP compression modes for native archive members. pub use craft_zip::Compression; + +/// A native ZIP reader for seekable sources that should not be copied into memory first. +pub struct SeekArchive { + inner: zip::ZipArchive, +} + +impl SeekArchive { + pub fn open(reader: R, max_entries: u64) -> Result { + let inner = zip::ZipArchive::new(reader).map_err(|e| craft_zip::Error::NotZip(e.to_string()))?; + let count = inner.len() as u64; + if count > max_entries { + return Err(craft_zip::Error::TooManyEntries { declared: count, limit: max_entries }); + } + Ok(Self { inner }) + } + + pub fn contains(&self, name: &str) -> bool { + self.inner.index_for_name(name).is_some() + } + + pub fn names(&self) -> Vec { + self.inner.file_names().filter_map(Result::ok).map(|n| n.into_owned()).collect() + } + + pub fn declared_size(&mut self, name: &str) -> Option { + self.inner.index_for_name(name).and_then(|i| self.inner.by_index_raw(i).ok().map(|f| f.size())) + } + + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + let Some(index) = self.inner.index_for_name(name) else { return Ok(None) }; + let mut file = self.inner.by_index(index).map_err(|e| craft_zip::Error::Entry { name: name.to_owned(), msg: e.to_string() })?; + if file.size() > max_bytes { + return Err(craft_zip::Error::EntryTooLarge { name: name.to_owned(), limit: max_bytes }); + } + let mut bytes = Vec::new(); + let mut limited = std::io::Read::take(&mut file, max_bytes.saturating_add(1)); + let read = std::io::Read::read_to_end(&mut limited, &mut bytes); + if let Err(error) = read { + return Err(craft_zip::Error::Entry { name: name.to_owned(), msg: error.to_string() }); + } + if bytes.len() as u64 > max_bytes { + return Err(craft_zip::Error::EntryTooLarge { name: name.to_owned(), limit: max_bytes }); + } + Ok(Some(bytes)) + } +} + +/// Shared access contract implemented by in-memory and seekable native archive readers. +pub trait NativeArchive { + fn contains(&self, name: &str) -> bool; + fn names(&self) -> Vec; + fn declared_size(&mut self, name: &str) -> Option; + fn compressed_size(&mut self, name: &str) -> Option; + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error>; +} + +impl NativeArchive for Archive<'_> { + fn contains(&self, name: &str) -> bool { + Archive::contains(self, name) + } + fn names(&self) -> Vec { + Archive::names(self).map(|n| n.into_owned()).collect() + } + fn declared_size(&mut self, name: &str) -> Option { + Archive::declared_size(self, name) + } + fn compressed_size(&mut self, name: &str) -> Option { + Archive::compressed_size(self, name) + } + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + Archive::read(self, name, max_bytes) + } +} + +impl NativeArchive for SeekArchive { + fn contains(&self, name: &str) -> bool { + SeekArchive::contains(self, name) + } + fn names(&self) -> Vec { + SeekArchive::names(self) + } + fn declared_size(&mut self, name: &str) -> Option { + SeekArchive::declared_size(self, name) + } + fn compressed_size(&mut self, name: &str) -> Option { + self.inner.index_for_name(name).and_then(|i| self.inner.by_index_raw(i).ok().map(|f| f.compressed_size())) + } + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + SeekArchive::read(self, name, max_bytes) + } +} + +#[cfg(test)] +mod seek_archive_tests { + use super::*; + + #[test] + fn seekable_reader_matches_bounded_in_memory_reader() { + let bytes = write_archive([Member { name: "document.json", bytes: b"payload", compression: Compression::Stored }]).unwrap(); + let mut reader = SeekArchive::open(std::io::Cursor::new(bytes), 4).unwrap(); + assert!(reader.contains("document.json")); + assert_eq!(reader.read_member("document.json", 16).unwrap(), Some(b"payload".to_vec())); + assert_eq!(reader.declared_size("document.json"), Some(7)); + assert!(reader.read_member("document.json", 3).is_err()); + } +} From e8628c526ea74ae09144508f666bf6dd92f18029 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:02:49 +0000 Subject: [PATCH 07/18] feat(figcraft-format): own native archive policy --- crates/figcraft-format/src/lib.rs | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 9e973475e..a06b39204 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -362,3 +362,60 @@ mod seek_archive_tests { assert!(reader.read_member("document.json", 3).is_err()); } } + +/// Largest individual ZIP member read by the native format. +pub const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// Most version snapshots kept on save and read on open. +pub const MAX_VERSIONS: usize = 100; +/// Maximum version and branch name lengths, in Unicode scalar values. +pub const MAX_VERSION_NAME: usize = 200; +pub const MAX_VERSION_DESCRIPTION: usize = 2000; +/// Maximum bytes of an index and all version snapshots read. +pub const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; +pub const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; +/// Maximum images read and maximum combined image bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ImageBudget { + pub images: usize, + pub bytes: u64, +} +pub const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 }; +/// Maximum branch histories kept. +pub const MAX_BRANCHES: usize = 100; +/// The branch index member. +pub const BRANCH_INDEX: &str = "branches/index.json"; +/// Maximum uncompressed bytes read from one native archive in total. +#[cfg(not(target_pointer_width = "32"))] +pub const MAX_UNPACKED: u64 = 6 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +pub const MAX_UNPACKED: u64 = 2 * 1024 * 1024 * 1024; + +/// Native-format errors shared with the consumer's document mapping. +#[derive(Debug, PartialEq, Eq)] +pub enum FormatError { + Io(String), + Corrupt(String), + Newer(u32), +} + +impl std::fmt::Display for FormatError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "{e}"), + Self::Corrupt(e) => write!(f, "not a valid FigCraft file: {e}"), + Self::Newer(v) => write!(f, "this file was saved by a newer FigCraft (format version {v})"), + } + } +} + +impl std::error::Error for FormatError {} + +/// Whether `id` can name a version or branch: 1–64 ASCII letters, digits, `-` and `_`. +pub fn is_valid_version_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// Clip text to at most `max` Unicode scalar values. +pub fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} From 68189d1e6e1a926db4eaa2d7843f35174cb75315 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:03:35 +0000 Subject: [PATCH 08/18] feat(figcraft-format): define document expansion policy --- crates/figcraft-format/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index a06b39204..8fddd808d 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -365,6 +365,12 @@ mod seek_archive_tests { /// Largest individual ZIP member read by the native format. pub const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// The document expansion ratio that its compressed size may claim. +pub const DOCUMENT_RATIO: u64 = 16; +#[cfg(not(target_pointer_width = "32"))] +pub const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +pub const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; /// Most version snapshots kept on save and read on open. pub const MAX_VERSIONS: usize = 100; /// Maximum version and branch name lengths, in Unicode scalar values. From 07bac57ec2bfe1633f1b2fbb8047cdb3d2096729 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 07:58:41 +0000 Subject: [PATCH 09/18] add shared FigCraft history and branch schemas --- crates/figcraft-format/src/lib.rs | 146 +++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 8fddd808d..5a53ea614 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -2,13 +2,129 @@ //! The neutral JSON header and version contract for FigCraft's native project format. //! App-specific document fields remain a generic flattened payload. use serde::de::{IgnoredAny, MapAccess, Visitor}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; pub const EXTENSION: &str = "figcraft"; pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; +fn is_false(value: &bool) -> bool { + !value +} + +/// One native version-history index entry. Invalid optional values decode to the same defaults as +/// the historical lenient reader; the app applies document-specific validity and issue reporting. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct VersionEntry { + pub id: String, + pub name: String, + pub description: String, + pub time: u64, + #[serde(skip_serializing_if = "is_false")] + pub auto: bool, + pub images: Vec, +} + +impl<'de> Deserialize<'de> for VersionEntry { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let string = |key: &str| value.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + let images = value.get("images").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + Ok(Self { + id: string("id"), + name: string("name"), + description: string("description"), + time: value.get("time").and_then(Value::as_u64).unwrap_or(0), + auto: value.get("auto").and_then(Value::as_bool).unwrap_or(false), + images, + }) + } +} + +/// Native version-history index, oldest first. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct VersionIndex { + pub versions: Vec, +} + +impl<'de> Deserialize<'de> for VersionIndex { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let versions = value + .get("versions") + .and_then(Value::as_array) + .map(|entries| entries.iter().cloned().map(serde_json::from_value).collect::, _>>()) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + Ok(Self { versions }) + } +} + +/// State stored on a native branch entry. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BranchState { + #[default] + Active, + Merged, + Archived, +} + +/// One native branch index entry. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BranchEntry { + pub id: String, + pub name: String, + pub time: u64, + pub client: u32, + pub state: BranchState, + pub base_images: Vec, + pub head_images: Vec, +} + +impl<'de> Deserialize<'de> for BranchEntry { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let string = |key: &str| value.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + let images = + |key: &str| value.get(key).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + let state = value.get("state").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let client = value.get("client").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + Ok(Self { + id: string("id"), + name: string("name"), + time: value.get("time").and_then(Value::as_u64).unwrap_or(0), + client, + state, + base_images: images("baseImages"), + head_images: images("headImages"), + }) + } +} + +/// Native branch index. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct BranchIndex { + pub branches: Vec, +} + +impl<'de> Deserialize<'de> for BranchIndex { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + let branches = value + .get("branches") + .and_then(Value::as_array) + .map(|entries| entries.iter().cloned().map(serde_json::from_value).collect::, _>>()) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + Ok(Self { branches }) + } +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Header { pub version: Option, @@ -92,6 +208,34 @@ mod tests { assert_eq!(h.version, Some(1)); assert_eq!(h.images, Some(vec!["abc".into()])); } + + #[test] + fn version_index_preserves_lenient_defaults_and_writes_compact_auto_flag() { + let index: VersionIndex = + serde_json::from_str(r#"{"extra":true,"versions":[{"id":"v1","time":7,"auto":false,"images":["a",3]},{}]}"#).unwrap(); + assert_eq!(index.versions.len(), 2); + assert_eq!(index.versions[0].images, vec!["a"]); + assert_eq!(index.versions[1], VersionEntry::default()); + let encoded = serde_json::to_value(&index).unwrap(); + assert!(encoded["versions"][0].get("auto").is_none()); + assert_eq!(encoded["versions"][0]["time"], 7); + } + + #[test] + fn branch_index_uses_native_camel_case_and_tolerates_invalid_optional_fields() { + let index: BranchIndex = serde_json::from_str( + r#"{"branches":[{"id":"b1","client":4294967296,"state":"unknown","baseImages":["a",false],"headImages":["b"]},{}]}"#, + ) + .unwrap(); + let first = &index.branches[0]; + assert_eq!(first.client, 0); + assert_eq!(first.state, BranchState::Active); + assert_eq!(first.base_images, vec!["a"]); + assert_eq!(first.head_images, vec!["b"]); + let encoded = serde_json::to_value(&index).unwrap(); + assert_eq!(encoded["branches"][0]["baseImages"][0], "a"); + assert!(encoded["branches"][0].get("base_images").is_none()); + } #[test] fn newer_version_is_reported_before_document_decode() { assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); From a75b9df240d3e2eb98898fcbf99403ae5a1c73bb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 08:00:56 +0000 Subject: [PATCH 10/18] share FigCraft document expansion policy --- crates/figcraft-format/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 5a53ea614..f086d4728 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -236,6 +236,16 @@ mod tests { assert_eq!(encoded["branches"][0]["baseImages"][0], "a"); assert!(encoded["branches"][0].get("base_images").is_none()); } + + #[test] + fn document_limit_keeps_compressible_files_and_caps_expansion() { + const MB: u64 = 1024 * 1024; + assert_eq!(document_limit(0, 0), MAX_MEMBER); + assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER); + assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER); + assert_eq!(document_limit(MAX_DOCUMENT, MAX_DOCUMENT), MAX_DOCUMENT); + assert_eq!(document_limit(244_362_923, 260 * MB), 244_362_923 * DOCUMENT_RATIO); + } #[test] fn newer_version_is_reported_before_document_decode() { assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); @@ -515,6 +525,13 @@ pub const DOCUMENT_RATIO: u64 = 16; pub const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; #[cfg(target_pointer_width = "32")] pub const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; + +/// Upper bound for the expanded document member based on both the archive and member policy. +/// The floor preserves support for large but highly compressible documents; the ceiling prevents +/// a tiny archive from claiming an unbounded expansion. +pub fn document_limit(packed: u64, archive_bytes: u64) -> u64 { + packed.min(archive_bytes).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT) +} /// Most version snapshots kept on save and read on open. pub const MAX_VERSIONS: usize = 100; /// Maximum version and branch name lengths, in Unicode scalar values. From d249336e76465ccbccf8b6570a9ed10a27b2cb1d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 08:10:02 +0000 Subject: [PATCH 11/18] move native archive read budgets into shared format --- crates/figcraft-format/src/lib.rs | 59 +++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index f086d4728..c7e7261aa 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -246,6 +246,23 @@ mod tests { assert_eq!(document_limit(MAX_DOCUMENT, MAX_DOCUMENT), MAX_DOCUMENT); assert_eq!(document_limit(244_362_923, 260 * MB), 244_362_923 * DOCUMENT_RATIO); } + + #[test] + fn read_budget_enforces_member_and_archive_totals() { + let bytes = write_archive([ + Member { name: "one", bytes: b"12345678", compression: Compression::Stored }, + Member { name: "two", bytes: b"abcdefgh", compression: Compression::Stored }, + ]) + .unwrap(); + let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap(); + let mut budget = ReadBudget::new(10); + assert_eq!(budget.read(&mut archive, "one", 16).unwrap(), b"12345678"); + assert!(budget.read(&mut archive, "two", 16).unwrap_err().to_string().contains("contents together pass 10 bytes")); + + let mut member_budget = ReadBudget::new(100); + assert!(member_budget.read(&mut archive, "two", 4).unwrap_err().to_string().contains("two is too large")); + assert_eq!(member_budget.read(&mut archive, "two", 16).unwrap(), b"abcdefgh"); + } #[test] fn newer_version_is_reported_before_document_decode() { assert_eq!(check_version(&Header { version: Some(2), images: None }), Err(NewerVersion(2))); @@ -577,6 +594,48 @@ impl std::fmt::Display for FormatError { impl std::error::Error for FormatError {} +/// Tracks the total bytes unpacked from one native archive across its document, images, library, +/// history, and branch members. +#[derive(Clone, Debug)] +pub struct ReadBudget { + left: u64, + total: u64, +} + +impl ReadBudget { + pub fn new(total: u64) -> Self { + Self { left: total, total } + } + + fn passed(&self) -> String { + match self.total / (1024 * 1024) { + 0 => format!("too large: the file's contents together pass {} bytes", self.total), + mb => format!("too large: the file's contents together pass {mb} MB"), + } + } + + /// Read one native member within its own size limit and the remaining archive-wide budget. + /// A failed read that consumed a member is charged at its declared size. + pub fn read(&mut self, archive: &mut impl NativeArchive, name: &str, limit: u64) -> Result, FormatError> { + let declared = archive.declared_size(name); + if declared.is_some_and(|size| size > self.left && size <= limit) { + return Err(FormatError::Corrupt(format!("{name}: {}", self.passed()))); + } + let cap = limit.min(self.left); + let size = declared.unwrap_or(0); + let result = match archive.read_member(name, cap) { + Ok(Some(bytes)) => Ok(bytes), + Ok(None) => Err(FormatError::Corrupt(format!("{name}: not found"))), + Err(error) if error.is_limit() => Err(FormatError::Corrupt(format!("{name} is too large"))), + Err(error) => Err(FormatError::Corrupt(format!("{name}: {error}"))), + }; + if size <= cap { + self.left = self.left.saturating_sub(result.as_ref().map_or(size, |bytes| bytes.len() as u64)); + } + result + } +} + /// Whether `id` can name a version or branch: 1–64 ASCII letters, digits, `-` and `_`. pub fn is_valid_version_id(id: &str) -> bool { (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') From 1592923e6db6ed1458f7a4f2982b6e9db4057224 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 08:15:45 +0000 Subject: [PATCH 12/18] expose archive budget remaining diagnostics --- crates/figcraft-format/src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index c7e7261aa..3dc8e3a32 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -257,6 +257,8 @@ mod tests { let mut archive = Archive::open(&bytes, ArchiveLimits { max_entries: 4, max_total_bytes: 128 }).unwrap(); let mut budget = ReadBudget::new(10); assert_eq!(budget.read(&mut archive, "one", 16).unwrap(), b"12345678"); + assert!(budget.exceeds_remaining(&mut archive, "two")); + assert!(budget.exhausted_message().contains("contents together pass 10 bytes")); assert!(budget.read(&mut archive, "two", 16).unwrap_err().to_string().contains("contents together pass 10 bytes")); let mut member_budget = ReadBudget::new(100); @@ -614,6 +616,16 @@ impl ReadBudget { } } + /// Whether the member's declared uncompressed size exceeds what remains in this budget. + pub fn exceeds_remaining(&self, archive: &mut impl NativeArchive, name: &str) -> bool { + archive.declared_size(name).is_some_and(|size| size > self.left) + } + + /// Human-readable reason for leaving out a member after the archive-wide limit is spent. + pub fn exhausted_message(&self) -> String { + self.passed() + } + /// Read one native member within its own size limit and the remaining archive-wide budget. /// A failed read that consumed a member is charged at its declared size. pub fn read(&mut self, archive: &mut impl NativeArchive, name: &str, limit: u64) -> Result, FormatError> { From c96926f5bf450f4c176160999842a71de472dd12 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 10:24:48 +0000 Subject: [PATCH 13/18] share published FigCraft library manifest schema --- crates/figcraft-format/src/lib.rs | 1 + crates/figcraft-format/src/library.rs | 107 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 crates/figcraft-format/src/library.rs diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 3dc8e3a32..ccbab57ed 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -1,6 +1,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] //! The neutral JSON header and version contract for FigCraft's native project format. //! App-specific document fields remain a generic flattened payload. +pub mod library; use serde::de::{IgnoredAny, MapAccess, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; diff --git a/crates/figcraft-format/src/library.rs b/crates/figcraft-format/src/library.rs new file mode 100644 index 000000000..fe42dc7a4 --- /dev/null +++ b/crates/figcraft-format/src/library.rs @@ -0,0 +1,107 @@ +//! Wire schema and member limits for published libraries embedded in a native project archive. +use serde::{Deserialize, Serialize}; + +pub const MANIFEST: &str = "library/manifest.json"; +pub const DOCUMENT: &str = "library/document.json"; +pub const IMAGES_PREFIX: &str = "library/images/"; +pub const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024; +pub const MAX_ID_CHARS: usize = 64; +pub const MAX_NAME_CHARS: usize = 200; +pub const MAX_DESCRIPTION_CHARS: usize = 2000; + +/// Native library manifest, independent of the consumer's document model. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Manifest { + pub id: String, + pub name: String, + pub version: u64, + pub description: String, + pub time: u64, + pub components: Vec, + pub styles: Vec