mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 10:46:28 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user