Merge pull request #6524 from dotCooCoo/fix/native-open-budget-no-reread

Native files: a document past a read budget is refused after one read
This commit is contained in:
BFlat
2026-11-11 13:38:32 +07:00
committed by GitHub
+87 -4
View File
@@ -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 // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the
// PDF are skipped without being kept. // PDF are skipped without being kept.
fn within_budgets<T>(max_nodes: usize, max_values: usize, read: impl FnOnce() -> T) -> T { fn within_budgets<T>(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::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)) 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::<Document>::read_with_pdf_preference(file.json())) { let (mut f, pdf_compatible) = match within_budgets(max_nodes, max_values, || Contents::<Document>::read_with_pdf_preference(file.json())) {
Ok(read) => read, Ok(read) => read,
// That pass refuses a `pdfCompatible` that is not a boolean, and any other data error. // 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 // When it stopped at the preference, the file is read again without it, and only then is
// the preference a second time, so such a file opens as before. // its JSON scanned for the preference a second time, so such a file opens as before. Any
Err(ReadError::Json(e)) if e.is_data() => { // 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::<Document>::read(file.json())).map_err(read_error)?; let f = within_budgets(max_nodes, max_values, || Contents::<Document>::read(file.json())).map_err(read_error)?;
let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?; let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
(f, pdf_compatible) (f, pdf_compatible)
@@ -631,6 +634,51 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result
thread_local! { thread_local! {
/// Tests: how many times a file's JSON was scanned again for its PDF preference on this thread. /// 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<u32> = const { std::cell::Cell::new(0) }; static PDF_MEMBER_SCANS: std::cell::Cell<u32> = 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<u32> = 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<A: MapAccess<'de>>(self, mut map: A) -> Result<(), A::Error> {
while let Some(key) = map.next_key::<String>()? {
if key == "pdfCompatible" {
map.next_value::<Option<bool>>()?;
} else {
map.next_value::<IgnoredAny>()?;
}
}
Ok(())
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<(), A::Error> {
while seq.next_element::<IgnoredAny>()?.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 /// 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(); value["pdfCompatible"] = odd.clone();
assert_eq!(scans_for(&serde_json::to_vec(&value).unwrap()), (false, 1), "{odd}"); 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"); 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!(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] #[test]