Native files: a document past a read budget is refused after one read

When the pass that reads the document and the saved PDF preference
together failed with a data error, open read the whole document a second
time without the preference. A document past a read budget, or with any
other data error, was then read up to the budget again before it was
refused. Open now reads it again only when the first pass stopped at a
pdfCompatible that is not a boolean, which it tells by reading the
preference alone over the bytes up to the first pass's error position.
This commit is contained in:
dotCooCoo
2026-11-10 21:37:49 -08:00
parent 22ec820569
commit 75fc513a6f
+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
// PDF are skipped without being kept.
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::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())) {
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::<Document>::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<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
@@ -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]