From ef01d69f7d2f05bd185431f300998ddd9b3b8557 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 19:11:55 -0500 Subject: [PATCH] Shared document bundle package IO --- Cargo.lock | 10 + Cargo.toml | 1 + crates/document-bundle/Cargo.toml | 17 ++ crates/document-bundle/src/lib.rs | 292 ++++++++++++++++++++++++++++++ crates/zip/src/archive.rs | 7 + 5 files changed, 327 insertions(+) create mode 100644 crates/document-bundle/Cargo.toml create mode 100644 crates/document-bundle/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index cf0e0bd47..174984c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1336,6 +1336,16 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-document-bundle" +version = "0.1.0" +dependencies = [ + "craft-zip", + "serde_json", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "craft-docx" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 629bab64c..4735dba39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ craft-dav = { path = "crates/dav" } craft-dialogs = { path = "crates/dialogs" } craft-doc-binary = { path = "crates/doc-binary" } craft-docx = { path = "crates/docx" } +craft-document-bundle = { path = "crates/document-bundle" } craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-eps = { path = "crates/eps" } diff --git a/crates/document-bundle/Cargo.toml b/crates/document-bundle/Cargo.toml new file mode 100644 index 000000000..705ac7db5 --- /dev/null +++ b/crates/document-bundle/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "craft-document-bundle" +description = "A reusable JSON document bundle envelope over ZIP: metadata, previews, asset entries and legacy inline asset extraction." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-zip = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } diff --git a/crates/document-bundle/src/lib.rs b/crates/document-bundle/src/lib.rs new file mode 100644 index 000000000..97acba322 --- /dev/null +++ b/crates/document-bundle/src/lib.rs @@ -0,0 +1,292 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! A JSON document bundle envelope over ZIP. +//! +//! Applications keep their document models. This crate owns the reusable container mechanics: +//! ordered mimetype, metadata, JSON model, previews, embedded asset paths, compression policy, +//! bounded reads and extraction of legacy base64 asset maps. Entry names and MIME type are +//! supplied by the format using the bundle. + +use std::io::{Read, Seek}; + +use craft_zip::{Archive, Compression, Limits, Writer}; +use serde_json::Value; + +const MAX_ENTRIES: u64 = 100_000; +const JSON_LIMIT: u64 = 256 << 20; +const ASSET_LIMIT: u64 = 1 << 30; +const EXPANSION_LIMIT: u64 = 4 << 30; + +/// Per-entry and total-expansion limits for an untrusted document bundle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReadLimits { + /// Maximum expanded size of metadata and document JSON. + pub json_bytes: u64, + /// Maximum expanded size of one compressed asset. Stored assets are bounded by archive size. + pub compressed_asset_bytes: u64, + /// Maximum expansion beyond the bytes in the source archive. + pub expansion_bytes: u64, +} + +impl Default for ReadLimits { + fn default() -> Self { + Self { json_bytes: JSON_LIMIT, compressed_asset_bytes: ASSET_LIMIT, expansion_bytes: EXPANSION_LIMIT } + } +} + +/// Names and identity used by one JSON document bundle format. +#[derive(Clone, Copy, Debug)] +pub struct Spec<'a> { + /// Stored first entry and its payload. + pub mimetype_entry: &'a str, + pub mimetype: &'a str, + /// Metadata and document JSON member names. + pub metadata_entry: &'a str, + pub document_entry: &'a str, + /// Optional preview entry name. + pub preview_entry: &'a str, + /// Directory prefix for embedded assets, without a trailing slash. + pub assets_directory: &'a str, + /// Metadata value written in the `format` field. + pub format_name: &'a str, + /// Old single-file JSON field containing base64 assets keyed by decimal id. + pub legacy_assets_field: Option<&'a str>, +} + +/// An embedded asset passed to [`write`]. +#[derive(Clone, Copy, Debug)] +pub struct Asset<'a> { + /// Decimal asset id used in the archive entry path. + pub id: &'a str, + /// MIME type used to select the extension and compression policy. + pub mime: &'a str, + /// File bytes retained byte-for-byte. + pub data: &'a [u8], +} + +/// A document and embedded payloads returned by [`read`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Package { + /// Parsed model JSON, after extracting legacy inline assets. + pub document: Value, + /// Format version declared by metadata, or 1 when absent. + pub version: u64, + /// Embedded asset bytes keyed by decimal asset id. + pub assets: Vec<(String, Vec)>, +} + +/// Why a document bundle could not be read or written. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The bytes are not a bundle or legacy JSON document. + #[error("not a document bundle: {0}")] + NotBundle(String), + /// The bundle requires a newer reader. + #[error("document format {0} is newer than this reader supports")] + TooNew(u64), + /// A member could not be written. + #[error("could not write document bundle: {0}")] + Write(String), + /// A member exceeded a read limit or could not be read. + #[error("could not read document bundle: {0}")] + Read(String), +} + +fn extension(mime: &str) -> &'static str { + match mime { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/tiff" => "tif", + "application/pdf" => "pdf", + "application/postscript" => "eps", + "image/svg+xml" => "svg", + _ => "bin", + } +} + +/// Whether `bytes` are a ZIP bundle that contains the configured document member. +pub fn has_document(bytes: &[u8], spec: Spec<'_>) -> bool { + let Ok(archive) = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: 0 }) else { return false }; + archive.contains(spec.document_entry) +} + +/// Whether `prefix` starts with the configured stored mimetype entry. +pub fn starts_with_mimetype(prefix: &[u8], spec: Spec<'_>) -> bool { + craft_zip::stored_mimetype(prefix).is_some_and(|mime| mime == spec.mimetype.as_bytes()) +} + +/// Write a ZIP document bundle containing the serialized document, metadata, preview and assets. +pub fn write(document: &[u8], version: u32, generator: &str, preview: Option<&[u8]>, assets: &[Asset<'_>], spec: Spec<'_>) -> Result, Error> { + let mut writer = Writer::new(); + writer.add(spec.mimetype_entry, spec.mimetype.as_bytes(), Compression::Stored).map_err(|e| Error::Write(e.to_string()))?; + let metadata = serde_json::json!({"format":spec.format_name, "version":version, "generator":generator}); + let metadata = serde_json::to_vec(&metadata).map_err(|e| Error::Write(e.to_string()))?; + writer.add(spec.metadata_entry, &metadata, Compression::Deflated).map_err(|e| Error::Write(e.to_string()))?; + if let Some(preview) = preview { + writer.add(spec.preview_entry, preview, Compression::Stored).map_err(|e| Error::Write(e.to_string()))?; + } + writer.add(spec.document_entry, document, Compression::Deflated).map_err(|e| Error::Write(e.to_string()))?; + for asset in assets.iter().filter(|asset| !asset.data.is_empty()) { + let path = format!("{}/{}.{}", spec.assets_directory, asset.id, extension(asset.mime)); + let compression = + if matches!(asset.mime, "image/png" | "image/jpeg" | "image/gif" | "image/webp") { Compression::Stored } else { Compression::Deflated }; + writer.add(&path, asset.data, compression).map_err(|e| Error::Write(e.to_string()))?; + } + writer.finish().map_err(|e| Error::Write(e.to_string())) +} + +/// Read a ZIP bundle or legacy JSON document within bounded member sizes. +/// +/// Members under the configured assets directory whose id does not occur in the document's +/// `assets` object are never decompressed. Unreferenced data cannot consume the caller's memory. +pub fn read(bytes: &[u8], supported_version: u32, spec: Spec<'_>) -> Result { + read_with_limits(bytes, supported_version, spec, ReadLimits::default()) +} + +/// Like [`read`], with caller-selected bounded-read limits. +pub fn read_with_limits(bytes: &[u8], supported_version: u32, spec: Spec<'_>, limits: ReadLimits) -> Result { + if !bytes.starts_with(b"PK") { + return read_legacy(bytes, spec); + } + let source_size = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let mut archive = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: source_size.saturating_add(limits.expansion_bytes) }) + .map_err(|e| Error::NotBundle(e.to_string()))?; + let version = archive + .read(spec.metadata_entry, limits.json_bytes) + .map_err(|e| Error::Read(e.to_string()))? + .and_then(|metadata| serde_json::from_slice::(&metadata).ok()) + .and_then(|metadata| metadata.get("version").and_then(Value::as_u64)) + .unwrap_or(1); + if version > u64::from(supported_version) { + return Err(Error::TooNew(version)); + } + let document_bytes = archive + .read(spec.document_entry, limits.json_bytes) + .map_err(|e| Error::Read(e.to_string()))? + .ok_or_else(|| Error::NotBundle(format!("missing {}", spec.document_entry)))?; + let document: Value = serde_json::from_slice(&document_bytes).map_err(|e| Error::NotBundle(e.to_string()))?; + let wanted: std::collections::HashSet = + document.get("assets").and_then(Value::as_object).into_iter().flat_map(|assets| assets.keys().cloned()).collect(); + let prefix = format!("{}/", spec.assets_directory); + let mut embedded = Vec::new(); + for path in archive.names().map(|name| name.into_owned()).collect::>() { + let Some(member) = path.strip_prefix(&prefix) else { continue }; + let Some(id) = member.split('.').next() else { continue }; + if !wanted.contains(id) || id.parse::().is_err() { + continue; + } + let stored = archive.is_stored(&path).unwrap_or(false); + let limit = if stored { source_size } else { limits.compressed_asset_bytes }; + let data = archive.read(&path, limit).map_err(|e| Error::Read(e.to_string()))?; + if let Some(data) = data { + embedded.push((id.to_owned(), data)); + } + } + Ok(Package { document, version, assets: embedded }) +} + +/// Read a preview with a fixed maximum size without decompressing the full bundle. +pub fn read_preview(bytes: &[u8], preview_entry: &str) -> Option> { + const PREVIEW_LIMIT: u64 = 4 << 20; + let mut archive = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: PREVIEW_LIMIT }).ok()?; + archive.read(preview_entry, PREVIEW_LIMIT).ok().flatten() +} + +/// Read a bounded preview from a seekable file without loading the full bundle into memory. +pub fn read_preview_from(reader: R, preview_entry: &str) -> Option> { + const PREVIEW_LIMIT: u64 = 4 << 20; + let mut zip = zip::ZipArchive::new(reader).ok()?; + let entry = zip.by_name(preview_entry).ok()?; + if entry.size() > PREVIEW_LIMIT { + return None; + } + let mut preview = Vec::new(); + entry.take(PREVIEW_LIMIT.saturating_add(1)).read_to_end(&mut preview).ok()?; + (u64::try_from(preview.len()).ok()? <= PREVIEW_LIMIT).then_some(preview) +} + +fn read_legacy(bytes: &[u8], spec: Spec<'_>) -> Result { + let mut document: Value = serde_json::from_slice(bytes).map_err(|e| Error::NotBundle(e.to_string()))?; + let mut assets = Vec::new(); + if let Some(field) = spec.legacy_assets_field { + if let Some(inline) = document.get(field).and_then(Value::as_object) { + for (id, value) in inline { + if id.parse::().is_ok() + && let Some(encoded) = value.as_str() + { + assets.push((id.clone(), decode_base64(encoded))); + } + } + } + if let Some(object) = document.as_object_mut() { + object.remove(field); + } + } + Ok(Package { document, version: 1, assets }) +} + +fn decode_base64(input: &str) -> Vec { + let mut out = Vec::with_capacity(input.len().saturating_mul(3) / 4); + let (mut buffer, mut bits) = (0u32, 0u32); + for byte in input.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => continue, + }; + buffer = (buffer << 6) | u32::from(value); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buffer >> bits) as u8); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const SPEC: Spec<'static> = Spec { + mimetype_entry: "mimetype", + mimetype: "application/vnd.test+zip", + metadata_entry: "meta.json", + document_entry: "document.json", + preview_entry: "preview.png", + assets_directory: "assets", + format_name: "test", + legacy_assets_field: Some("assetData"), + }; + + #[test] + fn writes_and_reads_bundle_members() { + let payload = [1u8, 2, 3]; + let bytes = + write(br#"{"assets":{"7":{}}}"#, 4, "Test 0.1", Some(b"png"), &[Asset { id: "7", mime: "image/png", data: &payload }], SPEC).unwrap(); + assert!(has_document(&bytes, SPEC)); + assert!(starts_with_mimetype(&bytes, SPEC)); + let loaded = read(&bytes, 4, SPEC).unwrap(); + assert_eq!(loaded.document, serde_json::json!({"assets":{"7":{}}})); + assert_eq!(loaded.version, 4); + assert_eq!(loaded.assets, [("7".to_owned(), payload.to_vec())]); + assert_eq!(read_preview(&bytes, SPEC.preview_entry).as_deref(), Some(&b"png"[..])); + } + + #[test] + fn extracts_legacy_inline_assets() { + let loaded = read(br#"{"assetData":{"7":"AQID"},"name":"x"}"#, 4, SPEC).unwrap(); + assert_eq!(loaded.document, serde_json::json!({"name":"x"})); + assert_eq!(loaded.assets, [("7".to_owned(), vec![1, 2, 3])]); + } + + #[test] + fn refuses_newer_versions() { + let bytes = write(b"{}", 9, "future", None, &[], SPEC).unwrap(); + assert_eq!(read(&bytes, 4, SPEC), Err(Error::TooNew(9))); + } +} diff --git a/crates/zip/src/archive.rs b/crates/zip/src/archive.rs index bdcf64cd7..448e2220b 100644 --- a/crates/zip/src/archive.rs +++ b/crates/zip/src/archive.rs @@ -97,6 +97,13 @@ impl<'a> Archive<'a> { self.zip.by_index_raw(index).ok().map(|f| f.compressed_size()) } + /// Is `name` stored without compression? The answer lets a format with in-memory source bytes + /// apply an output-size cap to compressed entries while bounding stored reads by source size. + pub fn is_stored(&mut self, name: &str) -> Option { + let index = self.zip.index_for_name(name)?; + self.zip.by_index_raw(index).ok().map(|f| f.compression() == zip::CompressionMethod::Stored) + } + /// The first `n` bytes of entry `name` (all of it when it is shorter), or `None` when there /// is no such entry: for telling a file by its signature without inflating it. The bytes are /// charged to the total; a total with less left than the bytes wanted refuses before reading.