fix(content): bound parser recovery work and held operands

A failed operand that reads to the end of the stream (an unterminated
array, hex string or dictionary) was retried from its next byte, so
'[[[[...' read the rest of the stream once per byte: 40 KB of '[' took
24 s in a release build. Unreadable inline-image parameters did the same
from each BI. Failed reads now share an allowance of 8 bytes per stream
byte (at least 1 MiB); past it a failed operand is skipped whole and an
unreadable inline image ends where its parameters stopped reading.

Operands with no operator were all held until one arrived; only the last
1024 or so are now kept, the rest counted as skipped.
This commit is contained in:
Dylan M. Taylor
2026-11-02 21:14:48 -05:00
parent 3936f93a14
commit fb4159739c
2 changed files with 92 additions and 9 deletions
+49 -9
View File
@@ -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<Op> {
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
+43
View File
@@ -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);
}