diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 4957f86861..5368401c33 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -87,6 +87,19 @@ fn is_delim(b: u8) -> bool { /// Longest unreadable number token retried from its next byte. Real numbers are far shorter. const MAX_RETRIED_TOKEN: usize = 64; +/// Bytes that failed operands (an unterminated array or dictionary, say) and failed inline-image +/// parameters may read, per byte of the stream and at least, before the parser stops retrying +/// them from their next byte. A failed operand can read to the end of the stream, and retrying it +/// from each byte reads that rest again every time: `[[[[…` would take time quadratic in its +/// length. Past the allowance, a failed operand is skipped whole, as far as it read. +const RESCAN_PER_BYTE: usize = 8; +const MIN_RESCAN: usize = 1 << 20; + +/// Operands kept for the next operator. No operator takes more than a few dozen; the oldest of a +/// longer run are dropped (and counted in [`Parsed::skipped`]), so a stream of operands with no +/// operator does not hold all of them at once. +const MAX_OPERANDS: usize = 1024; + /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { parse_pieces(data, &[]) @@ -117,6 +130,9 @@ pub struct Ops<'a> { ends: &'a [usize], boundary: usize, comment_edges: Vec<(bool, bool)>, + /// What failed operands and inline-image parameters have read so far, against + /// [`RESCAN_PER_BYTE`]. + rescanned: usize, } impl<'a> Ops<'a> { @@ -131,6 +147,7 @@ impl<'a> Ops<'a> { ends, boundary: 0, comment_edges: vec![(false, false); ends.len()], + rescanned: 0, } } @@ -150,9 +167,15 @@ impl Iterator for Ops<'_> { type Item = Op; fn next(&mut self) -> Option { - let Ops { data, lx, operands, start, skipped, ambiguous_images, ends, boundary, comment_edges: edges } = self; + let Ops { data, lx, operands, start, skipped, ambiguous_images, ends, boundary, comment_edges: edges, rescanned } = self; let (data, ends): (&[u8], &[usize]) = (data, ends); + let rescan_limit = data.len().saturating_mul(RESCAN_PER_BYTE).max(MIN_RESCAN); loop { + if operands.len() >= 2 * MAX_OPERANDS { + let dropped = operands.len() - MAX_OPERANDS; + operands.drain(..dropped); + *skipped += dropped; + } let trivia = lx.pos; lx.skip_ws(); // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a @@ -183,10 +206,12 @@ impl Iterator for Ops<'_> { let read = lx.pos.saturating_sub(at); #[cfg(test)] tests::RESCANNED.with(|c| c.set(c.get() + read)); + *rescanned = rescanned.saturating_add(read); let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit(); - if number && read > MAX_RETRIED_TOKEN { + if read > 0 && ((number && read > MAX_RETRIED_TOKEN) || *rescanned > rescan_limit) { // A long unreadable number is skipped whole: retrying from each of its - // bytes would read the rest of it again every time. + // bytes would read the rest of it again every time. So is any failed + // operand once retries have read more than the stream allows. *skipped += read; } else { // Skip the offending byte and carry on. @@ -223,7 +248,10 @@ impl Iterator for Ops<'_> { operands.push(Object::Null); } b"BI" => { - let image = inline_image(data, at, lx.pos); + let image = inline_image(data, at, lx.pos, *rescanned > rescan_limit); + #[cfg(test)] + tests::RESCANNED.with(|c| c.set(c.get() + image.rescanned)); + *rescanned = rescanned.saturating_add(image.rescanned); *ambiguous_images += usize::from(image.ambiguous); let end = image.end; lx.pos = end; @@ -293,6 +321,8 @@ struct Inline { end: usize, /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. ambiguous: bool, + /// How far unreadable parameters were read: the parser may read that part again. + rescanned: usize, } /// Read the inline image whose `BI` keyword starts at `bi` (its parameters start at `pos`). @@ -303,11 +333,16 @@ struct Inline { /// the renderer's tokenizer (hayro-syntax) rather than worked out here by another rule. Only /// when that tokenizer reads no image does this parser fall back to its own rules, and the /// image is marked ambiguous. -fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { +/// +/// `exhausted`: failed reads have used up the parser's allowance ([`RESCAN_PER_BYTE`]). Then +/// unreadable parameters end the image where reading them failed, not at an earlier `EI` from +/// which the rest would be read again. +fn inline_image(data: &[u8], bi: usize, pos: usize, exhausted: bool) -> Inline { let mut lx = Lexer::new(data, pos); let mut dict = Dict::new(); let mut readable = true; let mut stop = pos; + let mut rescanned = 0; loop { lx.skip_ws(); if lx.eat_keyword(b"ID") { @@ -321,10 +356,15 @@ fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { let Some((k, v)) = entry else { // A stream that ends inside the parameters is cut short, not unreadable: keep what was read. if at >= data.len() { - return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false }; + return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false, rescanned: 0 }; + } + let read = lx.pos.saturating_sub(at); + if exhausted { + return Inline { image: None, end: lx.pos.max(at + 1).min(data.len()), ambiguous: true, rescanned: read }; } readable = false; stop = at; + rescanned = read; break; }; dict.set(k, v); @@ -339,11 +379,11 @@ fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { { samples = rest; } - return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false, rescanned }; } if !readable { // Without an `EI` after it, the image runs to the end of the stream. - return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true }; + return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true, rescanned }; } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); @@ -354,7 +394,7 @@ fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { Some((end_data, end)) => (data.get(begin..end_data).unwrap_or_default().to_vec(), end), None => (data.get(begin..).unwrap_or_default().to_vec(), data.len()), }; - Inline { image: Some((dict, image)), end, ambiguous: true } + Inline { image: Some((dict, image)), end, ambiguous: true, rescanned } } /// Where the renderer's tokenizer ends the inline image whose `BI` starts at `bi`: the start and diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 9ac9297cd2..48f6cdb76f 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -566,3 +566,46 @@ fn invert_accepts_small_scale_and_rejects_singular() { // A subnormal scale passes the relative check but its inverse overflows (CodeRabbit on #5703). assert!(Matrix([1e-310, 0., 0., 1., 0., 0.]).invert().is_none()); } + +/// Failed operands that read to the end of the stream (an unterminated array, hex string or +/// dictionary, or unreadable inline-image parameters) are not retried from every byte without +/// end: the bytes they read stay proportional to the stream, through both [`parse`] and [`ops`]. +#[test] +fn failed_operands_read_a_bounded_multiple_of_the_stream() { + let n = 1 << 18; + for (junk, len) in [("[", 1), ("<", 1), ("<< /a ", 6), ("BI /K ( EI ", 11)] { + let mut src = junk.repeat(n / len).into_bytes(); + src.extend_from_slice(b"\n1 0 0 1 0 0 cm"); + let limit = src.len() * (RESCAN_PER_BYTE + 2) + MIN_RESCAN + src.len(); + RESCANNED.with(|c| c.set(0)); + let p = parse(&src); + let rescanned = RESCANNED.with(|c| c.get()); + assert!(rescanned <= limit, "{junk:?}: {rescanned} bytes read for a {}-byte stream", src.len()); + assert!(p.skipped > 0, "{junk:?}"); + RESCANNED.with(|c| c.set(0)); + let streamed = ops(&src).count(); + let rescanned = RESCANNED.with(|c| c.get()); + assert!(rescanned <= limit, "{junk:?}: {rescanned} bytes read streaming a {}-byte stream", src.len()); + assert_eq!(streamed, p.ops.len(), "{junk:?}"); + } + // A short stray opener still recovers byte by byte, keeping what follows it. + let p = parse(b"[ 1 0 0 1 0 0 cm"); + assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + let p = parse(b"<< /a 1 0 0 1 0 0 cm"); + assert_eq!(p.ops.last().map(|o| o.op.clone()), Some(b"cm".to_vec())); +} + +/// Operands without an operator are not all held: only the last [`MAX_OPERANDS`] or so are kept +/// for the operator that may follow, and the rest count as skipped. +#[test] +fn operands_without_an_operator_are_bounded() { + let n = 100_000; + let mut src = "1 ".repeat(n).into_bytes(); + src.extend_from_slice(b"0 0 1 0 0 cm"); + let p = parse(&src); + let cm = p.ops.last().expect("the cm operator"); + assert!(cm.operands.len() <= 2 * MAX_OPERANDS, "{} operands kept", cm.operands.len()); + assert_eq!(cm.nums::<6>(), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + assert_eq!(p.skipped + cm.operands.len(), n + 5); + assert_eq!(cm.span.start, 0); +}