feat(figcraft-format): add bounded native archive access

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 06:41:10 +00:00
1 parent 3c67a98e54
commit 111b522d05
3 files changed
+136 -12

No files matched your search

Generated
+10
View File
@@ -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"
+1
View File
@@ -13,4 +13,5 @@ workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
craft-zip = { workspace = true }
thiserror = { workspace = true }
+125 -12
View File
@@ -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<u64>, pub images: Option<Vec<String>> }
pub struct Header {
pub version: Option<u64>,
pub images: Option<Vec<String>>,
}
pub fn read_header(text: &str) -> Result<Header, serde_json::Error> { serde_json::from_str(text) }
pub fn read_header(text: &str) -> Result<Header, serde_json::Error> {
serde_json::from_str(text)
}
impl<'de> Deserialize<'de> for Header {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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<A: MapAccess<'de>>(self, mut m: A) -> Result<Header, A::Error> {
let mut h = Header::default();
while let Some(k) = m.next_key::<String>()? {
match k.as_str() {
"version" => h.version = m.next_value::<Value>()?.as_u64(),
"imageIndex" => h.images = m.next_value::<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::<IgnoredAny>()?; }
"imageIndex" => {
h.images = m
.next_value::<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::<IgnoredAny>()?;
}
}
}
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<D: Serialize, W: std::io::Write>(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<Self, craft_zip::Error> {
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<Item = std::borrow::Cow<'_, str>> {
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<Option<Vec<u8>>, 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<Item = Member<'a>>) -> Result<Vec<u8>, 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());
}
}