fix(format): reopen large documents once saved

`document.json` was read under the 512 MB cap for any archive member, so a
large imported document saved but didn't open again: 900 000 layers save
as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16
times its packed size (counted up to the archive's own size), within
512 MB and 4 GB; images and other members keep 512 MB. A small archive
still can't unpack into gigabytes.

That saved file reopens in 13 s with a 5.9 GB peak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
This commit is contained in:
Andrii Volkov
2026-10-16 10:46:08 +03:00
co-authored by Claude Opus 5.5
parent 12d43b1f99
commit 0efddc3894
2 changed files with 28 additions and 1 deletions
+15
View File
@@ -202,3 +202,18 @@ fn the_header_is_read_on_its_own() {
let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();
assert_eq!(index, Some(["ab".to_string()].into_iter().collect()));
}
#[test]
fn document_json_may_unpack_in_proportion_to_its_packed_size() {
const MB: u64 = 1024 * 1024;
// A small archive keeps the member cap: it can't unpack into gigabytes.
assert_eq!(document_limit(0, 0), MAX_MEMBER);
assert_eq!(document_limit(MB, 2 * MB), MAX_MEMBER);
// A real 900 000-layer document saved by us packs 1 477 139 810 bytes into 244 362 923, and
// didn't open again under the 512 MB cap.
assert!(document_limit(244_362_923, 260 * MB) > 1_477_139_810);
// The packed size an archive claims counts only up to the archive's own size.
assert_eq!(document_limit(u64::MAX, MB), MAX_MEMBER);
// Never more than the ceiling.
assert_eq!(document_limit(u64::MAX, u64::MAX), MAX_DOCUMENT);
}
+13 -1
View File
@@ -39,6 +39,11 @@ pub const FORMAT: &str = "figcraft";
pub const VERSION: u32 = 1;
/// Largest archive member we read (guards against zip bombs).
const MAX_MEMBER: u64 = 512 * 1024 * 1024;
/// `document.json` may unpack to [`DOCUMENT_RATIO`] times what it takes in the archive, within
/// [`MAX_MEMBER`] and [`MAX_DOCUMENT`]: a large document saved by us (a 900 000-layer file packs
/// 1.48 GB into 244 MB) opens again, while a small archive still can't unpack into gigabytes.
const DOCUMENT_RATIO: u64 = 16;
const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024;
/// Most versions a file keeps: saving more is refused, extra entries in a file are skipped.
pub const MAX_VERSIONS: usize = 100;
/// Longest version name and description kept (chars).
@@ -455,6 +460,11 @@ pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str
Ok(data)
}
/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`.
pub(crate) fn document_limit(packed: u64, len: u64) -> u64 {
packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT)
}
/// How many images, and how many bytes of them together, one file may give.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ImageBudget {
@@ -476,7 +486,9 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
return Ok(Loaded { issues, ..Loaded::from(doc) });
}
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let packed = z.by_name("document.json").map(|f| f.compressed_size()).unwrap_or(0);
let limit = document_limit(packed, bytes.len() as u64);
let text = String::from_utf8(read_member(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let (mut doc, mut issues, own_images) = parse_json(&text)?;
let (images, unread) = read_images(&mut z, own_images.as_ref(), budget);
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);