From 5f3aed6309bd537ae26f6b423aac7d688c72ac77 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 05:56:54 -0800 Subject: [PATCH] craft-doc: add cooperative cancellation to document reads --- crates/doc/README.md | 21 ++- crates/doc/src/cancel.rs | 116 ++++++++++++++ crates/doc/src/ecproj.rs | 95 ++++++++--- crates/doc/src/lib.rs | 202 ++++++++++++++++++------ crates/doc/src/pcraft.rs | 87 +++++++--- crates/doc/src/vectorcraft.rs | 172 ++++++++++++++------ crates/doc/src/zip.rs | 41 +++-- crates/doc/tests/cancellation.rs | 135 ++++++++++++++++ crates/ecproj-format/README.md | 2 + crates/ecproj-format/src/lib.rs | 11 +- crates/ecproj-format/tests/envelope.rs | 50 ++++++ crates/pcraft-format/README.md | 4 +- crates/pcraft-format/src/lib.rs | 7 +- crates/pcraft-format/src/limits.rs | 60 ++++--- crates/pcraft-format/tests/limits.rs | 22 ++- crates/vectorcraft-format/README.md | 4 + crates/vectorcraft-format/src/base64.rs | 104 ++++++++++++ crates/vectorcraft-format/src/lib.rs | 32 ++++ 18 files changed, 987 insertions(+), 178 deletions(-) create mode 100644 crates/doc/src/cancel.rs create mode 100644 crates/doc/tests/cancellation.rs diff --git a/crates/doc/README.md b/crates/doc/README.md index 03ee07e1..95dbd849 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -44,10 +44,29 @@ let outline = craft_doc::outline(&bytes, &limits)?; - `renditions(bytes, &Limits) -> Result, Error>`: each `Rendition` has a `kind` (`Pdf`, `Thumbnail`, `Composite`), a `mime` and the `bytes`. - `outline(bytes, &Limits) -> Result`: `canvas`, `pages` and `layers`. -- `Error` is `Unrecognized`, `Malformed`, `TooNew`, `Unsupported` or `LimitExceeded`. A file +- `Error` is `Unrecognized`, `Malformed`, `TooNew`, `Unsupported`, `LimitExceeded` or `Cancelled`. A file whose version is newer than the crate reads gives `TooNew` from `renditions` and `outline`. A ZIP archive whose `manifest.json` has no `format_version` gives `Unrecognized`. +### Cooperative cancellation + +`info_with_cancel`, `renditions_with_cancel` and `outline_with_cancel` take the same bytes and +limits plus `&dyn Fn() -> bool`. The callback runs on the calling thread. Once it returns true, +that call stops with `Error::Cancelled`, even if a later callback result would be false. + +```rust +use std::sync::atomic::{AtomicBool, Ordering}; + +let stopped = AtomicBool::new(false); +let renditions = craft_doc::renditions_with_cancel(&bytes, &limits, &|| stopped.load(Ordering::Relaxed))?; +``` + +Checks occur during format detection, JSON scans and parsing, gzip input and output, rendition +copies, ZIP directory and entry reads, CRC traversal, and outline conversion, including shared +header, manifest and base64 helpers. +The existing entry points keep their behavior and limits. PDF parsing/rendering and optional +PhotoCraft layer pixel decoding are separate operations. + ## What each format gives | | `.vectorcraft` | `.pcraft` | `.ecproj` | diff --git a/crates/doc/src/cancel.rs b/crates/doc/src/cancel.rs new file mode 100644 index 00000000..1701f0cf --- /dev/null +++ b/crates/doc/src/cancel.rs @@ -0,0 +1,116 @@ +//! Per-read cancellation. Callbacks run on the reader's thread and a request stays latched. + +use std::cell::Cell; +use std::io::{self, Read}; + +use crate::Error; + +pub(crate) const CHUNK: usize = 64 * 1024; + +pub(crate) fn check(control: Option<&Control<'_>>) -> Result<(), Error> { + control.map_or(Ok(()), Control::check) +} + +/// A byte scanner can skip over escaped characters, so test a threshold rather than a modulus. +pub(crate) struct Scan<'a> { + control: Option<&'a Control<'a>>, + next: usize, +} + +impl<'a> Scan<'a> { + pub(crate) fn new(control: Option<&'a Control<'a>>) -> Self { + Self { control, next: 0 } + } + + pub(crate) fn check(&mut self, at: usize) -> Result<(), Error> { + if self.control.is_some() && at >= self.next { + check(self.control)?; + self.next = at.saturating_add(CHUNK); + } + Ok(()) + } +} + +pub(crate) struct Control<'a> { + cancelled: &'a dyn Fn() -> bool, + stopped: Cell, +} + +impl<'a> Control<'a> { + fn new(cancelled: &'a dyn Fn() -> bool) -> Self { + Self { cancelled, stopped: Cell::new(false) } + } + + pub(crate) fn check(&self) -> Result<(), Error> { + if self.stopped.get() || (self.cancelled)() { + self.stopped.set(true); + Err(Error::Cancelled) + } else { + Ok(()) + } + } +} + +pub(crate) fn run(cancelled: &dyn Fn() -> bool, read: impl FnOnce(&Control<'_>) -> Result) -> Result { + let control = Control::new(cancelled); + control.check()?; + let result = read(&control); + control.check()?; + result +} + +/// Poll before each bounded portion of input. In particular, a one-byte JSON reader does not +/// call the callback for every byte, and a decoder cannot consume an unbounded read request. +pub(crate) struct Reader<'a, R> { + inner: R, + control: Option<&'a Control<'a>>, + left: usize, +} + +impl<'a, R> Reader<'a, R> { + pub(crate) fn new(inner: R, control: Option<&'a Control<'a>>) -> Self { + Self { inner, control, left: 0 } + } +} + +impl Read for Reader<'_, R> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let Some(control) = self.control else { return self.inner.read(buf) }; + if buf.is_empty() { + return Ok(0); + } + if self.left == 0 { + control.check().map_err(|_| io::Error::other("document read canceled"))?; + self.left = CHUNK; + } + let len = buf.len().min(self.left); + let n = self.inner.read(&mut buf[..len])?; + self.left -= n; + Ok(n) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn a_reader_stops_after_a_partial_read_and_latches_the_request() { + let polls = Cell::new(0); + let cancelled = || { + polls.set(polls.get() + 1); + polls.get() == 2 + }; + let control = Control::new(&cancelled); + let mut input = io::Cursor::new(vec![1; CHUNK * 3]); + let mut reader = Reader::new(&mut input, Some(&control)); + let mut bytes = vec![0; CHUNK * 3]; + assert_eq!(reader.read(&mut bytes).unwrap(), CHUNK); + assert!(reader.read(&mut bytes).is_err()); + assert!(reader.read(&mut bytes).is_err(), "a one-shot true remains canceled"); + assert_eq!(input.position(), CHUNK as u64, "the remaining input was not consumed"); + assert_eq!(polls.get(), 2); + } +} diff --git a/crates/doc/src/ecproj.rs b/crates/doc/src/ecproj.rs index 03c437fa..d9fd41fa 100644 --- a/crates/doc/src/ecproj.rs +++ b/crates/doc/src/ecproj.rs @@ -23,17 +23,27 @@ const TICKS_PER_SECOND: f64 = 254_016_000_000.0; /// Calls `stop` with each member name of the JSON object `text` starts with, in order, as far as /// `text` goes, until it returns `true`. Members of nested objects are skipped. A name is given as /// written, escapes included. -fn top_level_keys<'a>(text: &'a [u8], mut stop: impl FnMut(&'a [u8]) -> bool) { - let Some(body) = text.trim_ascii_start().strip_prefix(b"{") else { return }; +fn top_level_keys<'a>(text: &'a [u8], control: Option<&crate::cancel::Control<'_>>, mut stop: impl FnMut(&'a [u8]) -> bool) -> Result<(), Error> { + let mut scan = crate::cancel::Scan::new(control); + let mut start = 0; + while text.get(start).is_some_and(u8::is_ascii_whitespace) { + scan.check(start)?; + start += 1; + } + let Some(body) = text.get(start..).and_then(|s| s.strip_prefix(b"{")) else { return Ok(()) }; + // Start a new offset domain after leading whitespace. + let mut scan = crate::cancel::Scan::new(control); let (mut depth, mut want_key, mut i) = (0u32, true, 0); while let Some(&b) = body.get(i) { + scan.check(i)?; match b { b'"' => { let start = i + 1; let mut end = start; loop { + scan.check(end)?; match body.get(end) { - None => return, + None => return Ok(()), Some(b'\\') => end += 2, Some(b'"') => break, Some(_) => end += 1, @@ -44,26 +54,31 @@ fn top_level_keys<'a>(text: &'a [u8], mut stop: impl FnMut(&'a [u8]) -> bool) { && let Some(key) = body.get(start..end) && stop(key) { - return; + return Ok(()); } want_key = false; i = end; } b'{' | b'[' => depth += 1, - b'}' | b']' if depth == 0 => return, + b'}' | b']' if depth == 0 => return Ok(()), b'}' | b']' => depth -= 1, b',' if depth == 0 => want_key = true, _ => {} } i += 1; } + Ok(()) } /// A project starts with `savedBy` followed by `schema`, or, saved before `savedBy` existed, with /// `schema` followed by `items` or `savedBy`, all top-level members. pub(crate) fn sniff(head: &[u8]) -> bool { + sniff_checked(head, None).unwrap_or(false) +} + +pub(crate) fn sniff_checked(head: &[u8], control: Option<&crate::cancel::Control<'_>>) -> Result { let (mut first, mut found) = (None::<&[u8]>, false); - top_level_keys(head, |key| match first { + top_level_keys(head, control, |key| match first { None => { first = Some(key); !matches!(key, b"savedBy" | b"schema") @@ -76,8 +91,8 @@ pub(crate) fn sniff(head: &[u8]) -> bool { found = key == b"items" || key == b"savedBy"; found } - }); - found + })?; + Ok(found) } fn json<'a>(bytes: &'a [u8], budget: &Budget) -> Result<&'a [u8], Error> { @@ -88,9 +103,14 @@ fn json<'a>(bytes: &'a [u8], budget: &Budget) -> Result<&'a [u8], Error> { /// The project's [`Head`] read with [`Head::read`]; [`ReadError::TooNew`] when its schema is /// newer than this crate reads. fn read_head(json: &[u8], budget: &Budget) -> Result, Error> { - check_depth(json, budget.limits.max_depth)?; + check_depth(json, budget.limits.max_depth, budget.control)?; let text = std::str::from_utf8(json).map_err(|e| malformed(format!("the project is not UTF-8: {e}")))?; - Ok(Head::read(text)) + let head = match budget.control { + Some(control) => Head::read_from(crate::cancel::Reader::new(json, Some(control))), + None => Head::read(text), + }; + budget.check_cancelled()?; + Ok(head) } /// The project's [`Head`], refused when its schema is newer than this crate reads. @@ -185,12 +205,23 @@ struct SwitchesM { pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result { let json = json(bytes, budget)?; head(json, budget)?; - let p: Project = parse(json, budget.limits)?; - let mut comps: Vec = p.items.into_values().filter(|i| i.kind.kind == "Comp").collect(); + let p: Project = parse(json, budget)?; + project_outline(p, budget) +} + +fn project_outline(p: Project, budget: &Budget) -> Result { + let mut comps = Vec::new(); + for i in p.items.into_values() { + budget.check_cancelled()?; + if i.kind.kind == "Comp" { + comps.push(i); + } + } comps.sort_by_key(|i| i.id); let pages = comps .into_iter() .map(|i| { + budget.check_cancelled()?; let k = i.kind; let (Some(width), Some(height)) = (k.width, k.height) else { return Err(malformed(format!("composition `{}` has no size", i.name))); @@ -205,17 +236,41 @@ pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result>()?; Ok(Page { name: i.name, x: 0.0, y: 0.0, width: f64::from(width), height: f64::from(height), timing, layers }) }) .collect::>()?; Ok(Outline { canvas: None, pages, layers: vec![] }) } + +#[cfg(test)] +mod cancellation_tests { + #![allow(clippy::unwrap_used)] + use super::*; + use std::cell::Cell; + + #[test] + fn conversion_stops_inside_composition_layers() { + let project: Project = + serde_json::from_str(r#"{"items":{"1":{"id":1,"kind":{"type":"Comp","width":1,"height":1,"layers":[{},{},{},{},{},{}]}}}}"#).unwrap(); + let polls = Cell::new(0); + let cancel = || { + polls.set(polls.get() + 1); + polls.get() == 6 + }; + let result = crate::cancel::run(&cancel, |control| project_outline(project, &Budget::controlled(&crate::Limits::default(), control))); + assert_eq!(result, Err(Error::Cancelled)); + assert_eq!(polls.get(), 6); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ed5daa36..dc9e47ad 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -26,6 +26,7 @@ //! assert!(info.supported); //! ``` +mod cancel; mod ecproj; mod pcraft; #[cfg(feature = "pcraft-pixels")] @@ -98,6 +99,9 @@ pub struct Info { #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// A cancellation-aware read observed the caller's cancellation request. + #[error("document read canceled")] + Cancelled, /// [`sniff`] does not recognize the bytes as a Craft document, or a ZIP archive's /// `manifest.json` is a JSON object without `format_version`, so the archive is not a /// PhotoCraft bundle. @@ -273,15 +277,45 @@ pub fn sniff(head: &[u8]) -> Option { } } +fn read_kind(bytes: &[u8], budget: &Budget) -> Result { + if pcraft::sniff_checked(bytes, budget.control)? { + Ok(Kind::PhotoCraft) + } else if vectorcraft::sniff_checked(bytes, budget.control)? { + Ok(Kind::VectorCraft) + } else if ecproj::sniff_checked(bytes, budget.control)? { + Ok(Kind::EffectCraft) + } else { + Err(Error::Unrecognized) + } +} + /// The document's kind, version and writer. A version newer than this crate reads is not an /// error here: [`Info::supported`] is `false`. pub fn info(bytes: &[u8], limits: &Limits) -> Result { let mut budget = Budget::new(limits); - let kind = sniff(bytes).ok_or(Error::Unrecognized)?; + read_info(bytes, &mut budget) +} + +/// [`info`] with cooperative cancellation. The callback runs synchronously on this thread; +/// returning true stops the read at a checkpoint with [`Error::Cancelled`]. The limits and +/// returned data are the same as [`info`]. +/// +/// ``` +/// use std::sync::atomic::{AtomicBool, Ordering}; +/// let stopped = AtomicBool::new(true); +/// let result = craft_doc::info_with_cancel(b"", &craft_doc::Limits::default(), &|| stopped.load(Ordering::Relaxed)); +/// assert_eq!(result, Err(craft_doc::Error::Cancelled)); +/// ``` +pub fn info_with_cancel(bytes: &[u8], limits: &Limits, cancelled: &dyn Fn() -> bool) -> Result { + cancel::run(cancelled, |control| read_info(bytes, &mut Budget::controlled(limits, control))) +} + +fn read_info(bytes: &[u8], budget: &mut Budget) -> Result { + let kind = read_kind(bytes, budget)?; let (version, generator) = match kind { - Kind::PhotoCraft => pcraft::info(bytes, &mut budget)?, - Kind::VectorCraft => vectorcraft::info(bytes, &mut budget)?, - Kind::EffectCraft => ecproj::info(bytes, &mut budget)?, + Kind::PhotoCraft => pcraft::info(bytes, budget)?, + Kind::VectorCraft => vectorcraft::info(bytes, budget)?, + Kind::EffectCraft => ecproj::info(bytes, budget)?, }; Ok(Info { kind, version, generator, supported: version <= kind.supported_version() }) } @@ -292,20 +326,39 @@ pub fn info(bytes: &[u8], limits: &Limits) -> Result { /// manifest names; an `.ecproj` project has none. pub fn renditions(bytes: &[u8], limits: &Limits) -> Result, Error> { let mut budget = Budget::new(limits); - match sniff(bytes).ok_or(Error::Unrecognized)? { - Kind::PhotoCraft => pcraft::renditions(bytes, &mut budget), - Kind::VectorCraft => vectorcraft::renditions(bytes, &mut budget), - Kind::EffectCraft => ecproj::renditions(bytes, &mut budget), + read_renditions(bytes, &mut budget) +} + +/// [`renditions`] with cooperative cancellation, as [`info_with_cancel`]. This interrupts +/// extraction; it does not parse or render the PDF or decode the image a rendition contains. +pub fn renditions_with_cancel(bytes: &[u8], limits: &Limits, cancelled: &dyn Fn() -> bool) -> Result, Error> { + cancel::run(cancelled, |control| read_renditions(bytes, &mut Budget::controlled(limits, control))) +} + +fn read_renditions(bytes: &[u8], budget: &mut Budget) -> Result, Error> { + match read_kind(bytes, budget)? { + Kind::PhotoCraft => pcraft::renditions(bytes, budget), + Kind::VectorCraft => vectorcraft::renditions(bytes, budget), + Kind::EffectCraft => ecproj::renditions(bytes, budget), } } /// The document's canvas, pages and layers (see [`Outline`]). pub fn outline(bytes: &[u8], limits: &Limits) -> Result { let mut budget = Budget::new(limits); - match sniff(bytes).ok_or(Error::Unrecognized)? { - Kind::PhotoCraft => pcraft::outline(bytes, &mut budget), - Kind::VectorCraft => vectorcraft::outline(bytes, &mut budget), - Kind::EffectCraft => ecproj::outline(bytes, &mut budget), + read_outline(bytes, &mut budget) +} + +/// [`outline`] with cooperative cancellation, as [`info_with_cancel`]. +pub fn outline_with_cancel(bytes: &[u8], limits: &Limits, cancelled: &dyn Fn() -> bool) -> Result { + cancel::run(cancelled, |control| read_outline(bytes, &mut Budget::controlled(limits, control))) +} + +fn read_outline(bytes: &[u8], budget: &mut Budget) -> Result { + match read_kind(bytes, budget)? { + Kind::PhotoCraft => pcraft::outline(bytes, budget), + Kind::VectorCraft => vectorcraft::outline(bytes, budget), + Kind::EffectCraft => ecproj::outline(bytes, budget), } } @@ -316,12 +369,38 @@ fn malformed(m: impl std::fmt::Display) -> Error { /// The bytes one call may still decompress, read out of ZIP entries or return as renditions. pub(crate) struct Budget<'a> { pub(crate) limits: &'a Limits, + pub(crate) control: Option<&'a cancel::Control<'a>>, left: u64, } impl<'a> Budget<'a> { fn new(limits: &'a Limits) -> Self { - Budget { limits, left: limits.max_total } + Budget { limits, control: None, left: limits.max_total } + } + + fn controlled(limits: &'a Limits, control: &'a cancel::Control<'a>) -> Self { + Budget { limits, control: Some(control), left: limits.max_total } + } + + pub(crate) fn check_cancelled(&self) -> Result<(), Error> { + if let Some(control) = self.control { + control.check()?; + } + Ok(()) + } + + /// Copy bytes whose size the caller has already checked against the budget. + pub(crate) fn copy(&self, bytes: &[u8]) -> Result, Error> { + if self.control.is_none() { + return Ok(bytes.to_vec()); + } + self.check_cancelled()?; + let mut out = Vec::with_capacity(bytes.len()); + for chunk in bytes.chunks(cancel::CHUNK) { + self.check_cancelled()?; + out.extend_from_slice(chunk); + } + Ok(out) } /// Bytes still allowed. @@ -331,6 +410,7 @@ impl<'a> Budget<'a> { /// Count `n` bytes of `what` against the total. pub(crate) fn take(&mut self, n: u64, what: &str) -> Result<(), Error> { + self.check_cancelled()?; self.left = self .left .checked_sub(n) @@ -340,6 +420,7 @@ impl<'a> Budget<'a> { /// Check one rendition or ZIP entry of `n` bytes against `max_entry`. pub(crate) fn check_entry(&self, n: u64, what: &str) -> Result<(), Error> { + self.check_cancelled()?; if n > self.limits.max_entry { return Err(Error::LimitExceeded(format!("{what} is {n} bytes, more than max_entry ({})", self.limits.max_entry))); } @@ -354,6 +435,7 @@ impl<'a> Budget<'a> { /// Check `n` bytes of JSON against `max_json`. pub(crate) fn check_json(&self, n: u64, what: &str) -> Result<(), Error> { + self.check_cancelled()?; if n > self.limits.max_json { return Err(Error::LimitExceeded(format!("{what} is more than max_json ({} bytes)", self.limits.max_json))); } @@ -363,29 +445,34 @@ impl<'a> Budget<'a> { /// Is the JSON text nested at most `max` levels deep (arrays and objects), and at most /// [`MAX_DEPTH`]? -pub(crate) fn check_depth(json: &[u8], max: u32) -> Result<(), Error> { +pub(crate) fn check_depth(json: &[u8], max: u32, control: Option<&cancel::Control<'_>>) -> Result<(), Error> { let max = max.min(MAX_DEPTH); let (mut depth, mut in_str, mut escaped) = (0u32, false, false); - for &b in json { - if in_str { + for chunk in json.chunks(cancel::CHUNK) { + if let Some(control) = control { + control.check()?; + } + for &b in chunk { + if in_str { + match b { + _ if escaped => escaped = false, + b'\\' => escaped = true, + b'"' => in_str = false, + _ => {} + } + continue; + } match b { - _ if escaped => escaped = false, - b'\\' => escaped = true, - b'"' => in_str = false, + b'"' => in_str = true, + b'[' | b'{' => { + depth += 1; + if depth > max { + return Err(Error::LimitExceeded(format!("JSON nested deeper than max_depth ({max} levels)"))); + } + } + b']' | b'}' => depth = depth.saturating_sub(1), _ => {} } - continue; - } - match b { - b'"' => in_str = true, - b'[' | b'{' => { - depth += 1; - if depth > max { - return Err(Error::LimitExceeded(format!("JSON nested deeper than max_depth ({max} levels)"))); - } - } - b']' | b'}' => depth = depth.saturating_sub(1), - _ => {} } } Ok(()) @@ -491,18 +578,23 @@ pub(crate) fn counted_map<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) } /// Parse one JSON value of type `T` from `json`, after checking its nesting against the limits. -pub(crate) fn parse(json: &[u8], limits: &Limits) -> Result { - check_depth(json, limits.max_depth)?; - counting(limits.max_items, || parse_checked(json).map_err(malformed)) +pub(crate) fn parse(json: &[u8], budget: &Budget) -> Result { + check_depth(json, budget.limits.max_depth, budget.control)?; + counting(budget.limits.max_items, || parse_checked(json, budget).map_err(malformed)) } /// Parse JSON whose nesting [`check_depth`] has accepted. -fn parse_checked(json: &[u8]) -> Result { - let mut de = serde_json::Deserializer::from_slice(json); - de.disable_recursion_limit(); - let v = T::deserialize(&mut de)?; - de.end()?; - Ok(v) +fn parse_checked(json: &[u8], budget: &Budget) -> Result { + fn read<'de, T: DeserializeOwned, R: serde_json::de::Read<'de>>(mut de: serde_json::Deserializer) -> Result { + de.disable_recursion_limit(); + let v = T::deserialize(&mut de)?; + de.end()?; + Ok(v) + } + match budget.control { + Some(control) => read(serde_json::Deserializer::from_reader(cancel::Reader::new(json, Some(control)))), + None => read(serde_json::Deserializer::from_slice(json)), + } } /// The top-level member that holds a document's version number: VectorCraft's `version`, @@ -545,13 +637,23 @@ impl<'de> Visitor<'de> for VersionIn { /// Parse `T` from `json`; when that fails, report [`Error::TooNew`] instead if the file's version /// (its top-level member that [`version_member`] names) is newer than `kind` supports, since a /// newer format may change what `T` expects. -pub(crate) fn parse_versioned(json: &[u8], limits: &Limits, kind: Kind) -> Result { - check_depth(json, limits.max_depth)?; - counting(limits.max_items, || { - parse_checked(json).map_err(|e| { - let mut de = serde_json::Deserializer::from_slice(json); - de.disable_recursion_limit(); - let version = (&mut de).deserialize_map(VersionIn(version_member(kind))).and_then(|v| de.end().map(|()| v)); +pub(crate) fn parse_versioned(json: &[u8], budget: &Budget, kind: Kind) -> Result { + fn version<'de, R: serde_json::de::Read<'de>>(mut de: serde_json::Deserializer, kind: Kind) -> Result, serde_json::Error> { + de.disable_recursion_limit(); + let version = (&mut de).deserialize_map(VersionIn(version_member(kind)))?; + de.end()?; + Ok(version) + } + check_depth(json, budget.limits.max_depth, budget.control)?; + counting(budget.limits.max_items, || { + parse_checked(json, budget).map_err(|e| { + if let Err(cancelled) = budget.check_cancelled() { + return cancelled; + } + let version = match budget.control { + Some(control) => version(serde_json::Deserializer::from_reader(cancel::Reader::new(json, Some(control))), kind), + None => version(serde_json::Deserializer::from_slice(json), kind), + }; match version { Ok(Some(version)) if version > kind.supported_version() => too_new(kind, version), _ => malformed(e), @@ -684,9 +786,9 @@ mod tests { #[test] fn depth_counts_brackets_outside_strings() { - assert!(check_depth(br#"[[["[[[[[[[["]]]"#, 3).is_ok()); - assert!(check_depth(br#"[[[["\"[["]]]]"#, 3).is_err()); - assert!(check_depth(br#"{"a":{"b":[1]}}"#, 3).is_ok()); + assert!(check_depth(br#"[[["[[[[[[[["]]]"#, 3, None).is_ok()); + assert!(check_depth(br#"[[[["\"[["]]]]"#, 3, None).is_err()); + assert!(check_depth(br#"{"a":{"b":[1]}}"#, 3, None).is_ok()); } #[test] diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs index bfbb53ff..df8b04ec 100644 --- a/crates/doc/src/pcraft.rs +++ b/crates/doc/src/pcraft.rs @@ -18,17 +18,24 @@ use crate::{Budget, Canvas, Error, Kind, Layer, Outline, Rendition, RenditionKin /// its central directory decide, as [`Zip::find`] reads them; otherwise the names in the local /// headers that `head` holds. pub(crate) fn sniff(head: &[u8]) -> bool { - head.starts_with(b"PK\x03\x04") - && match Zip::open(head) { - Ok(zip) => zip.find(MANIFEST).is_some(), - Err(_) => local_names_include(head, MANIFEST), - } + sniff_checked(head, None).unwrap_or(false) +} + +pub(crate) fn sniff_checked(head: &[u8], control: Option<&crate::cancel::Control<'_>>) -> Result { + if !head.starts_with(b"PK\x03\x04") { + return Ok(false); + } + match Zip::open(head, control) { + Ok(zip) => Ok(zip.find(MANIFEST, control)?.is_some()), + Err(Error::Cancelled) => Err(Error::Cancelled), + Err(_) => local_names_include(head, MANIFEST, control), + } } /// The archive and its `manifest.json`. fn manifest<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result<(Zip<'a>, Vec), Error> { - let zip = Zip::open(bytes)?; - let entry = zip.find(MANIFEST).ok_or_else(|| malformed("the bundle has no manifest.json"))?; + let zip = Zip::open(bytes, budget.control)?; + let entry = zip.find(MANIFEST, budget.control)?.ok_or_else(|| malformed("the bundle has no manifest.json"))?; let max = budget.limits.max_json.min(budget.limits.max_entry); let json = zip.read(entry, max, budget, MANIFEST)?; Ok((zip, json)) @@ -76,7 +83,7 @@ fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result Result<(u32, Option), Error> { - let Object(stamp) = parse::>(json, budget.limits)?; + let Object(stamp) = parse::>(json, budget)?; let version = stamp.format_version.ok_or(Error::Unrecognized)?; if version == 0 { return Err(malformed("format_version 0 is invalid")); @@ -107,7 +114,12 @@ fn read(json: &[u8], budget: &Budget) -> Result let mut limits = format::Limits::default(); limits.max_manifest_bytes = json.len(); limits.max_depth = (budget.limits.max_depth as usize).min(manifest_depth(MAX_GROUP_DEPTH)); - format::check_manifest(json, &limits).map_err(|e| match e { + let checked = match budget.control { + Some(control) => format::check_manifest_with_cancel(json, &limits, &|| control.check().is_err()), + None => format::check_manifest(json, &limits), + }; + checked.map_err(|e| match e { + format::Error::Cancelled => Error::Cancelled, format::Error::LimitExceeded(m) => Error::LimitExceeded(m), e => malformed(e), })?; @@ -115,7 +127,8 @@ fn read(json: &[u8], budget: &Budget) -> Result if version > FORMAT_VERSION { return Err(too_new(Kind::PhotoCraft, version)); } - let Object(m) = parse::>>(json, budget.limits)?; + budget.check_cancelled()?; + let Object(m) = parse::>>(json, budget)?; for (member, entry, expected) in [("thumbnail", &m.thumbnail, THUMB), ("composite", &m.composite, COMPOSITE)] { if let Some(entry) = entry && entry != expected @@ -137,7 +150,8 @@ pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result, } -impl From for Layer { - fn from(l: LayerM) -> Self { - Layer { - name: l.name, - kind: l.content.kind, - visible: l.visible, - opacity: Some(l.opacity), - blend: Some(l.blend), - children: l.content.children.into_iter().map(Layer::from).collect(), - } - } +fn layer(l: LayerM, budget: &Budget) -> Result { + budget.check_cancelled()?; + Ok(Layer { + name: l.name, + kind: l.content.kind, + visible: l.visible, + opacity: Some(l.opacity), + blend: Some(l.blend), + children: l.content.children.into_iter().map(|l| layer(l, budget)).collect::>()?, + }) } pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result { let (_, json) = manifest(bytes, budget)?; let d = read::(&json, budget)?.document; + document_outline(d, budget) +} + +fn document_outline(d: DocM, budget: &Budget) -> Result { Ok(Outline { canvas: Some(Canvas { width: d.size.width, height: d.size.height, resolution_dpi: d.resolution_dpi, mode: d.mode, depth: d.depth }), pages: vec![], - layers: d.layers.into_iter().map(Layer::from).collect(), + layers: d.layers.into_iter().map(|l| layer(l, budget)).collect::>()?, }) } +#[cfg(test)] +mod cancellation_tests { + #![allow(clippy::unwrap_used)] + use super::*; + use std::cell::Cell; + + #[test] + fn conversion_stops_inside_group_children() { + let child = r#"{"name":"child","visible":true,"blend":"Normal","opacity":1,"content":{"kind":"Raster"}}"#; + let parent = format!( + r#"{{"name":"group","visible":true,"blend":"Normal","opacity":1,"content":{{"kind":"Group","children":[{child},{child},{child},{child},{child}]}}}}"# + ); + let node: LayerM = serde_json::from_str(&parent).unwrap(); + let polls = Cell::new(0); + let cancel = || { + polls.set(polls.get() + 1); + polls.get() == 4 + }; + let result = crate::cancel::run(&cancel, |control| layer(node, &Budget::controlled(&crate::Limits::default(), control))); + assert_eq!(result, Err(Error::Cancelled)); + assert_eq!(polls.get(), 4); + } +} + /// The archive and the manifest's document read as `D`, checked as [`outline`] reads it. #[cfg(feature = "pcraft-pixels")] pub(crate) fn document<'a, D: DeserializeOwned>(bytes: &'a [u8], budget: &mut Budget) -> Result<(Zip<'a>, D), Error> { diff --git a/crates/doc/src/vectorcraft.rs b/crates/doc/src/vectorcraft.rs index 83b06750..eb6b1653 100644 --- a/crates/doc/src/vectorcraft.rs +++ b/crates/doc/src/vectorcraft.rs @@ -25,6 +25,7 @@ use flate2::read::GzDecoder; use serde::Deserialize; use serde::de::{self, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use crate::cancel::Reader; use crate::{ Budget, Error, Kind, Layer, Lenient, Outline, Page, Rendition, RenditionKind, Scalar, Shaped, counted, malformed, parse, parse_versioned, too_new, }; @@ -36,12 +37,37 @@ pub(crate) fn sniff(head: &[u8]) -> bool { craft_vectorcraft_format::sniff(head) } +pub(crate) fn sniff_checked(head: &[u8], control: Option<&crate::cancel::Control<'_>>) -> Result { + if control.is_none() || !is_compressed(head) { + return Ok(sniff(head)); + } + // Match the format crate's 256-byte probe, including truncated gzip input. Bound input + // reads too: optional gzip header fields can exceed the decoded probe's length. + let mut prefix = Vec::new(); + let _ = GzDecoder::new(Reader::new(head, control)).take(256).read_to_end(&mut prefix); + crate::cancel::check(control)?; + Ok(!is_compressed(&prefix) && sniff(&prefix)) +} + /// Where the JSON ends and the data after the marker starts, when `text` holds a NUL. JSON holds /// no raw NUL and the marker's second byte is one, so the JSON ends right before the first NUL. /// The marker must follow the JSON's closing `}`, `]` or `"`, as /// [`craft_vectorcraft_format::marker_at`] requires; any other NUL makes the file malformed. -fn marker(text: &[u8]) -> Result<(usize, usize), Error> { - marker_at(text).map(|at| (at, at + BLOB_MAGIC.len())).ok_or_else(|| malformed("the file holds a NUL byte outside the marker after its JSON")) +fn marker(text: &[u8], nul: usize) -> Result<(usize, usize), Error> { + // Start at the byte before the marker so the shared check inspects a bounded suffix, + // rather than scanning the already searched JSON again. + let found = nul.checked_sub(2).and_then(|start| marker_at(text.get(start..)?).map(|at| start + at)); + found.map(|at| (at, at + BLOB_MAGIC.len())).ok_or_else(|| malformed("the file holds a NUL byte outside the marker after its JSON")) +} + +fn first_nul(bytes: &[u8], budget: &Budget) -> Result, Error> { + for (i, chunk) in bytes.chunks(crate::cancel::CHUNK).enumerate() { + budget.check_cancelled()?; + if chunk.contains(&0) { + return Ok(chunk.iter().position(|&b| b == 0).map(|at| i * crate::cancel::CHUNK + at)); + } + } + Ok(None) } fn damaged(e: std::io::Error) -> Error { @@ -49,21 +75,21 @@ fn damaged(e: std::io::Error) -> Error { } /// A file's JSON, and the data after it. -struct Unpacked<'a> { +struct Unpacked<'a, 'c> { json: Cow<'a, [u8]>, - tail: Tail<'a>, + tail: Tail<'a, 'c>, } /// The data after the marker. -enum Tail<'a> { +enum Tail<'a, 'c> { /// In an uncompressed file. Slice(&'a [u8]), /// Still in the gzip stream: `read` holds what was decompressed past the marker already, not /// yet counted against the budget. - Stream { dec: Box>, read: Vec }, + Stream { dec: Box>>, read: Vec }, } -impl Tail<'_> { +impl Tail<'_, '_> { /// The bytes each `[offset, length]` in `ranges` names, or `None` for a range the file ends /// before. A compressed file is decompressed only as far as the last range ends, and that /// offset is checked against the budget before anything past the marker is decompressed. @@ -71,7 +97,7 @@ impl Tail<'_> { fn ranges(self, ranges: &[[u64; 2]], budget: &mut Budget) -> Result>>, Error> { const WHAT: &str = "the decompressed document"; let (mut dec, read) = match self { - Tail::Slice(s) => return Ok(ranges.iter().map(|&r| entry(s, r).map(<[u8]>::to_vec)).collect()), + Tail::Slice(s) => return ranges.iter().map(|&r| entry(s, r).map(|bytes| budget.copy(bytes)).transpose()).collect(), Tail::Stream { dec, read } => (dec, read), }; let end = ranges.iter().map(|&[offset, len]| offset.saturating_add(len)).max().unwrap_or(0); @@ -96,7 +122,7 @@ impl Tail<'_> { let stated = ranges.iter().fold(0u64, |n, &[_, len]| n.saturating_add(len)); let spanned = merged.iter().fold(0u64, |n, &[start, end]| n.saturating_add(end - start)); budget.take(stated.saturating_sub(spanned), WHAT)?; - let mut src = read.as_slice().chain(&mut *dec); + let mut src = Reader::new(read.as_slice().chain(&mut *dec), budget.control); let (mut at, mut kept) = (0u64, Vec::<(u64, Vec)>::new()); for [start, end] in merged { let skipped = std::io::copy(&mut (&mut src).take(start.saturating_sub(at)), &mut std::io::sink()).map_err(damaged)?; @@ -114,22 +140,24 @@ impl Tail<'_> { break; } } - Ok(ranges + ranges .iter() - .map(|&[offset, len]| kept.iter().find_map(|(start, bytes)| entry(bytes, [offset.checked_sub(*start)?, len])).map(<[u8]>::to_vec)) - .collect()) + .map(|&[offset, len]| { + kept.iter().find_map(|(start, bytes)| entry(bytes, [offset.checked_sub(*start)?, len])).map(|bytes| budget.copy(bytes)).transpose() + }) + .collect() } } /// A file's JSON and where the data after it is. A compressed file is decompressed only as far /// as the end of the marker, every byte used counted against the budget, and its JSON is /// checked against `max_json` as it grows. -fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result, Error> { +fn unpack<'a, 'c>(bytes: &'a [u8], budget: &mut Budget<'c>) -> Result, Error> { const JSON: &str = "the document's JSON"; let too_long = |budget: &Budget| Error::LimitExceeded(format!("{JSON} is more than max_json ({} bytes)", budget.limits.max_json)); if !is_compressed(bytes) { - let (json, tail) = if bytes.contains(&0) { - let (end, tail) = marker(bytes)?; + let (json, tail) = if let Some(nul) = first_nul(bytes, budget)? { + let (end, tail) = marker(bytes, nul)?; (bytes.get(..end).unwrap_or_default(), bytes.get(tail..).unwrap_or_default()) } else { (bytes, &[][..]) @@ -137,18 +165,18 @@ fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result, Erro budget.check_json(json.len() as u64, JSON)?; return Ok(Unpacked { json: Cow::Borrowed(json), tail: Tail::Slice(tail) }); } - let mut dec = Box::new(GzDecoder::new(bytes)); + let mut dec = Box::new(GzDecoder::new(Reader::new(bytes, budget.control))); let mut buf = Vec::new(); let mut split: Option<(usize, usize)> = None; let mut nul: Option = None; loop { let start = buf.len(); let want = CHUNK.min(budget.left().saturating_add(1)); - let n = (&mut *dec).take(want).read_to_end(&mut buf).map_err(damaged)?; + let n = Reader::new(&mut *dec, budget.control).take(want).read_to_end(&mut buf).map_err(damaged)?; // The JSON ends before the first NUL: until there is one, all of it is JSON. The marker // may end in a later chunk than its first NUL. if nul.is_none() { - nul = buf.get(start..).and_then(|b| b.iter().position(|&c| c == 0)).map(|p| p + start); + nul = first_nul(buf.get(start..).unwrap_or_default(), budget)?.map(|p| p + start); } if nul.unwrap_or(buf.len()).saturating_sub(1) as u64 > budget.limits.max_json { return Err(too_long(budget)); @@ -157,7 +185,7 @@ fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result, Erro if let Some(nul) = nul && buf.len() >= nul + BLOB_MAGIC.len() - 1 { - let s = marker(&buf)?; + let s = marker(&buf, nul)?; split = Some(s); // The data after the marker in this chunk is counted when it is read. used = s.1.saturating_sub(start).min(n); @@ -170,7 +198,7 @@ fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result, Erro let (json_len, tail_at) = match (split, nul) { (Some(s), _) => s, // The stream ended inside the marker. - (None, Some(_)) => marker(&buf)?, + (None, Some(nul)) => marker(&buf, nul)?, (None, None) => (buf.len(), buf.len()), }; budget.check_json(json_len as u64, JSON)?; @@ -408,7 +436,7 @@ impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp { /// The file's [`Stamp`], refused when the format is not VectorCraft's, and when the version is /// newer than this crate reads unless `newer` is true. fn stamp(json: &[u8], budget: &Budget, newer: bool) -> Result, Error> { - let s: Stamp = if newer { parse(json, budget.limits)? } else { parse_versioned(json, budget.limits, Kind::VectorCraft)? }; + let s: Stamp = if newer { parse(json, budget)? } else { parse_versioned(json, budget, Kind::VectorCraft)? }; match craft_vectorcraft_format::check(&s.format, s.version) { Ok(()) => Ok(s), Err(craft_vectorcraft_format::Error::Format(format)) => Err(malformed(format!("format is `{format}`"))), @@ -424,6 +452,15 @@ pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option Result, Error> { + let bytes = if budget.control.is_some() { + image.bytes_at_most_with_check(&[], u64::MAX, || budget.check_cancelled())? + } else { + image.bytes_at_most(&[], u64::MAX) + }; + bytes.ok_or_else(|| malformed(format!("the {what} is not valid base64"))) +} + pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result, Error> { let Unpacked { json, tail } = unpack(bytes, budget)?; let head = stamp::(&json, budget, false)?; @@ -437,10 +474,11 @@ pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result = members.iter().filter_map(|(_, m, _)| m.blob).collect(); @@ -448,10 +486,12 @@ pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result blobs.next().flatten().ok_or_else(|| malformed(format!("the file ends before its {what}")))?, - None => m.bytes_at_most(&[], u64::MAX).ok_or_else(|| malformed(format!("the {what} is not valid base64")))?, + None => inline_bytes(&m, budget, what)?, }; + budget.check_cancelled()?; Ok(Rendition { kind, mime: m.mime, bytes }) }) .collect() @@ -517,34 +557,42 @@ struct KindM { pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result { let json = unpack(bytes, budget)?.json; stamp::(&json, budget, false)?; - let f: WithDocument = parse(&json, budget.limits)?; - let pages = f - .document + let f: WithDocument = parse(&json, budget)?; + document_outline(f.document, budget) +} + +fn document_outline(document: DocM, budget: &Budget) -> Result { + let pages = document .artboards .into_iter() - .map(|a| Page { - name: a.name, - x: a.rect.x0.min(a.rect.x1), - y: a.rect.y0.min(a.rect.y1), - width: (a.rect.x1 - a.rect.x0).abs(), - height: (a.rect.y1 - a.rect.y0).abs(), - timing: None, - layers: vec![], + .map(|a| { + budget.check_cancelled()?; + Ok(Page { + name: a.name, + x: a.rect.x0.min(a.rect.x1), + y: a.rect.y0.min(a.rect.y1), + width: (a.rect.x1 - a.rect.x0).abs(), + height: (a.rect.y1 - a.rect.y0).abs(), + timing: None, + layers: vec![], + }) }) - .collect(); - let layers = f - .document + .collect::>()?; + let layers = document .layers .into_iter() - .map(|n| Layer { - name: n.name.unwrap_or_default(), - kind: n.kind.kind, - visible: n.visible, - opacity: Some(n.opacity), - blend: Some(n.blend.unwrap_or_else(|| "Normal".into())), - children: vec![], + .map(|n| { + budget.check_cancelled()?; + Ok(Layer { + name: n.name.unwrap_or_default(), + kind: n.kind.kind, + visible: n.visible, + opacity: Some(n.opacity), + blend: Some(n.blend.unwrap_or_else(|| "Normal".into())), + children: vec![], + }) }) - .collect(); + .collect::>()?; Ok(Outline { canvas: None, pages, layers }) } @@ -554,6 +602,36 @@ mod tests { use crate::tests::{unread_array, unread_object}; use serde::de::value::{Error as ValueError, MapDeserializer, SeqDeserializer, StrDeserializer}; + #[test] + #[allow(clippy::unwrap_used)] + fn conversion_stops_inside_document_layers() { + use std::cell::Cell; + let node = r#"{"kind":{"type":"Path"}}"#; + let json = format!(r#"{{"layers":[{node},{node},{node},{node},{node}]}}"#); + let document: DocM = serde_json::from_str(&json).unwrap(); + let polls = Cell::new(0); + let cancel = || { + polls.set(polls.get() + 1); + polls.get() == 4 + }; + let result = crate::cancel::run(&cancel, |control| document_outline(document, &Budget::controlled(&crate::Limits::default(), control))); + assert_eq!(result, Err(Error::Cancelled)); + assert_eq!(polls.get(), 4); + } + + #[test] + fn inline_cancellation_interrupts_extraction() { + let image = Image::inline("application/pdf", &vec![1; 2 << 20]); + let polls = std::cell::Cell::new(0); + let cancelled = || { + polls.set(polls.get() + 1); + polls.get() >= 10 + }; + let result = crate::cancel::run(&cancelled, |control| inline_bytes(&image, &Budget::controlled(&crate::Limits::default(), control), "PDF")); + assert!(matches!(result, Err(Error::Cancelled))); + assert_eq!(polls.get(), 10); + } + #[test] fn an_image_of_the_wrong_shape_is_skipped_without_reading_it_whole() { assert!(Lenient::::deserialize(unread_object(1000)).unwrap().0.is_none()); @@ -612,7 +690,7 @@ mod tests { for shape in shapes { let json = format!(r#"{{"format":"vectorcraft","version":4,"generator":{shape},"preview":{shape},"pdf":{shape},"document":0}}"#); let file = craft_vectorcraft_format::File::::read(json.as_bytes()).unwrap(); - let stamp: Stamp = crate::parse(json.as_bytes(), &crate::Limits::default()).unwrap(); + let stamp: Stamp = crate::parse(json.as_bytes(), &crate::Budget::new(&crate::Limits::default())).unwrap(); assert_eq!((&stamp.generator, &stamp.preview, &stamp.pdf), (&file.generator, &file.preview, &file.pdf), "{shape}"); } } diff --git a/crates/doc/src/zip.rs b/crates/doc/src/zip.rs index abc3721f..788e4a85 100644 --- a/crates/doc/src/zip.rs +++ b/crates/doc/src/zip.rs @@ -55,7 +55,8 @@ pub(crate) struct Zip<'a> { } impl<'a> Zip<'a> { - pub(crate) fn open(bytes: &'a [u8]) -> Result { + pub(crate) fn open(bytes: &'a [u8], control: Option<&crate::cancel::Control<'_>>) -> Result { + crate::cancel::check(control)?; let last = bytes.len().checked_sub(EOCD_LEN).ok_or_else(|| malformed("not a ZIP archive (too short)"))?; // The comment may itself hold the signature: accept only a record whose comment ends the // file and whose central directory ends where the record starts. @@ -87,6 +88,7 @@ impl<'a> Zip<'a> { let mut entries = Vec::with_capacity(count.min(dir_size / CENTRAL_LEN)); let mut p = dir; for _ in 0..count { + crate::cancel::check(control)?; if u32_at(bytes, p) != Some(CENTRAL_SIG) || p + CENTRAL_LEN > eocd { return Err(malformed("bad ZIP central directory entry")); } @@ -123,10 +125,16 @@ impl<'a> Zip<'a> { /// The first entry named `name`. Each call walks the central directory; for many lookups, /// use [`Zip::index`]. - pub(crate) fn find(&self, name: &str) -> Option<&Entry> { + pub(crate) fn find(&self, name: &str, control: Option<&crate::cancel::Control<'_>>) -> Result, Error> { #[cfg(test)] SCANS.with(|n| n.set(n.get() + 1)); - self.entries.iter().find(|e| self.bytes.get(e.name.clone()) == Some(name.as_bytes())) + for e in &self.entries { + crate::cancel::check(control)?; + if self.bytes.get(e.name.clone()) == Some(name.as_bytes()) { + return Ok(Some(e)); + } + } + Ok(None) } /// Every entry by name, the first of each name as [`Zip::find`] gives it, built in one walk of @@ -163,18 +171,26 @@ impl<'a> Zip<'a> { if e.compressed != size { return Err(malformed(format!("{what} has the wrong size"))); } - raw.to_vec() + budget.copy(raw)? } else { // DEFLATE expands at most 1032 times. let mut out = Vec::with_capacity(size.min(raw.len().saturating_mul(1032))); - flate2::read::DeflateDecoder::new(raw).take(e.size + 1).read_to_end(&mut out).map_err(|err| malformed(format!("{what}: {err}")))?; + let input = crate::cancel::Reader::new(raw, budget.control); + let decoder = flate2::read::DeflateDecoder::new(input); + crate::cancel::Reader::new(decoder, budget.control) + .take(e.size + 1) + .read_to_end(&mut out) + .map_err(|err| malformed(format!("{what}: {err}")))?; if out.len() != size { return Err(malformed(format!("{what} has the wrong size"))); } out }; let mut crc = flate2::Crc::new(); - crc.update(&data); + for chunk in data.chunks(crate::cancel::CHUNK) { + budget.check_cancelled()?; + crc.update(chunk); + } if crc.sum() != e.crc { return Err(malformed(format!("{what} failed its CRC check"))); } @@ -185,26 +201,27 @@ impl<'a> Zip<'a> { /// Does a local header in `head` name `name`? Walks the local headers from the start, for a /// head too short to reach the central directory; stops at an entry whose size is only in a /// data descriptor. -pub(crate) fn local_names_include(head: &[u8], name: &str) -> bool { +pub(crate) fn local_names_include(head: &[u8], name: &str, control: Option<&crate::cancel::Control<'_>>) -> Result { let mut p = 0usize; while u32_at(head, p) == Some(LOCAL_SIG) { + crate::cancel::check(control)?; let (Some(flags), Some(compressed), Some(name_len), Some(extra_len)) = (u16_at(head, p + 6), u32_at(head, p + 18), u16_at(head, p + 26), u16_at(head, p + 28)) else { - return false; + return Ok(false); }; let start = p + LOCAL_LEN; let end = start + usize::from(name_len); if head.get(start..end) == Some(name.as_bytes()) { - return true; + return Ok(true); } if flags & 8 != 0 { - return false; + return Ok(false); } p = match end.checked_add(usize::from(extra_len)).and_then(|q| q.checked_add(compressed as usize)) { Some(q) => q, - None => return false, + None => return Ok(false), }; } - false + Ok(false) } diff --git a/crates/doc/tests/cancellation.rs b/crates/doc/tests/cancellation.rs new file mode 100644 index 00000000..17eb781a --- /dev/null +++ b/crates/doc/tests/cancellation.rs @@ -0,0 +1,135 @@ +//! Cancellation is observed inside reads, with no timing or worker-thread scheduling assumptions. + +mod common; + +use std::cell::Cell; + +use common::*; +use craft_doc::{Error, Limits, info, info_with_cancel, outline, outline_with_cancel, renditions, renditions_with_cancel}; + +type Read = fn(&[u8], &Limits, &dyn Fn() -> bool) -> Result<(), Error>; + +const READS: [Read; 3] = [ + |bytes, limits, cancel| info_with_cancel(bytes, limits, cancel).map(|_| ()), + |bytes, limits, cancel| renditions_with_cancel(bytes, limits, cancel).map(|_| ()), + |bytes, limits, cancel| outline_with_cancel(bytes, limits, cancel).map(|_| ()), +]; + +#[test] +fn cancellation_interrupts_detection_before_an_unrecognized_result() { + let json = format!(r#"{{"savedBy":"{}","unrelated":0}}"#, "\\n".repeat(1 << 20)); + // Optional filename and comment fields are consumed before the decoded prefix. + let mut header = vec![0x1f, 0x8b, 8, 24, 0, 0, 0, 0, 0, 3]; + header.extend(std::iter::repeat_n(b'x', 60_000)); + header.push(0); + header.extend(std::iter::repeat_n(b'x', 60_000)); + header.push(0); + header.extend(&gzip(b"unrelated")[10..]); + let entries: Vec<_> = (0..200).map(|_| store("other", b"")).collect(); + let archive = zip(&entries); + // The truncated bundle forces detection to walk local headers instead of the directory. + let local = &archive[..archive.len() / 4]; + for (case, bytes) in [json.as_bytes(), header.as_slice(), archive.as_slice(), local].into_iter().enumerate() { + for read in READS { + let threshold = if case == 1 { 3 } else { 10 }; + let polls = Cell::new(0); + let cancel = || { + polls.set(polls.get() + 1); + polls.get() == threshold + }; + assert_eq!(read(bytes, &Limits::default(), &cancel), Err(Error::Cancelled), "detection case {case}, polls {}", polls.get()); + assert_eq!(polls.get(), threshold); + } + } +} + +#[test] +fn reads_honor_an_initial_request_even_for_unrecognized_input() { + for read in READS { + assert_eq!(read(b"not a document", &Limits::default(), &|| true), Err(Error::Cancelled)); + } +} + +#[test] +fn uncanceled_reads_preserve_results_and_errors() { + let vc = vectorcraft_json(4, ""); + for file in [vc.as_bytes().to_vec(), gzip(vc.as_bytes()), pcraft(&png(1, 1, 0), &png(2, 2, 0)), ecproj(2).into_bytes(), vec![1]] { + let limits = Limits::default(); + assert_eq!(info_with_cancel(&file, &limits, &|| false), info(&file, &limits)); + assert_eq!(renditions_with_cancel(&file, &limits, &|| false), renditions(&file, &limits)); + assert_eq!(outline_with_cancel(&file, &limits, &|| false), outline(&file, &limits)); + } +} + +#[test] +fn cancellation_after_work_starts_interrupts_plain_and_gzip_json() { + let json = vectorcraft_json(4, &format!(r#""padding":"{}""#, "x".repeat(2 << 20))); + for file in [json.as_bytes().to_vec(), gzip(json.as_bytes())] { + for read in READS { + let polls = Cell::new(0); + let cancel = || { + polls.set(polls.get() + 1); + polls.get() >= 20 + }; + assert_eq!(read(&file, &Limits::default(), &cancel), Err(Error::Cancelled)); + assert_eq!(polls.get(), 20, "the request is latched instead of being polled again"); + } + } +} + +#[test] +fn cancellation_interrupts_zip_manifest_reads() { + let json = pcraft_manifest(1, "[]", &format!(r#","padding":"{}""#, "x".repeat(2 << 20))); + for entry in [store("manifest.json", json.as_bytes()), deflated("manifest.json", json.as_bytes())] { + let file = zip(&[entry]); + for read in READS { + let polls = Cell::new(0); + let cancel = || { + polls.set(polls.get() + 1); + polls.get() >= 20 + }; + assert_eq!(read(&file, &Limits::default(), &cancel), Err(Error::Cancelled)); + } + } +} + +#[test] +fn cancellation_interrupts_the_effectcraft_header_parse() { + let original = ecproj(2); + let prefix = original.trim_end().strip_suffix('}').unwrap(); + let json = format!(r#"{prefix},"padding":"{}"}}"#, "x".repeat(2 << 20)); + // Allow the initial callback, input-size check and complete depth scan. Cancellation then + // arrives after the header's own reader has consumed some input. + let threshold = json.len().div_ceil(64 * 1024) + 6; + let polls = Cell::new(0); + let cancel = || { + polls.set(polls.get() + 1); + polls.get() >= threshold + }; + assert_eq!(info_with_cancel(json.as_bytes(), &Limits::default(), &cancel), Err(Error::Cancelled)); +} + +#[test] +fn controlled_reads_keep_limit_and_malformed_input_errors() { + let json = vectorcraft_json(4, r#""preview":{"mime":"image/png","data":"AQID"}"#); + let files = [ + json.as_bytes().to_vec(), + gzip(json.as_bytes()), + json.as_bytes()[..json.len() - 1].to_vec(), + vectorcraft_json(99, "").into_bytes(), + pcraft(&png(1, 1, 0), &png(2, 2, 0)), + ]; + for file in files { + let mut bounds = vec![Limits::default(); 6]; + bounds[1].max_json = 16; + bounds[2].max_entry = 0; + bounds[3].max_total = 8; + bounds[4].max_depth = 1; + bounds[5].max_items = 0; + for limits in bounds { + assert_eq!(info_with_cancel(&file, &limits, &|| false), info(&file, &limits)); + assert_eq!(renditions_with_cancel(&file, &limits, &|| false), renditions(&file, &limits)); + assert_eq!(outline_with_cancel(&file, &limits, &|| false), outline(&file, &limits)); + } + } +} diff --git a/crates/ecproj-format/README.md b/crates/ecproj-format/README.md index 55426f21..bcb26e43 100644 --- a/crates/ecproj-format/README.md +++ b/crates/ecproj-format/README.md @@ -38,6 +38,8 @@ let head = Head::read(&text)?; flattened into it. `to_json()` gives the file's text indented with two spaces, and `to_json_compact()` gives it on one line. Neither checks the values: `serde_json` writes NaN and infinity as `null`, so the caller checks its values before saving. +- `Head::read_from(reader)` applies the same header parsing to a `std::io::Read`, allowing a + caller to bound or interrupt input. File/network readers should be buffered. - `Head::read(text) -> Result` reads `savedBy` and `schema` from one JSON object, and skips other members. `savedBy` is `None` when it is absent, is not a string, or appears more than once. Text that is not a JSON object, has no `schema` that is a `u32`, or has diff --git a/crates/ecproj-format/src/lib.rs b/crates/ecproj-format/src/lib.rs index 344d14ed..67d7e811 100644 --- a/crates/ecproj-format/src/lib.rs +++ b/crates/ecproj-format/src/lib.rs @@ -139,7 +139,16 @@ impl Head { /// members are skipped, and two `schema` members are refused. A schema newer than /// [`SCHEMA_VERSION`] is refused with [`ReadError::TooNew`], which holds the file's `savedBy`. pub fn read(text: &str) -> Result { - let mut deserializer = serde_json::Deserializer::from_str(text); + Self::read_deserializer(serde_json::Deserializer::from_str(text)) + } + + /// [`Head::read`] from a reader, which may enforce input limits or cooperative + /// interruption. Buffer file or network readers to avoid one-byte I/O requests. + pub fn read_from(reader: impl std::io::Read) -> Result { + Self::read_deserializer(serde_json::Deserializer::from_reader(reader)) + } + + fn read_deserializer<'de, R: serde_json::de::Read<'de>>(mut deserializer: serde_json::Deserializer) -> Result { let head = deserializer.deserialize_map(HeadVisitor)?; deserializer.end()?; if let Err(too_new) = check_schema(head.schema) { diff --git a/crates/ecproj-format/tests/envelope.rs b/crates/ecproj-format/tests/envelope.rs index b6443d4c..182ea1f5 100644 --- a/crates/ecproj-format/tests/envelope.rs +++ b/crates/ecproj-format/tests/envelope.rs @@ -120,6 +120,56 @@ fn a_project_saved_before_saved_by_has_a_head_without_it() { assert_eq!(Head::read(text).unwrap(), Head { saved_by: None, schema: 1 }); } +#[test] +fn reader_heads_preserve_string_reader_behavior() { + for text in [ + r#"{"schema":2,"savedBy":"writer","items":{}}"#, + r#"{"schema":99,"savedBy":"new writer"}"#, + r#"{"schema":2,"savedBy":"first","savedBy":"second"}"#, + r#"{"schema":2,"schema":2}"#, + r#"{"schema":"bad"}"#, + "[]", + "", + ] { + match (Head::read_from(text.as_bytes()), Head::read(text)) { + (Ok(from_reader), Ok(from_str)) => assert_eq!(from_reader, from_str), + (Err(ReadError::Json(from_reader)), Err(ReadError::Json(from_str))) => { + assert_eq!(from_reader.classify(), from_str.classify()); + // serde's reader may consume lookahead before reporting the same error. + assert_eq!(from_reader.to_string().split(" at line ").next(), from_str.to_string().split(" at line ").next()); + } + (Err(ReadError::TooNew { too_new: a, saved_by: a_writer, .. }), Err(ReadError::TooNew { too_new: b, saved_by: b_writer, .. })) => { + assert_eq!(a.schema, b.schema); + assert_eq!(a_writer, b_writer); + } + (from_reader, from_str) => panic!("different header results: {from_reader:?}, {from_str:?}"), + } + } +} + +#[test] +fn reader_heads_propagate_an_interruption_before_consuming_all_input() { + struct Interrupted { + input: std::io::Cursor>, + left: usize, + } + impl std::io::Read for Interrupted { + fn read(&mut self, bytes: &mut [u8]) -> std::io::Result { + if self.left == 0 { + return Err(std::io::Error::other("stopped")); + } + let len = bytes.len().min(self.left); + let n = std::io::Read::read(&mut self.input, &mut bytes[..len])?; + self.left -= n; + Ok(n) + } + } + let json = format!(r#"{{"schema":2,"padding":"{}"}}"#, "x".repeat(128 * 1024)); + let mut reader = Interrupted { input: std::io::Cursor::new(json.into_bytes()), left: 32 * 1024 }; + assert!(matches!(Head::read_from(&mut reader), Err(ReadError::Json(e)) if e.is_io())); + assert_eq!(reader.input.position(), 32 * 1024); +} + #[test] fn a_head_needs_a_schema() { assert!(Head::read(r#"{"savedBy": "0.12.0", "items": {}}"#).is_err()); diff --git a/crates/pcraft-format/README.md b/crates/pcraft-format/README.md index 29321df8..591fc0ee 100644 --- a/crates/pcraft-format/README.md +++ b/crates/pcraft-format/README.md @@ -57,11 +57,13 @@ let manifest: Manifest = craft_pcraft_format::read_manifest(&manifes without recursion. PhotoCraft's own check of a manifest it saves tests the nesting and the item count only. `parse_manifest(json, &Limits)` runs that check and then parses into a `serde_json::Value` with serde_json's 128-level recursion limit turned off. +- `check_manifest_with_cancel(json, &Limits, &cancelled)` applies those same checks while + polling a callback between bounded portions of JSON. A true result stops with `Error::Cancelled`. - `migrate(&mut value, steps, target)` upgrades the JSON in place, where `steps[i]` upgrades version `i + 1` to `i + 2`, and returns the version the file had. `Step` is `fn(&mut Value) -> Result<(), E>`; with `E: From`, the steps return the caller's own error type. -- `Error` is `Corrupt`, `Unsupported`, `TooNew`, `LimitExceeded` or `Json`. A manifest whose +- `Error` is `Corrupt`, `Unsupported`, `TooNew`, `LimitExceeded`, `Json` or `Cancelled`. A manifest whose `format_version` is newer than `target` gives `TooNew`, and one with no step to the next version gives `Unsupported`. A missing or invalid `format_version` gives `Corrupt`, and any other missing or mistyped member gives `Json`. `TooNew` holds `found`, `supported` and the manifest's diff --git a/crates/pcraft-format/src/lib.rs b/crates/pcraft-format/src/lib.rs index eb6482aa..1c0a33e0 100644 --- a/crates/pcraft-format/src/lib.rs +++ b/crates/pcraft-format/src/lib.rs @@ -37,7 +37,9 @@ mod read; use serde::{Deserialize, Serialize}; -pub use limits::{Limits, MAX_DEPTH, MAX_GROUP_DEPTH, MAX_MANIFEST_ITEMS, check_manifest, manifest_depth, parse_manifest}; +pub use limits::{ + Limits, MAX_DEPTH, MAX_GROUP_DEPTH, MAX_MANIFEST_ITEMS, check_manifest, check_manifest_with_cancel, manifest_depth, parse_manifest, +}; pub use migrate::{Step, migrate}; pub use read::{read_manifest, read_manifest_with}; @@ -168,6 +170,9 @@ impl TileRef { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// A cancellation-aware validation observed the caller's request. + #[error("manifest check canceled")] + Cancelled, /// The manifest breaks the format. #[error("corrupt .pcraft bundle: {0}")] Corrupt(String), diff --git a/crates/pcraft-format/src/limits.rs b/crates/pcraft-format/src/limits.rs index 62c4ad10..047c81c6 100644 --- a/crates/pcraft-format/src/limits.rs +++ b/crates/pcraft-format/src/limits.rs @@ -65,37 +65,55 @@ impl Default for Limits { /// This check also refuses JSON larger than [`Limits::max_manifest_bytes`], so a writer that runs /// it before saving refuses a manifest of more than that size. pub fn check_manifest(json: &[u8], limits: &Limits) -> Result<(), Error> { + checked_manifest(json, limits, None) +} + +/// [`check_manifest`] with cooperative cancellation while scanning the JSON. The callback +/// runs on this thread; returning true stops validation with [`Error::Cancelled`]. +pub fn check_manifest_with_cancel(json: &[u8], limits: &Limits, cancelled: &dyn Fn() -> bool) -> Result<(), Error> { + checked_manifest(json, limits, Some(cancelled)) +} + +fn checked_manifest(json: &[u8], limits: &Limits, cancelled: Option<&dyn Fn() -> bool>) -> Result<(), Error> { + let check = || match cancelled { + Some(cancelled) if cancelled() => Err(Error::Cancelled), + _ => Ok(()), + }; + check()?; if json.len() > limits.max_manifest_bytes { return Err(Error::LimitExceeded(format!("`{}` exceeds {} bytes", crate::MANIFEST, limits.max_manifest_bytes))); } let max_depth = limits.max_depth.min(MAX_DEPTH); let (mut depth, mut items, mut in_str, mut escaped) = (0usize, 0usize, false, false); - for &b in json { - if in_str { + for chunk in json.chunks(64 * 1024) { + check()?; + for &b in chunk { + if in_str { + match b { + _ if escaped => escaped = false, + b'\\' => escaped = true, + b'"' => in_str = false, + _ => {} + } + continue; + } match b { - _ if escaped => escaped = false, - b'\\' => escaped = true, - b'"' => in_str = false, + b'"' => in_str = true, + b'[' | b'{' => { + depth += 1; + if depth > max_depth { + return Err(Error::LimitExceeded(format!("manifest JSON nested deeper than {max_depth} levels"))); + } + } + b']' | b'}' => depth = depth.saturating_sub(1), _ => {} } - continue; - } - match b { - b'"' => in_str = true, - b'[' | b'{' => { - depth += 1; - if depth > max_depth { - return Err(Error::LimitExceeded(format!("manifest JSON nested deeper than {max_depth} levels"))); + if matches!(b, b'[' | b'{' | b',') { + items += 1; + if items > limits.max_items { + return Err(Error::LimitExceeded(format!("manifest JSON holds more than {} values", limits.max_items))); } } - b']' | b'}' => depth = depth.saturating_sub(1), - _ => {} - } - if matches!(b, b'[' | b'{' | b',') { - items += 1; - if items > limits.max_items { - return Err(Error::LimitExceeded(format!("manifest JSON holds more than {} values", limits.max_items))); - } } } Ok(()) diff --git a/crates/pcraft-format/tests/limits.rs b/crates/pcraft-format/tests/limits.rs index a7caf6f1..5abb2767 100644 --- a/crates/pcraft-format/tests/limits.rs +++ b/crates/pcraft-format/tests/limits.rs @@ -1,4 +1,24 @@ -use craft_pcraft_format::{Error, Limits, MAX_DEPTH, MAX_GROUP_DEPTH, MAX_MANIFEST_ITEMS, check_manifest, manifest_depth, parse_manifest}; +use craft_pcraft_format::{ + Error, Limits, MAX_DEPTH, MAX_GROUP_DEPTH, MAX_MANIFEST_ITEMS, check_manifest, check_manifest_with_cancel, manifest_depth, parse_manifest, +}; + +#[test] +fn cancellation_stops_manifest_validation_after_it_starts() { + let polls = std::cell::Cell::new(0); + let cancelled = || { + polls.set(polls.get() + 1); + polls.get() >= 3 + }; + let json = format!(r#"{{"padding":"{}"}}"#, "x".repeat(256 * 1024)); + assert!(matches!(check_manifest_with_cancel(json.as_bytes(), &Limits::default(), &cancelled), Err(Error::Cancelled))); + assert_eq!(polls.get(), 3); + for limits in [Limits::default(), small(1, 1)] { + assert_eq!( + check_manifest_with_cancel(json.as_bytes(), &limits, &|| false).map_err(|e| e.to_string()), + check_manifest(json.as_bytes(), &limits).map_err(|e| e.to_string()) + ); + } +} fn nested(depth: usize) -> Vec { let mut json = "[".repeat(depth).into_bytes(); diff --git a/crates/vectorcraft-format/README.md b/crates/vectorcraft-format/README.md index e69a3ac9..1925a097 100644 --- a/crates/vectorcraft-format/README.md +++ b/crates/vectorcraft-format/README.md @@ -94,6 +94,10 @@ let pdf = file.pdf.map(|p| entries.bytes(&p)).transpose()?.flatten(); for version 4 and entries of at least `INLINE_MAX` bytes, inline otherwise. `Tail::write_to` writes the marker and the entries. - `base64::encode`, `decode` and `decoded_len` handle the `data` members. +- `Image::len_with_check` and `bytes_at_most_with_check` accept a fallible checkpoint called + during bounded scans, base64 decoding and blob copying. The checkpoint error is returned + unchanged; `Ok(None)` keeps `bytes_at_most`'s meaning. `base64::decoded_len_with_check` and + `decode_with_check` expose the same checkpoints for standalone base64 data. Size limits on the JSON and on decompression stay with each reader. diff --git a/crates/vectorcraft-format/src/base64.rs b/crates/vectorcraft-format/src/base64.rs index 2af25a36..c4ab9f4b 100644 --- a/crates/vectorcraft-format/src/base64.rs +++ b/crates/vectorcraft-format/src/base64.rs @@ -41,6 +41,69 @@ pub fn decoded_len(s: &str) -> u64 { (n / 4 * 3).saturating_sub(pad) } +pub(crate) const CHECK_CHUNK: usize = 64 * 1024; + +/// [`decoded_len`] with a fallible checkpoint before each bounded portion of input. +pub fn decoded_len_with_check(s: &str, mut check: impl FnMut() -> Result<(), E>) -> Result { + let (n, pad) = measured_with_check(s, &mut check)?; + Ok((n / 4 * 3).saturating_sub(pad)) +} + +fn measured_with_check(s: &str, mut check: impl FnMut() -> Result<(), E>) -> Result<(u64, u64), E> { + check()?; + let (mut n, mut pad) = (0u64, 0u64); + for chunk in s.as_bytes().chunks(CHECK_CHUNK) { + check()?; + for &b in chunk { + if !b.is_ascii_whitespace() { + n += 1; + pad = if b == b'=' { (pad + 1).min(2) } else { 0 }; + } + } + } + Ok((n, pad)) +} + +/// [`decode`] with fallible checkpoints during length calculation and decoding. Whitespace, +/// padding and non-canonical groups follow [`decode`]; `Ok(None)` means invalid base64. +pub fn decode_with_check(s: &str, mut check: impl FnMut() -> Result<(), E>) -> Result>, E> { + let (n, pad) = measured_with_check(s, &mut check)?; + if !n.is_multiple_of(4) { + return Ok(None); + } + let Ok(len) = usize::try_from((n / 4 * 3).saturating_sub(pad)) else { return Ok(None) }; + decode_chunks(s, len, &mut check) +} + +fn decode_chunks(s: &str, capacity: usize, check: &mut impl FnMut() -> Result<(), E>) -> Result>, E> { + let mut out = Vec::with_capacity(capacity); + let mut clean = Vec::with_capacity(CHECK_CHUNK); + let mut ended = false; + for chunk in s.as_bytes().chunks(CHECK_CHUNK) { + check()?; + for &b in chunk { + if b.is_ascii_whitespace() { + continue; + } + if ended { + return Ok(None); + } + clean.push(b); + if clean.len() == CHECK_CHUNK { + let Ok(text) = std::str::from_utf8(&clean) else { return Ok(None) }; + let Some(decoded) = decode(text) else { return Ok(None) }; + out.extend_from_slice(&decoded); + ended = clean.contains(&b'='); + clean.clear(); + } + } + } + let Ok(text) = std::str::from_utf8(&clean) else { return Ok(None) }; + let Some(decoded) = decode(text) else { return Ok(None) }; + out.extend_from_slice(&decoded); + Ok(Some(out)) +} + /// The bytes `s` encodes; `None` when it is not valid padded base64. Padding is allowed only at /// the end. The unused bits before the padding are not checked, so a non-canonical group decodes /// like its canonical form (`Zh==` gives the bytes of `Zg==`, and `Zm9=` those of `Zm8=`), as @@ -99,6 +162,8 @@ mod tests { assert_eq!(encode(dec.as_bytes()), enc); assert_eq!(decode(enc).as_deref(), Some(dec.as_bytes()), "{enc}"); assert_eq!(decoded_len(enc), dec.len() as u64, "{enc}"); + assert_eq!(decode_with_check(enc, || Ok::<_, ()>(())).unwrap(), decode(enc)); + assert_eq!(decoded_len_with_check(enc, || Ok::<_, ()>(())).unwrap(), decoded_len(enc)); } assert_eq!(decode("Zm9v\nYmFy").as_deref(), Some(&b"foobar"[..])); } @@ -125,6 +190,45 @@ mod tests { fn misplaced_padding_and_foreign_characters_are_refused() { for s in ["====", "A===", "AA=A", "=AAA", "QQ==QUJD", "QUI=QUJD", "QUJ", "QU*D", "QUJDé"] { assert_eq!(decode(s), None, "{s}"); + assert_eq!(decode_with_check(s, || Ok::<_, ()>(())).unwrap(), None, "{s}"); } } + + #[test] + fn checked_blocks_preserve_padding_and_whitespace() { + for pad in ["QUJD", "Zh==", "Zm9=", "A===", "QUJ"] { + let data = format!("{}\n{pad}\t", "QUJD".repeat(CHECK_CHUNK / 4 - 1)); + for data in [data.clone(), format!("{data}QUJD")] { + assert_eq!(decode_with_check(&data, || Ok::<_, ()>(())).unwrap(), decode(&data)); + assert_eq!(decoded_len_with_check(&data, || Ok::<_, ()>(())).unwrap(), decoded_len(&data)); + } + } + } + + #[test] + fn decoding_can_stop_after_its_first_block() { + let data = "QUJD".repeat(CHECK_CHUNK); + let mut polls = 0; + let mut check = || { + polls += 1; + if polls == 2 { Err("stopped") } else { Ok(()) } + }; + assert_eq!(decode_chunks(&data, 0, &mut check), Err("stopped")); + assert_eq!(polls, 2); + } + + #[test] + fn checked_image_reads_keep_bounds_and_blob_ranges() { + let mut image = crate::Image::inline("application/pdf", b"abcdef"); + for max in [0, 5, 6, u64::MAX] { + assert_eq!(image.bytes_at_most_with_check(b"", max, || Ok::<_, ()>(())).unwrap(), image.bytes_at_most(b"", max)); + } + image.blob = Some([2, 3]); + for tail in [&b"123456"[..], b"12"] { + for max in [0, 2, 3, u64::MAX] { + assert_eq!(image.bytes_at_most_with_check(tail, max, || Ok::<_, ()>(())).unwrap(), image.bytes_at_most(tail, max)); + } + } + assert_eq!(image.bytes_at_most_with_check(b"123456", 3, || Err(17)), Err(17)); + } } diff --git a/crates/vectorcraft-format/src/lib.rs b/crates/vectorcraft-format/src/lib.rs index 8b4892f2..5a3eb74a 100644 --- a/crates/vectorcraft-format/src/lib.rs +++ b/crates/vectorcraft-format/src/lib.rs @@ -406,6 +406,18 @@ impl Image { self.len() == 0 } + /// [`Image::len`] with fallible checkpoints while scanning inline data. The checkpoint's + /// error is returned unchanged. + pub fn len_with_check(&self, mut check: impl FnMut() -> Result<(), E>) -> Result { + match self.blob { + Some([_, len]) => { + check()?; + Ok(len) + } + None => base64::decoded_len_with_check(&self.data, check), + } + } + /// The entry's bytes: `data` decoded, or the range `blob` names in `tail` (the data after the /// JSON, from [`split_tail`]). `None` when the entry states more than [`MAX_ENTRY`] bytes, /// when `data` is not valid base64, or when `tail` ends before the range does. Each call @@ -426,6 +438,26 @@ impl Image { None => base64::decode(&self.data), } } + + /// [`Image::bytes_at_most`] with fallible checkpoints during size checks, base64 decoding + /// and copying. `Ok(None)` has the same meaning as [`Image::bytes_at_most`]. + pub fn bytes_at_most_with_check(&self, tail: &[u8], max: u64, mut check: impl FnMut() -> Result<(), E>) -> Result>, E> { + if self.len_with_check(&mut check)? > max { + return Ok(None); + } + match self.blob { + Some(at) => { + let Some(bytes) = entry(tail, at) else { return Ok(None) }; + let mut out = Vec::with_capacity(bytes.len()); + for chunk in bytes.chunks(base64::CHECK_CHUNK) { + check()?; + out.extend_from_slice(chunk); + } + Ok(Some(out)) + } + None => base64::decode_with_check(&self.data, check), + } + } } /// The bytes `[offset, length]` names in `tail`; `None` when `tail` ends before them.