diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1da653311..667b39aad 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -511,6 +511,8 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the // PDF are skipped without being kept. fn within_budgets(max_nodes: usize, max_values: usize, read: impl FnOnce() -> T) -> T { + #[cfg(test)] + DOCUMENT_READS.with(|n| n.set(n.get() + 1)); vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, || { vectorcraft_doc::node::with_node_budget(max_nodes, || vectorcraft_doc::node::with_buffered_value_budget(max_values, read)) }) @@ -527,9 +529,10 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result let (mut f, pdf_compatible) = match within_budgets(max_nodes, max_values, || Contents::::read_with_pdf_preference(file.json())) { Ok(read) => read, // That pass refuses a `pdfCompatible` that is not a boolean, and any other data error. - // The file is read again without the preference, and only then is its JSON scanned for - // the preference a second time, so such a file opens as before. - Err(ReadError::Json(e)) if e.is_data() => { + // When it stopped at the preference, the file is read again without it, and only then is + // its JSON scanned for the preference a second time, so such a file opens as before. Any + // other error, such as a document past a budget, is returned without a second read. + Err(ReadError::Json(e)) if e.is_data() && refused_preference_at(file.json(), &e) => { let f = within_budgets(max_nodes, max_values, || Contents::::read(file.json())).map_err(read_error)?; let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; (f, pdf_compatible) @@ -631,6 +634,51 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result thread_local! { /// Tests: how many times a file's JSON was scanned again for its PDF preference on this thread. static PDF_MEMBER_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Tests: how many times a file's document was read on this thread. + static DOCUMENT_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Whether reading `json`'s `pdfCompatible` as a boolean or null fails with a data error at the +/// line and column of `e`, that is, whether the read that gave `e` stopped at the preference. Only +/// the bytes up to that position, and the one there, are read, and they are skipped without being +/// kept. +fn refused_preference_at(json: &[u8], e: &serde_json::Error) -> bool { + // serde_json gives a 1-based line and the bytes before the position on that line. + let Some(line_start) = (match e.line() { + 0 => None, + 1 => Some(0), + n => json.iter().enumerate().filter(|(_, b)| **b == b'\n').nth(n - 2).map(|(i, _)| i + 1), + }) else { + return false; + }; + let end = line_start.saturating_add(e.column()).saturating_add(1).min(json.len()); + struct StrictPreference; + impl<'de> Visitor<'de> for StrictPreference { + type Value = (); + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a native file envelope") + } + + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + while let Some(key) = map.next_key::()? { + if key == "pdfCompatible" { + map.next_value::>()?; + } else { + map.next_value::()?; + } + } + Ok(()) + } + + fn visit_seq>(self, mut seq: A) -> Result<(), A::Error> { + while seq.next_element::()?.is_some() {} + Ok(()) + } + } + serde_json::Deserializer::from_slice(&json[..end]) + .deserialize_any(StrictPreference) + .is_err_and(|s| s.is_data() && s.line() == e.line() && s.column() == e.column()) } /// The saved PDF preference, falling back to the presence of its payload without retaining or @@ -842,9 +890,44 @@ mod tests { value["pdfCompatible"] = odd.clone(); assert_eq!(scans_for(&serde_json::to_vec(&value).unwrap()), (false, 1), "{odd}"); } - // A document that can't be read still fails, after the second read. + // A document that can't be read still fails. Read before the preference, it is not read + // a second time. value["document"] = serde_json::json!("not a document"); + DOCUMENT_READS.with(|n| n.set(0)); assert!(load_info(&serde_json::to_vec(&value).unwrap()).is_err()); + assert_eq!(DOCUMENT_READS.with(|n| n.get()), 1); + // Read after a preference that is not a boolean, it fails on the second read, which is + // the first to reach it. + let mut first = serde_json::to_vec(&value).unwrap(); + assert!(first.starts_with(br#"{"document""#)); + first.splice(1..1, br#""pdfCompatible":"yes","#.iter().copied()); + DOCUMENT_READS.with(|n| n.set(0)); + assert!(load_info(&first).is_err()); + assert_eq!(DOCUMENT_READS.with(|n| n.get()), 2); + } + + /// A document past a read budget is refused after one read, also in a file whose preference + /// is not a boolean. + #[test] + fn a_document_past_a_budget_is_read_once() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + for i in 0..20 { + let id = d.alloc_id(); + d.insert(Some(l), i, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + } + let n = d.node_count(); + let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + for preference in [serde_json::Value::Null, serde_json::json!(true), serde_json::json!("yes")] { + value["pdfCompatible"] = preference.clone(); + let bytes = serde_json::to_vec(&value).unwrap(); + DOCUMENT_READS.with(|r| r.set(0)); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1, vectorcraft_doc::node::MAX_BUFFERED_VALUES) else { + panic!("one object past the budget must be refused") + }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + assert_eq!(DOCUMENT_READS.with(|r| r.get()), 1, "{preference}"); + } } #[test]