Merge pull request #1176 from storytold/feat/figcraft-native-format

Add shared native FigCraft project format crate
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 08:42:00 -05:00
commit 0e2a717c64
5 files changed
+1002 -2

No files matched your search

Generated
+11
View File
@@ -1473,6 +1473,17 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-figcraft-format"
version = "0.1.0"
dependencies = [
"craft-zip",
"serde",
"serde_json",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "craft-figma-rest"
version = "0.1.0"
+18
View File
@@ -0,0 +1,18 @@
[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 }
craft-zip = { workspace = true }
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
thiserror = { workspace = true }
+749
View File
@@ -0,0 +1,749 @@
#![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;
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<String>,
}
impl<'de> Deserialize<'de> for VersionEntry {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<VersionEntry>,
}
/// Encode the native version-history index using the FigCraft wire schema.
pub fn write_version_index(index: &VersionIndex) -> Result<Vec<u8>, serde_json::Error> {
serde_json::to_vec(index)
}
/// Decode the native version-history index using the format's lenient optional-field defaults.
pub fn read_version_index(bytes: &[u8]) -> Result<VersionIndex, serde_json::Error> {
serde_json::from_slice(bytes)
}
impl<'de> Deserialize<'de> for VersionIndex {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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::<Result<Vec<_>, _>>())
.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<String>,
pub head_images: Vec<String>,
}
impl<'de> Deserialize<'de> for BranchEntry {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<BranchEntry>,
}
/// Encode the native branch index using the FigCraft wire schema.
pub fn write_branch_index(index: &BranchIndex) -> Result<Vec<u8>, serde_json::Error> {
serde_json::to_vec(index)
}
/// Decode the native branch index using the format's lenient optional-field defaults.
pub fn read_branch_index(bytes: &[u8]) -> Result<BranchIndex, serde_json::Error> {
serde_json::from_slice(bytes)
}
impl<'de> Deserialize<'de> for BranchIndex {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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::<Result<Vec<_>, _>>())
.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<u64>,
pub images: Option<Vec<String>>,
}
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 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>()?;
}
}
}
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);
/// Failure while reading the native document envelope or its app-supplied payload type.
#[derive(Debug, thiserror::Error)]
pub enum ReadDocumentError {
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
NewerVersion(#[from] NewerVersion),
}
/// Read a document payload after checking the native header and version first.
///
/// The payload type is supplied by the consumer, keeping app model conversion outside this crate.
pub fn read_document_json<D: serde::de::DeserializeOwned>(text: &str) -> Result<(D, Header), ReadDocumentError> {
let header = read_header(text)?;
check_version(&header)?;
let document = serde_json::from_str(text)?;
Ok((document, header))
}
/// The native document fields needed to render a recent-file preview.
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
pub struct PreviewDocument {
#[serde(default)]
pub pages: Vec<u64>,
#[serde(default)]
pub nodes: std::collections::BTreeMap<String, PreviewNode>,
#[serde(default)]
pub kind: PreviewKind,
}
/// Preview-relevant fields from one native document node.
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
pub struct PreviewNode {
#[serde(default)]
pub background: Option<Value>,
}
/// The supported native document kinds as stored on disk.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PreviewKind {
#[default]
Design,
Board,
}
/// Decode the preview-relevant subset of the native FigCraft document schema.
pub fn read_preview_document(bytes: &[u8]) -> Result<PreviewDocument, serde_json::Error> {
serde_json::from_slice(bytes)
}
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<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,
}
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 version_index_preserves_lenient_defaults_and_writes_compact_auto_flag() {
let index = read_version_index(br#"{"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: Value = serde_json::from_slice(&write_version_index(&index).unwrap()).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 =
read_branch_index(br#"{"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 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 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.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);
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)));
assert_eq!(check_version(&Header { version: Some(u64::MAX), images: None }), Err(NewerVersion(u32::MAX)));
#[derive(Deserialize)]
struct Payload {
document: u32,
}
let text = r#"{"version":2,"document":{"wrong":"type"}}"#;
assert!(matches!(read_document_json::<Payload>(text), Err(ReadDocumentError::NewerVersion(NewerVersion(2)))));
let (payload, header) = read_document_json::<Payload>(r#"{"version":1,"imageIndex":[{"hash":"a"}],"document":5}"#).unwrap();
assert_eq!(payload.document, 5);
assert_eq!(header.images, Some(vec!["a".into()]));
}
#[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");
}
#[test]
fn preview_document_reads_native_page_background_and_kind() {
let doc = read_preview_document(br#"{"pages":[7],"nodes":{"7":{"background":{"r":0.1}}},"kind":"board","unrelated":true}"#).unwrap();
assert_eq!(doc.pages, vec![7]);
assert_eq!(doc.nodes["7"].background.as_ref().and_then(|v| v.get("r")).and_then(Value::as_f64), Some(0.1));
assert_eq!(doc.kind, PreviewKind::Board);
assert_eq!(read_preview_document(b"{}").unwrap(), PreviewDocument::default());
assert!(read_preview_document(br#"{"kind":"unknown"}"#).is_err());
}
}
/// 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 declared_size(&mut self, name: &str) -> Option<u64> {
self.inner.declared_size(name)
}
pub fn compressed_size(&mut self, name: &str) -> Option<u64> {
self.inner.compressed_size(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());
}
}
/// 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<D: Serialize>(&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<Vec<u8>, 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);
}
}
/// 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<R: std::io::Read + std::io::Seek> {
inner: zip::ZipArchive<R>,
}
impl<R: std::io::Read + std::io::Seek> SeekArchive<R> {
pub fn open(reader: R, max_entries: u64) -> Result<Self, craft_zip::Error> {
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<String> {
self.inner.file_names().filter_map(Result::ok).map(|n| n.into_owned()).collect()
}
pub fn declared_size(&mut self, name: &str) -> Option<u64> {
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<Option<Vec<u8>>, 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<String>;
fn declared_size(&mut self, name: &str) -> Option<u64>;
fn compressed_size(&mut self, name: &str) -> Option<u64>;
fn read_member(&mut self, name: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, craft_zip::Error>;
}
impl NativeArchive for Archive<'_> {
fn contains(&self, name: &str) -> bool {
Archive::contains(self, name)
}
fn names(&self) -> Vec<String> {
Archive::names(self).map(|n| n.into_owned()).collect()
}
fn declared_size(&mut self, name: &str) -> Option<u64> {
Archive::declared_size(self, name)
}
fn compressed_size(&mut self, name: &str) -> Option<u64> {
Archive::compressed_size(self, name)
}
fn read_member(&mut self, name: &str, max_bytes: u64) -> Result<Option<Vec<u8>>, craft_zip::Error> {
Archive::read(self, name, max_bytes)
}
}
impl<R: std::io::Read + std::io::Seek> NativeArchive for SeekArchive<R> {
fn contains(&self, name: &str) -> bool {
SeekArchive::contains(self, name)
}
fn names(&self) -> Vec<String> {
SeekArchive::names(self)
}
fn declared_size(&mut self, name: &str) -> Option<u64> {
SeekArchive::declared_size(self, name)
}
fn compressed_size(&mut self, name: &str) -> Option<u64> {
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<Option<Vec<u8>>, 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());
}
}
/// 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;
/// 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.
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 {}
/// 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"),
}
}
/// 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<Vec<u8>, 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'_')
}
/// Clip text to at most `max` Unicode scalar values.
pub fn clip(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
+205
View File
@@ -0,0 +1,205 @@
//! Wire schema and member limits for published libraries embedded in a native project archive.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
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<Component>,
pub styles: Vec<Style>,
pub collections: Vec<Collection>,
pub variables: Vec<Variable>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Component {
pub id: u64,
#[serde(default)]
pub name: String,
#[serde(default)]
pub set: bool,
#[serde(default)]
pub version: u64,
#[serde(default)]
pub published: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Style {
pub id: String,
pub name: String,
#[serde(rename = "type")]
pub kind: String,
pub version: u64,
pub published: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Collection {
pub id: String,
pub name: String,
pub version: u64,
pub published: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Variable {
pub id: String,
pub name: String,
pub collection: String,
#[serde(rename = "type")]
pub kind: String,
pub version: u64,
pub published: bool,
}
/// Decode the native published-library manifest.
pub fn read_manifest(bytes: &[u8]) -> Result<Manifest, serde_json::Error> {
serde_json::from_slice(bytes)
}
/// Encode the native published-library manifest.
pub fn write_manifest(manifest: &Manifest) -> Result<Vec<u8>, serde_json::Error> {
serde_json::to_vec(manifest)
}
/// Whether `id` is a valid stable library id in the native manifest.
pub fn is_valid_id(id: &str) -> bool {
(1..=MAX_ID_CHARS).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Clip a library display string to its native-format scalar-value limit.
pub fn clip_display(s: &str, max_chars: usize) -> String {
s.chars().take(max_chars).collect()
}
/// Write the complete published-library member group into a native archive. The consumer maps
/// its document to the neutral manifest and image index; path layout, compression, and shared
/// image reuse stay here.
pub fn write_members<'a, D: Serialize>(
writer: &mut super::ArchiveWriter,
manifest: &Manifest,
document: &D,
image_index: &[super::ImageIndexEntry],
already_written: &BTreeSet<String>,
images: impl IntoIterator<Item = (&'a str, &'a [u8])>,
) -> Result<(), super::ArchiveWriteError> {
let manifest_bytes = write_manifest(manifest)?;
writer.add(MANIFEST, &manifest_bytes, super::Compression::Level(3))?;
writer.add_document_json(DOCUMENT, document, image_index)?;
for (hash, bytes) in images {
if already_written.contains(hash) || !valid_image_key(hash) {
continue;
}
writer.add(&format!("{IMAGES_PREFIX}{hash}"), bytes, super::Compression::Stored)?;
}
Ok(())
}
/// Read only published-library image resources selected by the consumer's document model.
/// Missing and oversized images are omitted, leaving the consumer to render its established
/// placeholder. The shared layer handles path choice, safe member names, and byte accounting.
pub fn read_image_resources(
archive: &mut impl super::NativeArchive,
hashes: impl IntoIterator<Item = String>,
already_available: &BTreeSet<String>,
unpacked: &mut super::ReadBudget,
) -> BTreeMap<String, Vec<u8>> {
let mut remaining = MAX_IMAGE_BYTES;
let mut images = BTreeMap::new();
for hash in hashes {
if already_available.contains(&hash) || !valid_image_key(&hash) {
continue;
}
let preferred = format!("{IMAGES_PREFIX}{hash}");
let fallback = format!("images/{hash}");
let Some(name) = [preferred, fallback].into_iter().find(|name| archive.contains(name)) else { continue };
let Ok(bytes) = unpacked.read(archive, &name, remaining.min(super::MAX_MEMBER)) else { continue };
remaining = remaining.saturating_sub(bytes.len() as u64);
images.insert(hash, bytes);
}
images
}
fn valid_image_key(key: &str) -> bool {
(1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_wire_names_defaults_and_round_trip_match_native_contract() {
let json = r#"{"id":"lib-1","components":[{"id":12,"set":true}],"styles":[{"id":"s","type":"paint"}],"collections":[{"id":"c"}],"variables":[{"id":"v","collection":"c","type":"color"}]}"#;
let manifest = read_manifest(json.as_bytes()).unwrap();
assert_eq!(manifest.components[0].id, 12);
assert_eq!(manifest.styles[0].kind, "paint");
assert_eq!(manifest.variables[0].kind, "color");
let encoded: serde_json::Value = serde_json::from_slice(&write_manifest(&manifest).unwrap()).unwrap();
assert_eq!(encoded["styles"][0]["type"], "paint");
assert!(encoded["styles"][0].get("kind").is_none());
assert_eq!(encoded["variables"][0]["type"], "color");
}
#[test]
fn library_id_and_display_limits_are_format_level() {
assert!(is_valid_id(&"a".repeat(MAX_ID_CHARS)));
assert!(!is_valid_id(&"a".repeat(MAX_ID_CHARS + 1)));
assert!(!is_valid_id("../bad"));
assert_eq!(clip_display("aé🙂b", 2), "aé");
}
#[test]
fn library_member_writer_and_reader_own_resource_layout_and_budgets() {
let mut writer = super::super::ArchiveWriter::new();
let manifest = Manifest { id: "library-1".into(), ..Manifest::default() };
let already_written = BTreeSet::from(["existing".to_string()]);
write_members(
&mut writer,
&manifest,
&serde_json::json!({"name":"library snapshot"}),
&[],
&already_written,
[("stored", &b"library bytes"[..]), ("existing", &b"duplicate"[..]), ("../bad", &b"hostile"[..])],
)
.unwrap();
writer.add("images/fallback", b"fallback bytes", super::super::Compression::Stored).unwrap();
let bytes = writer.finish().unwrap();
let mut archive = super::super::Archive::open(&bytes, super::super::ArchiveLimits { max_entries: 8, max_total_bytes: 1024 }).unwrap();
assert!(archive.contains(MANIFEST));
assert!(archive.contains(DOCUMENT));
let manifest_bytes = archive.read(MANIFEST, MAX_MANIFEST_BYTES).unwrap().unwrap();
assert_eq!(read_manifest(&manifest_bytes).unwrap(), manifest);
assert!(archive.contains("library/images/stored"));
assert!(!archive.contains("library/images/existing"));
assert!(!archive.contains("library/images/../bad"));
let available = BTreeSet::from(["existing".to_string()]);
let mut budget = super::super::ReadBudget::new(1024);
let images =
read_image_resources(&mut archive, ["stored".into(), "existing".into(), "fallback".into(), "../bad".into()], &available, &mut budget);
assert_eq!(images["stored"], b"library bytes");
assert_eq!(images["fallback"], b"fallback bytes");
assert!(!images.contains_key("existing"));
assert!(!images.contains_key("../bad"));
}
}
+19 -2
View File
@@ -33,7 +33,7 @@ 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> {
let (method, level) = match compression {
Compression::Stored => (zip::CompressionMethod::Stored, None),
@@ -44,13 +44,30 @@ impl Writer {
.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 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
/// 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())
.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))
}
/// 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()))