mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Merge pull request #1176 from storytold/feat/figcraft-native-format
Add shared native FigCraft project format crate
This commit is contained in:
5 files changed
+1002
-2
No files matched your search
Generated
+11
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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()))
|
||||
|
||||
Reference in new issue
Block a user