From 10dfb8b2610a0a9996f0b2879dec69402acef92d Mon Sep 17 00:00:00 2001 From: Rahul Date: Tue, 27 Oct 2026 12:40:07 +0530 Subject: [PATCH 1/3] fix(format): cap a compressed file's JSON at 256 MB on wasm32 (#3587) MAX_JSON was 1 GiB on every target, but on wasm32 the 4 GiB address space also holds the document the JSON deserializes into, several times its size. The cap is now a quarter of the native one there, mirroring MAX_BLOBS, and Unpacked::within makes the cap exercisable wherever a test runs. Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- crates/format/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 292385305..798fcf312 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -66,8 +66,10 @@ pub const EXTENSION: &str = "vectorcraft"; pub const LEGACY_EXTENSION: &str = "drawcraft"; /// Most bytes the JSON of a compressed file may unpack to (a guard against decompression bombs, /// as are [`MAX_BLOB`] and [`MAX_BLOBS`]). A compressed save refuses a document past any of them, -/// so every file VectorCraft writes reads back. -pub const MAX_JSON: u64 = 1 << 30; +/// so every file VectorCraft writes reads back. On wasm32 the cap is a quarter of the native +/// one: the JSON is deserialized into a document several times its size, and the 4 GiB address +/// space also holds the file, the JSON, the document and the app (#3587). +pub const MAX_JSON: u64 = if cfg!(target_arch = "wasm32") { 256 << 20 } else { 1 << 30 }; /// Most bytes one entry of the data after the JSON (an image, a placed document, the PDF, a /// profile) may have in a compressed file. pub const MAX_BLOB: u64 = 1 << 30; From 36cc0813162b72cec6a14e293fdd2eaf09c18b2e Mon Sep 17 00:00:00 2001 From: Rahul Date: Tue, 27 Oct 2026 12:40:09 +0530 Subject: [PATCH 2/3] fix(format): cap a compressed file's JSON at 256 MB on wasm32 (#3587) MAX_JSON was 1 GiB on every target, but on wasm32 the 4 GiB address space also holds the document the JSON deserializes into, several times its size. The cap is now a quarter of the native one there, mirroring MAX_BLOBS, and Unpacked::within makes the cap exercisable wherever a test runs. Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- crates/format/src/unpack.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 9aa3c81b2..2b8652453 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -57,8 +57,16 @@ enum Tail<'a> { } impl<'a> Unpacked<'a> { - /// Open `bytes` (compressed or not). + /// Open `bytes` (compressed or not), with the JSON part capped at [`MAX_JSON`]. pub(crate) fn new(bytes: &'a [u8]) -> Result { + Self::within(bytes, MAX_JSON) + } + + /// Open `bytes` with the JSON part capped at `max_json` bytes instead of [`MAX_JSON`], so + /// the cap is exercised wherever the test runs: a compressed file's JSON is deserialized into + /// a document several times its size, and the cap is what keeps that off the address space + /// (#3587). + fn within(bytes: &'a [u8], max_json: u64) -> Result { if !is_compressed(bytes) { let (json, tail) = split_tail(bytes); return Ok(Self { json: Json::Borrowed(json), tail: Tail::Slice(tail) }); @@ -75,13 +83,13 @@ impl<'a> Unpacked<'a> { if n == 0 || nul.is_some_and(|z| buf.len() >= z + BLOB_MAGIC.len()) { break; } - if buf.len() as u64 > MAX_JSON + CHUNK { + if buf.len() as u64 > max_json + CHUNK { return Err(FormatError::TooBig); } } let Some(z) = nul else { // No NUL, so no marker: all of it is JSON. - if buf.len() as u64 > MAX_JSON { + if buf.len() as u64 > max_json { return Err(FormatError::TooBig); } return Ok(Self { json: Json::Owned(buf), tail: Tail::None }); @@ -92,7 +100,7 @@ impl<'a> Unpacked<'a> { && buf.get(end..).is_some_and(|r| r.starts_with(BLOB_MAGIC)) && end.checked_sub(1).and_then(|k| buf.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"')) { - if end as u64 > MAX_JSON { + if end as u64 > max_json { return Err(FormatError::TooBig); } let read = buf.split_off(end + BLOB_MAGIC.len()); @@ -323,6 +331,24 @@ mod tests { assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); } + /// #3587: a compressed file's JSON is capped wherever the cap runs, so a small gzip that + /// unpacks past it is refused instead of being deserialized into a document several times + /// its size. + #[test] + fn a_documents_json_is_capped() { + // The cap itself: a quarter of the address space on wasm32, 1 GiB elsewhere. + assert_eq!(MAX_JSON, if cfg!(target_arch = "wasm32") { 256 << 20 } else { 1 << 30 }); + let mut json = br#"{"format":"vectorcraft","pad":""#.to_vec(); + json.extend(std::iter::repeat(b'x').take(4096)); + json.extend(br#""}"#); + let gz = gzip(&json); + // Within the target's cap the file opens, and the JSON reads back whole. + assert!(Unpacked::new(&gz).is_ok()); + assert_eq!(Unpacked::within(&gz, json.len() as u64).unwrap().json(), json.as_slice()); + // One byte past the cap, the whole document is refused with TooBig. + assert!(matches!(Unpacked::within(&gz, json.len() as u64 - 1), Err(FormatError::TooBig))); + } + #[test] fn entries_sharing_bytes_obey_the_limits_in_memory_too() { // Each entry within MAX_BLOB, together past MAX_BLOBS: many references to the same bytes. From d6331bec1c467d6c786a95b16d6cbe205fa83fdd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 27 Oct 2026 14:36:12 +0700 Subject: [PATCH 3/3] Format: repeat_n in the JSON cap test (clippy manual_repeat_n) --- crates/format/src/unpack.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 2b8652453..d0833ad0f 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -339,7 +339,7 @@ mod tests { // The cap itself: a quarter of the address space on wasm32, 1 GiB elsewhere. assert_eq!(MAX_JSON, if cfg!(target_arch = "wasm32") { 256 << 20 } else { 1 << 30 }); let mut json = br#"{"format":"vectorcraft","pad":""#.to_vec(); - json.extend(std::iter::repeat(b'x').take(4096)); + json.extend(std::iter::repeat_n(b'x', 4096)); json.extend(br#""}"#); let gz = gzip(&json); // Within the target's cap the file opens, and the JSON reads back whole.