From 1ec46732b86c338300c158ac4331f902776184a7 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:17:25 +0430 Subject: [PATCH 1/3] PDF import: the pre-open stream scan doesn't let a "stream" inside a literal string hide the real stream after it Review of #3919: the scan raised its walk-back floor at every `>>stream`, so one inside a literal string moved the floor past the `<<` of the cross-reference stream around it, and that stream was never looked at. The backward walk also counted a `>>` inside a string, and missed a comment between `>>` and `stream`. The scan now reads the file forward once, as the reader does: literal strings (nested, escaped), hex strings and comments are skipped, `<<` and `>>` pair on a stack, and a dictionary followed by `stream` is a stream's, whose data is skipped. Because the reader can still start at a `<<` inside skipped text (a cross-reference offset or its repair scan), each such `<<` is read as a dictionary of its own, up to 64 KiB, within a budget of four times the file plus 1 MiB; a crafted file that spends it is refused. The scan stays linear. --- crates/pdf/src/streams.rs | 340 +++++++++++++++++++++++++++++++------- docs/gaps.md | 1 + 2 files changed, 285 insertions(+), 56 deletions(-) diff --git a/crates/pdf/src/streams.rs b/crates/pdf/src/streams.rs index 25f0c1973..4b2086de9 100644 --- a/crates/pdf/src/streams.rs +++ b/crates/pdf/src/streams.rs @@ -269,67 +269,250 @@ fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool { /// Their decode parameters must be in their dictionary, as the reader can't be asked for others /// yet. Encrypted object streams are looked at once the file is open (their data is ciphered /// here). +/// +/// The file is read once from the start, as the reader reads PDF: literal strings (nested +/// parentheses, `\` escaping), hex strings and comments are skipped, `<<` and `>>` pair up, and a +/// dictionary that `stream` follows (white space and comments between) is a stream's, whose data +/// is skipped. So a `stream` or `>>` in a string or a comment is no keyword (#3919 review). The +/// reader may still start at a `<<` inside what this reading skipped (a cross-reference offset or +/// its repair scan can point there), so each such `<<` is read as a dictionary of its own, within +/// a budget for the whole file: a file that spends it is refused. Linear in the file (#3586). pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { - let find = |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at); - // A walk back through a dictionary stops at `dicts_from`, the end of the last keyword walked - // back from, so the scan reads each byte a bounded number of times (#3586): before, every - // keyword walked back up to 64 KiB through all the dictionaries before it. - let (mut from, mut dicts_from) = (0, 0); - // The first `endstream` at or after the last place it was looked for (`None`: there is none). - let mut endstream: Option> = None; - while let Some(at) = find(from, b"stream") { - from = at + 6; - if at >= 3 && pdf.get(at - 3..at) == Some(b"end") { - continue; - } - // The dictionary before it: back from its `>>` to the `<<` that opens it. - let Some(close) = pdf.get(..at).and_then(|head| head.iter().rposition(|c| !c.is_ascii_whitespace())).map(|i| i + 1) else { continue }; - if close < 2 || pdf.get(close - 2..close) != Some(b">>") { - continue; - } - let floor = close.saturating_sub(1 << 16).max(dicts_from); - dicts_from = from; - let (mut depth, mut open, mut i) = (0usize, None, close); - while i >= floor + 2 { - match pdf.get(i - 2..i) { - Some(b">>") => (depth, i) = (depth + 1, i - 2), - Some(b"<<") => { - depth -= 1; - i -= 2; - if depth == 0 { - open = Some(i); - break; - } + let mut scan = FileScan { pdf, budget: pdf.len().saturating_mul(4).saturating_add(1 << 20), endstream: None }; + scan.read().is_err() +} + +/// A stream the reader would fail on, or a file that spent the scan's budget. +struct Unsafe; + +/// The most bytes a dictionary read from inside a string, comment or stream data may span, as far +/// as the scan looked back from a `stream` keyword before. +const MAX_SKIPPED_DICT: usize = 1 << 16; + +/// The most `<<` the scan keeps open: deeper than the reader nests dictionaries. +const MAX_OPEN: usize = 1024; + +/// PDF white space (ISO 32000-1, 7.2.2). +fn white(b: u8) -> bool { + matches!(b, 0 | 9 | 10 | 12 | 13 | 32) +} + +/// Where `what` is first found in `pdf` at or after `from`. +fn find(pdf: &[u8], from: usize, what: &[u8]) -> Option { + pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at) +} + +/// Past the literal string opening at `at` (a `(`), as the reader reads it: parentheses nest and +/// `\` escapes the byte after it. `Err` with how far it got when it doesn't close before `limit`. +fn string_end(pdf: &[u8], at: usize, limit: usize) -> Result { + let (mut depth, mut i) = (0usize, at); + while let Some(&b) = pdf.get(i).filter(|_| i < limit) { + match b { + b'\\' => i += 1, + b'(' => depth += 1, + b')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Ok(i + 1); } - _ => i -= 1, } + _ => {} } - let Some(dict) = open.and_then(|o| pdf.get(o..close)).and_then(Dict::from_bytes) else { continue }; - if !dict.get::>(b"Type").is_some_and(|t| matches!(t.as_ref(), b"ObjStm" | b"XRef")) { - continue; - } - let indirect = [&b"DecodeParms"[..], b"DP"].iter().any(|k| matches!(dict.get_raw::>(k), Some(MaybeRef::Ref(_)))); - if indirect || params(&dict).iter().any(bad_predictor) { - return true; - } - let start = from - + usize::from(pdf.get(from) == Some(&b'\r')) - + usize::from(pdf.get(from + usize::from(pdf.get(from) == Some(&b'\r'))) == Some(&b'\n')); - let length = dict.get::(b"Length").and_then(|n| start.checked_add(n)).filter(|e| *e <= pdf.len()); - let end = match length { - Some(end) => end, - None => { - if endstream.is_none_or(|e| e.is_some_and(|e| e < start)) { - endstream = Some(find(start, b"endstream")); - } - endstream.flatten().unwrap_or(pdf.len()) - } - }; - if matches!(decode(pdf.get(start..end).unwrap_or_default(), &dict, false), Err(TooBig)) { - return true; + i += 1; + } + Err(i.min(limit)) +} + +/// Past the hex string opening at `at` (a `<`): hex digits and white space, then `>`. `Err` with +/// how far it got for one the reader fails on. +fn hex_end(pdf: &[u8], at: usize, limit: usize) -> Result { + let mut i = at + 1; + while pdf.get(i).is_some_and(|b| i < limit && (b.is_ascii_hexdigit() || white(*b))) { + i += 1; + } + if i < limit && pdf.get(i) == Some(&b'>') { Ok(i + 1) } else { Err(i.min(limit)) } +} + +/// The end of the comment starting at `at` (a `%`): its end of line, not included. +fn comment_end(pdf: &[u8], at: usize) -> usize { + pdf.get(at..).and_then(|rest| rest.iter().position(|b| matches!(b, b'\n' | b'\r'))).map_or(pdf.len(), |n| at + n) +} + +/// Past the white space and comments from `at`. +fn skip_space(pdf: &[u8], mut at: usize) -> usize { + while let Some(&b) = pdf.get(at) { + match b { + b'%' => at = comment_end(pdf, at), + b if white(b) => at += 1, + _ => break, } } - false + at +} + +/// Past the dictionary opening at `at` (a `<<`), read to the `>>` that closes it before `limit`. +/// `Err` with how far it got for one that doesn't close there. +fn dict_end(pdf: &[u8], at: usize, limit: usize) -> Result { + let (mut depth, mut i) = (0usize, at); + while let Some(&b) = pdf.get(i).filter(|_| i < limit) { + match pdf.get(i..i + 2) { + Some(b"<<") => (depth, i) = (depth + 1, i + 2), + Some(b">>") => { + (depth, i) = (depth.saturating_sub(1), i + 2); + if depth == 0 { + return Ok(i); + } + } + _ => match b { + b'(' => i = string_end(pdf, i, limit)?, + b'<' => i = hex_end(pdf, i, limit)?, + b'%' => i = comment_end(pdf, i), + _ => i += 1, + }, + } + } + Err(i.min(limit)) +} + +/// The pre-open scan of a file's streams ([`unsafe_file_streams`]). +struct FileScan<'a> { + pdf: &'a [u8], + /// The bytes left to read again: dictionaries inside skipped text, `endstream` searches and + /// the data decoded. Every byte of a well-formed file is read about once. + budget: usize, + /// Where an `endstream` was last looked for, and the first one found there (`None`: none). + endstream: Option<(usize, Option)>, +} + +impl FileScan<'_> { + fn charge(&mut self, bytes: usize) -> Result<(), Unsafe> { + self.budget = self.budget.checked_sub(bytes).ok_or(Unsafe)?; + Ok(()) + } + + /// The file from the start, as the reader reads it. + fn read(&mut self) -> Result<(), Unsafe> { + let pdf = self.pdf; + let mut opens = std::collections::VecDeque::new(); + // The dictionary just read, while only white space and comments follow it. + let mut closed: Option<(usize, usize)> = None; + let mut i = 0; + while let Some(&b) = pdf.get(i) { + if white(b) { + i += 1; + continue; + } + if b == b'%' { + let end = comment_end(pdf, i); + self.skipped(i, end)?; + i = end; + continue; + } + let last = closed.take(); + match pdf.get(i..i + 2) { + Some(b"<<") => { + if opens.len() == MAX_OPEN { + opens.pop_front(); + } + opens.push_back(i); + // In `<<<` the reader may start a dictionary at the second `<` too. + if pdf.get(i + 2) == Some(&b'<') { + self.dict(i + 1)?; + } + i += 2; + } + Some(b">>") => { + closed = opens.pop_back().map(|open| (open, i + 2)); + i += 2; + } + _ if b == b'(' => { + let end = string_end(pdf, i, pdf.len()).unwrap_or(pdf.len()); + self.skipped(i, end)?; + i = end; + } + _ if b == b'<' => i = hex_end(pdf, i, pdf.len()).unwrap_or(i + 1), + _ => match last { + Some((open, close)) if pdf.get(i..).is_some_and(|rest| rest.starts_with(b"stream")) => { + let end = self.stream(open, close, i + 6)?; + self.skipped(i + 6, end)?; + i = end.max(i + 6); + } + _ => i += 1, + }, + } + } + Ok(()) + } + + /// Each `<<` in `from..to`, text the reading skipped, read as a dictionary of its own. + fn skipped(&mut self, from: usize, to: usize) -> Result<(), Unsafe> { + let text = self.pdf.get(..to).unwrap_or_default(); + let mut i = from; + while let Some(at) = find(text, i, b"<<") { + self.dict(at)?; + i = at + 1; + } + Ok(()) + } + + /// The dictionary opening at `at`, and the stream it begins if `stream` follows it. + fn dict(&mut self, at: usize) -> Result<(), Unsafe> { + let pdf = self.pdf; + let limit = at.saturating_add(MAX_SKIPPED_DICT).min(pdf.len()); + let end = dict_end(pdf, at, limit); + let (Ok(reached) | Err(reached)) = end; + self.charge(reached.saturating_sub(at))?; + let Ok(close) = end else { return Ok(()) }; + let keyword = skip_space(pdf, close); + self.charge(keyword - close)?; + if pdf.get(keyword..).is_some_and(|rest| rest.starts_with(b"stream")) { + self.stream(at, close, keyword + 6)?; + } + Ok(()) + } + + /// The stream whose dictionary is `open..close` and whose `stream` keyword ends at `from`: + /// `Err` when the reader would fail on it, else where its data ends. + fn stream(&mut self, open: usize, close: usize, from: usize) -> Result { + let pdf = self.pdf; + let cr = usize::from(pdf.get(from) == Some(&b'\r')); + let start = from + cr + usize::from(pdf.get(from + cr) == Some(&b'\n')); + let dict = pdf.get(open..close).and_then(Dict::from_bytes); + let length = dict.as_ref().and_then(|d| d.get::(b"Length")).and_then(|n| start.checked_add(n)).filter(|e| *e <= pdf.len()); + let end = match length { + Some(end) => end, + None => self.endstream(start)?, + }; + let Some(dict) = dict.filter(|d| d.get::>(b"Type").is_some_and(|t| matches!(t.as_ref(), b"ObjStm" | b"XRef"))) else { + return Ok(end); + }; + let indirect = [&b"DecodeParms"[..], b"DP"].iter().any(|k| matches!(dict.get_raw::>(k), Some(MaybeRef::Ref(_)))); + if indirect || params(&dict).iter().any(bad_predictor) { + return Err(Unsafe); + } + if !filters(&dict).is_empty() { + self.charge(end.saturating_sub(start))?; + } + if matches!(decode(pdf.get(start..end).unwrap_or_default(), &dict, false), Err(TooBig)) { + return Err(Unsafe); + } + Ok(end) + } + + /// Where the first `endstream` at or after `start` is, or the end of the file. + fn endstream(&mut self, start: usize) -> Result { + let len = self.pdf.len(); + if let Some((from, found)) = self.endstream + && from <= start + && found.is_none_or(|f| f >= start) + { + return Ok(found.unwrap_or(len)); + } + let found = find(self.pdf, start, b"endstream"); + self.charge(found.unwrap_or(len).saturating_sub(start))?; + self.endstream = Some((start, found)); + Ok(found.unwrap_or(len)) + } } #[cfg(test)] @@ -474,7 +657,7 @@ mod tests { ] { assert!(!unsafe_file_streams(&fine)); } - // A bad stream after others is still found: a walk back stops at the previous keyword, not before its own `<<`. + // A bad stream after others is still found. let fine = stream("/Type /XRef /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 5 >>", b"x"); let bad = stream("/Type /XRef /Filter /FlateDecode /DecodeParms 9 0 R", b"x"); assert!(unsafe_file_streams(&[fine.as_slice(), b"<< >>stream x endstream", &bad].concat())); @@ -492,4 +675,49 @@ mod tests { assert!(took < std::time::Duration::from_secs(5), "{} took {took:?}", String::from_utf8_lossy(unit)); } } + + #[test] + fn a_stream_keyword_in_a_string_or_comment_hides_no_stream_after_it() { + // #3919 review: a `>>stream` in a literal string (one with a `<<` before it, nested + // parentheses, an escaped one) or in a comment, a `>>` in a string and a comment between + // `>>` and `stream` each left the cross-reference stream they were in or before unread. + let cases = [ + &b"<< /Type /XRef /Note (<<) /Pad (>>stream) /DecodeParms 9 0 R >> stream\nx\nendstream"[..], + b"<< /Type /XRef /Pad (a (b) \\) >>stream) /DecodeParms 9 0 R >> stream\nx\nendstream", + b"<< /Type /XRef /Pad (>>) /DecodeParms 9 0 R >> stream\nx\nendstream", + b"<< /Type /XRef % >>stream\n/DecodeParms 9 0 R >> stream\nx\nendstream", + b"<< /Type /XRef /DecodeParms 9 0 R >> % a comment\nstream\nx\nendstream", + ]; + let unread: Vec<_> = cases.iter().filter(|bad| !unsafe_file_streams(bad)).map(|bad| String::from_utf8_lossy(bad)).collect(); + assert!(unread.is_empty(), "{unread:#?}"); + } + + #[test] + fn a_stream_in_text_the_scan_skips_is_still_read() { + // The scan skips a stream's data, a string and a comment; the reader may still start at + // a dictionary in them (a cross-reference offset can point anywhere), so they're read too. + let bad = b"<< /Type /XRef /DecodeParms 9 0 R >> stream\nx\nendstream"; + for wrap in [ + [&b"1 0 obj << /Length 99 >> stream\n"[..], bad, b"\nendstream endobj"], + [b"(", bad, b")"], + [b"% ", bad, b"\n"], + [b"<< /Note (", bad, b") >>"], + ] { + let pdf = wrap.concat(); + assert!(unsafe_file_streams(&pdf), "{}", String::from_utf8_lossy(&pdf)); + } + } + + #[test] + fn strings_and_comments_dense_with_dictionaries_are_scanned_in_bounded_time() { + // Each `<<` in a string or comment is read as a dictionary of its own (the reader may + // start at one), within a budget for the whole file. + for unit in [&b"<<("[..], b"(<<", b"%<<\n", b"<>stream) >>stream\n"] { + let pdf = unit.repeat((1 << 20) / unit.len()); + let t = std::time::Instant::now(); + unsafe_file_streams(&pdf); + let took = t.elapsed(); + assert!(took < std::time::Duration::from_secs(5), "{} took {took:?}", String::from_utf8_lossy(unit)); + } + } } diff --git a/docs/gaps.md b/docs/gaps.md index 7a3020ed3..3bb786ce3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -613,6 +613,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv |---|---|---| | 2026-10-24 | minor | G14 shrinks: pen pressure and the pen's eraser end on macOS, read from AppKit's tablet events (#852; not yet tried on a Mac); estimate 8–16 h → 4–10 h. | | 2026-10-24 | minor | G11: Shape Builder pieces: the planar map contracts the stubs the sweep left between two cuts of a line's passes a rounding error apart (bounded by each cluster's extent), so dense hatches at exact binary steps no longer add a speck per crossing; estimate unchanged, #937 open. | +| 2026-10-24 | minor | G10: the PDF pre-open stream scan reads the file as the reader does (literal and hex strings, comments, stream data skipped), so a `stream` or `>>` in a string or comment, or a comment before `stream`, no longer hides a cross-reference or object stream from it (#3919 review); a `<<` in skipped text is read as a dictionary too, within a budget a crafted file that spends is refused on. No entry closed, estimates unchanged. | | 2026-10-24 | minor | G4: images in OpenType SVG glyph documents draw, export and outline with the glyph; colour fonts are done but for CPAL palette variables; estimate unchanged. | | 2026-10-24 | minor | G4: bitmap glyphs (emoji) go with type that effects reshape, into Expand Appearance, envelope Expand and EMF/WMF export, as images that hold their own pixels until a document takes them in; estimate unchanged. | | 2026-10-24 | minor | G10: Fix #3586: the PDF pre-open scan reads a file dense with `stream` keywords in linear time; each keyword walked back up to 64 KiB through every dictionary before it, so 1 MiB of `<> >>stream` took minutes. No entry closed, estimates unchanged. | From 2d5024bf82c7dec0017d8e443fc3e6d578745cb2 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:23:58 +0430 Subject: [PATCH 2/3] PDF import: a run of "<" in skipped text no longer spends the pre-open scan's budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #3924: every `<<` in a string, comment or stream's data was read as a dictionary of its own, and in a run of `<` (an uncompressed image's flat area of grey 60) every position is one, each read to past the run, so about N²/2 bytes for N `<` and a 1 MiB run refused the file. The reader starts a dictionary there only where its repair scan tries one (after white space or a dictionary's `>>`) or after an object's `N G obj`: so only those `<<` are read, in a run of `<` only the first. A reading goes on as far as 64 KiB past the last `<<` opened on the way, looks at each nested dictionary's `>>` for a stream and marks each `<<`, so it is not read again; a comment's end of line is kept, as many readings cross the same comment; the `<<<` second-`<` reading in the main pass goes (the reader never starts there). The dictionary text a stream check parses is charged to the budget. --- crates/pdf/src/streams.rs | 229 +++++++++++++++++++++++++++----------- docs/gaps.md | 2 +- 2 files changed, 164 insertions(+), 67 deletions(-) diff --git a/crates/pdf/src/streams.rs b/crates/pdf/src/streams.rs index 4b2086de9..c19b99a74 100644 --- a/crates/pdf/src/streams.rs +++ b/crates/pdf/src/streams.rs @@ -275,18 +275,19 @@ fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool { /// dictionary that `stream` follows (white space and comments between) is a stream's, whose data /// is skipped. So a `stream` or `>>` in a string or a comment is no keyword (#3919 review). The /// reader may still start at a `<<` inside what this reading skipped (a cross-reference offset or -/// its repair scan can point there), so each such `<<` is read as a dictionary of its own, within -/// a budget for the whole file: a file that spends it is refused. Linear in the file (#3586). +/// its repair scan can point there), so each such `<<` it could start at is read as a dictionary +/// of its own, within a budget for the whole file: a file that spends it is refused. Linear in +/// the file (#3586), a run of `<` included (#3924 review). pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { - let mut scan = FileScan { pdf, budget: pdf.len().saturating_mul(4).saturating_add(1 << 20), endstream: None }; + let mut scan = FileScan { pdf, budget: pdf.len().saturating_mul(4).saturating_add(1 << 20), endstream: None, line: None, read_from: Vec::new() }; scan.read().is_err() } /// A stream the reader would fail on, or a file that spent the scan's budget. struct Unsafe; -/// The most bytes a dictionary read from inside a string, comment or stream data may span, as far -/// as the scan looked back from a `stream` keyword before. +/// The most bytes a dictionary read from inside a string, comment or stream data may span past +/// the last `<<` in it, as far as the scan looked back from a `stream` keyword before. const MAX_SKIPPED_DICT: usize = 1 << 16; /// The most `<<` the scan keeps open: deeper than the reader nests dictionaries. @@ -338,40 +339,13 @@ fn comment_end(pdf: &[u8], at: usize) -> usize { pdf.get(at..).and_then(|rest| rest.iter().position(|b| matches!(b, b'\n' | b'\r'))).map_or(pdf.len(), |n| at + n) } -/// Past the white space and comments from `at`. -fn skip_space(pdf: &[u8], mut at: usize) -> usize { - while let Some(&b) = pdf.get(at) { - match b { - b'%' => at = comment_end(pdf, at), - b if white(b) => at += 1, - _ => break, - } - } - at -} - -/// Past the dictionary opening at `at` (a `<<`), read to the `>>` that closes it before `limit`. -/// `Err` with how far it got for one that doesn't close there. -fn dict_end(pdf: &[u8], at: usize, limit: usize) -> Result { - let (mut depth, mut i) = (0usize, at); - while let Some(&b) = pdf.get(i).filter(|_| i < limit) { - match pdf.get(i..i + 2) { - Some(b"<<") => (depth, i) = (depth + 1, i + 2), - Some(b">>") => { - (depth, i) = (depth.saturating_sub(1), i + 2); - if depth == 0 { - return Ok(i); - } - } - _ => match b { - b'(' => i = string_end(pdf, i, limit)?, - b'<' => i = hex_end(pdf, i, limit)?, - b'%' => i = comment_end(pdf, i), - _ => i += 1, - }, - } - } - Err(i.min(limit)) +/// Can the reader start a dictionary at the `<<` at `at`? Its repair scan tries one at the start +/// of the file, after white space and after a dictionary it read (`>>`); an object that a +/// cross-reference offset points at begins `N G obj`, then white space or comments. A `<<` after +/// anything else is read only as part of what is before it: in a run of `<`, only the first. +fn startable(pdf: &[u8], at: usize) -> bool { + let before = pdf.get(..at).unwrap_or_default(); + before.last().is_none_or(|b| white(*b)) || before.ends_with(b">>") || before.ends_with(b"obj") } /// The pre-open scan of a file's streams ([`unsafe_file_streams`]). @@ -382,6 +356,11 @@ struct FileScan<'a> { budget: usize, /// Where an `endstream` was last looked for, and the first one found there (`None`: none). endstream: Option<(usize, Option)>, + /// Where a comment's end of line was last looked for, and where it is. + line: Option<(usize, usize)>, + /// The `<<` a dictionary in skipped text was read from, a bit a byte of the file (empty + /// until one is). + read_from: Vec, } impl FileScan<'_> { @@ -415,10 +394,6 @@ impl FileScan<'_> { opens.pop_front(); } opens.push_back(i); - // In `<<<` the reader may start a dictionary at the second `<` too. - if pdf.get(i + 2) == Some(&b'<') { - self.dict(i + 1)?; - } i += 2; } Some(b">>") => { @@ -444,39 +419,123 @@ impl FileScan<'_> { Ok(()) } - /// Each `<<` in `from..to`, text the reading skipped, read as a dictionary of its own. + /// Each `<<` in `from..to`, text the reading skipped, that the reader could start at, read as + /// a dictionary of its own. fn skipped(&mut self, from: usize, to: usize) -> Result<(), Unsafe> { let text = self.pdf.get(..to).unwrap_or_default(); let mut i = from; while let Some(at) = find(text, i, b"<<") { - self.dict(at)?; + if startable(self.pdf, at) { + self.dicts(at)?; + } i = at + 1; } Ok(()) } - /// The dictionary opening at `at`, and the stream it begins if `stream` follows it. - fn dict(&mut self, at: usize) -> Result<(), Unsafe> { + /// The dictionary at `at` in skipped text, read to the `>>` that closes it, as far as + /// [`MAX_SKIPPED_DICT`] past the last `<<` opened on the way; and each dictionary it holds. + /// Each `>>` closing one is looked at for a stream after it, and each `<<` opening one is + /// marked: a reading from there reads the same text as far, so a later call for it returns + /// at once and text dense with `<<` is read about once. + fn dicts(&mut self, at: usize) -> Result<(), Unsafe> { + if self.was_read(at) { + return Ok(()); + } let pdf = self.pdf; - let limit = at.saturating_add(MAX_SKIPPED_DICT).min(pdf.len()); - let end = dict_end(pdf, at, limit); - let (Ok(reached) | Err(reached)) = end; - self.charge(reached.saturating_sub(at))?; - let Ok(close) = end else { return Ok(()) }; - let keyword = skip_space(pdf, close); + let mut opens = std::collections::VecDeque::new(); + let (mut depth, mut limit, mut i, mut work) = (0usize, at.saturating_add(MAX_SKIPPED_DICT), at, 0usize); + while let Some(&b) = pdf.get(i).filter(|_| i < limit) { + let next = match pdf.get(i..i + 2) { + Some(b"<<") => { + self.mark(i); + limit = limit.max(i.saturating_add(MAX_SKIPPED_DICT)); + if opens.len() == MAX_OPEN { + opens.pop_front(); + } + opens.push_back(i); + depth += 1; + Ok(i + 2) + } + Some(b">>") => { + depth = depth.saturating_sub(1); + if let Some(open) = opens.pop_back() { + self.closed(open, i + 2)?; + } + if depth == 0 { Err(i + 2) } else { Ok(i + 2) } + } + _ => match b { + b'(' => string_end(pdf, i, limit), + b'<' => hex_end(pdf, i, limit), + b'%' => { + i = self.line_end(i)?; + continue; + } + _ => Ok(i + 1), + }, + }; + let (Ok(n) | Err(n)) = next; + work += n.saturating_sub(i); + if next.is_err() { + break; + } + i = n; + } + self.charge(work) + } + + /// The dictionary `open..close` in skipped text: the stream it begins if `stream` follows it. + fn closed(&mut self, open: usize, close: usize) -> Result<(), Unsafe> { + let mut keyword = close; + while let Some(&b) = self.pdf.get(keyword) { + match b { + b'%' => keyword = self.line_end(keyword)?, + b if white(b) => keyword += 1, + _ => break, + } + } self.charge(keyword - close)?; - if pdf.get(keyword..).is_some_and(|rest| rest.starts_with(b"stream")) { - self.stream(at, close, keyword + 6)?; + if self.pdf.get(keyword..).is_some_and(|rest| rest.starts_with(b"stream")) { + self.stream(open, close, keyword + 6)?; } Ok(()) } + /// The end of the comment at `at` ([`comment_end`]), the last one found kept: comments in + /// skipped text are read again from many places. + fn line_end(&mut self, at: usize) -> Result { + if let Some((from, end)) = self.line + && from <= at + && at <= end + { + return Ok(end); + } + let end = comment_end(self.pdf, at); + self.charge(end - at)?; + self.line = Some((at, end)); + Ok(end) + } + + fn mark(&mut self, at: usize) { + if self.read_from.is_empty() { + self.read_from = vec![0; self.pdf.len() / 64 + 1]; + } + if let Some(word) = self.read_from.get_mut(at / 64) { + *word |= 1 << (at % 64); + } + } + + fn was_read(&self, at: usize) -> bool { + self.read_from.get(at / 64).is_some_and(|word| word >> (at % 64) & 1 == 1) + } + /// The stream whose dictionary is `open..close` and whose `stream` keyword ends at `from`: /// `Err` when the reader would fail on it, else where its data ends. fn stream(&mut self, open: usize, close: usize, from: usize) -> Result { let pdf = self.pdf; let cr = usize::from(pdf.get(from) == Some(&b'\r')); let start = from + cr + usize::from(pdf.get(from + cr) == Some(&b'\n')); + self.charge(close.saturating_sub(open))?; let dict = pdf.get(open..close).and_then(Dict::from_bytes); let length = dict.as_ref().and_then(|d| d.get::(b"Length")).and_then(|n| start.checked_add(n)).filter(|e| *e <= pdf.len()); let end = match length { @@ -695,29 +754,67 @@ mod tests { #[test] fn a_stream_in_text_the_scan_skips_is_still_read() { // The scan skips a stream's data, a string and a comment; the reader may still start at - // a dictionary in them (a cross-reference offset can point anywhere), so they're read too. + // a dictionary in them (a cross-reference offset can point at `1 0 obj` anywhere, its + // repair scan tries a `<<` after white space), so they're read too. let bad = b"<< /Type /XRef /DecodeParms 9 0 R >> stream\nx\nendstream"; - for wrap in [ - [&b"1 0 obj << /Length 99 >> stream\n"[..], bad, b"\nendstream endobj"], - [b"(", bad, b")"], - [b"% ", bad, b"\n"], - [b"<< /Note (", bad, b") >>"], + // `data` as the data of a stream whose length holds all of it. + let image = |data: &[u8]| [format!("1 0 obj << /Length {} >> stream\n", data.len()).as_bytes(), data, b"\nendstream endobj"].concat(); + let run = vec![b'<'; 1 << 16]; + let cases = [ + image(bad), + [&b"(1 0 obj "[..], bad, b")"].concat(), + [&b"(1 0 obj"[..], bad, b")"].concat(), + [&b"% "[..], bad, b"\n"].concat(), + [&b"<< /Note (1 0 obj "[..], bad, b") >>"].concat(), + // #3924 review: right after a run of `<` in an image, and as the end of one (a + // reader starting at the run's first `<<` reads it inside the others). + image(&[&run[..], b"\n", bad].concat()), + image(&[&run[..], b" /Type /XRef /DecodeParms 9 0 R >> stream\nx\nendstream"].concat()), + ]; + let unread: Vec<_> = + cases.iter().filter(|bad| !unsafe_file_streams(bad)).map(|bad| String::from_utf8_lossy(&bad[..bad.len().min(80)])).collect(); + assert!(unread.is_empty(), "{unread:#?}"); + } + + #[test] + fn a_run_of_lt_in_an_image_a_string_or_a_comment_opens() { + // #3924 review: every `<<` in a run of `<` (an image's flat area of grey 60) was read as a + // dictionary to past the run, so 1 MiB of it spent the budget and the file was refused. + // The reader starts a dictionary only at the run's first `<<`. + let run = vec![b'<'; 1 << 20]; + let image = b"1 0 obj << /Type /XObject /Subtype /Image /Width 1024 /Height 1024 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1048576 >> stream\n"; + for (what, pdf) in [ + ("image", [&b"%PDF-1.7\n"[..], image, &run[..], b"\nendstream endobj\n"].concat()), + ("string", [&b"%PDF-1.7\n2 0 obj ("[..], &run[..], b") endobj\n"].concat()), + ("comment", [&b"%PDF-1.7\n% "[..], &run[..], b"\n"].concat()), ] { - let pdf = wrap.concat(); - assert!(unsafe_file_streams(&pdf), "{}", String::from_utf8_lossy(&pdf)); + let t = std::time::Instant::now(); + assert!(!unsafe_file_streams(&pdf), "{what}"); + let took = t.elapsed(); + assert!(took < std::time::Duration::from_secs(5), "{what} took {took:?}"); } } #[test] fn strings_and_comments_dense_with_dictionaries_are_scanned_in_bounded_time() { - // Each `<<` in a string or comment is read as a dictionary of its own (the reader may - // start at one), within a budget for the whole file. - for unit in [&b"<<("[..], b"(<<", b"%<<\n", b"<>stream) >>stream\n"] { + // Each `<<` in a string or comment the reader could start at is read as a dictionary of + // its own, within a budget for the whole file. + for unit in [&b"<<("[..], b"(<<", b"%<<\n", b"<>stream) >>stream\n", b"<"] { let pdf = unit.repeat((1 << 20) / unit.len()); let t = std::time::Instant::now(); unsafe_file_streams(&pdf); let took = t.elapsed(); assert!(took < std::time::Duration::from_secs(5), "{} took {took:?}", String::from_utf8_lossy(unit)); } + // An image's flat area of a colour whose bytes are `<<` and white space, `(` or `%` (a + // reader may start at each `<<` after white space): read about once, and it opens. + for pixel in [&b" <<"[..], b"<<(", b"<<%", b" <<%", b"\0<<\0"] { + let data = pixel.repeat((1 << 20) / pixel.len()); + let pdf = [format!("1 0 obj << /Length {} >> stream\n", data.len()).as_bytes(), &data[..], b"\nendstream endobj\n"].concat(); + let t = std::time::Instant::now(); + assert!(!unsafe_file_streams(&pdf), "{}", String::from_utf8_lossy(pixel)); + let took = t.elapsed(); + assert!(took < std::time::Duration::from_secs(5), "{} took {took:?}", String::from_utf8_lossy(pixel)); + } } } diff --git a/docs/gaps.md b/docs/gaps.md index 3bb786ce3..e2e64bad0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -613,7 +613,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv |---|---|---| | 2026-10-24 | minor | G14 shrinks: pen pressure and the pen's eraser end on macOS, read from AppKit's tablet events (#852; not yet tried on a Mac); estimate 8–16 h → 4–10 h. | | 2026-10-24 | minor | G11: Shape Builder pieces: the planar map contracts the stubs the sweep left between two cuts of a line's passes a rounding error apart (bounded by each cluster's extent), so dense hatches at exact binary steps no longer add a speck per crossing; estimate unchanged, #937 open. | -| 2026-10-24 | minor | G10: the PDF pre-open stream scan reads the file as the reader does (literal and hex strings, comments, stream data skipped), so a `stream` or `>>` in a string or comment, or a comment before `stream`, no longer hides a cross-reference or object stream from it (#3919 review); a `<<` in skipped text is read as a dictionary too, within a budget a crafted file that spends is refused on. No entry closed, estimates unchanged. | +| 2026-10-24 | minor | G10: the PDF pre-open stream scan reads the file as the reader does (literal and hex strings, comments, stream data skipped), so a `stream` or `>>` in a string or comment, or a comment before `stream`, no longer hides a cross-reference or object stream from it (#3919 review); a `<<` in skipped text that the reader could start a dictionary at (after white space, `>>` or `obj`) is read as one too, each about once, so a run of `<` in an uncompressed image (grey 60) no longer spends the budget and refuses the file (#3924 review). No entry closed, estimates unchanged. | | 2026-10-24 | minor | G4: images in OpenType SVG glyph documents draw, export and outline with the glyph; colour fonts are done but for CPAL palette variables; estimate unchanged. | | 2026-10-24 | minor | G4: bitmap glyphs (emoji) go with type that effects reshape, into Expand Appearance, envelope Expand and EMF/WMF export, as images that hold their own pixels until a document takes them in; estimate unchanged. | | 2026-10-24 | minor | G10: Fix #3586: the PDF pre-open scan reads a file dense with `stream` keywords in linear time; each keyword walked back up to 64 KiB through every dictionary before it, so 1 MiB of `<> >>stream` took minutes. No entry closed, estimates unchanged. | From d3a01307ea4a694307247dd6f364f93d11711da9 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:35:14 +0430 Subject: [PATCH 3/3] PDF import: a string entered from many dictionaries in skipped text is read once Review of #3924: in a flat area of ` <<(` (CMYK or RGBA 32, 60, 60, 40) in an uncompressed image, each `<<` after white space was read as a dictionary, and each read its string, which never ends, to 64 KiB past it: about 16 K bytes a pixel, so a few hundred bytes of it spent the budget and the file was refused. Whether a byte in a literal string is escaped doesn't depend on where reading began (the run of `\` before it lies after any `(` before it), so the string from any `(` ends after the first `)` that takes the file-wide count of `(` less `)` below the count at that `(`. Those ends are found for the whole file in three linear passes the first time a string read in skipped text runs past 64 bytes: forward for the final count, back for the `(` whose string never ends, forward pairing the rest. Ends further than 64 bytes are kept (at most one for every 16 bytes of the file; a file built to nest past that is refused); nearer ones are read again. So entering a string costs at most 64 bytes, charged as one. A comment's end of line is kept for every comment read, not only the last. --- crates/pdf/src/streams.rs | 189 +++++++++++++++++++++++++++++++++----- docs/gaps.md | 2 +- 2 files changed, 166 insertions(+), 25 deletions(-) diff --git a/crates/pdf/src/streams.rs b/crates/pdf/src/streams.rs index c19b99a74..7da9ac94c 100644 --- a/crates/pdf/src/streams.rs +++ b/crates/pdf/src/streams.rs @@ -4,6 +4,7 @@ //! that large whatever the stream holds (#1701). A file with such a stream is refused instead. use std::borrow::Cow; +use std::collections::BTreeMap; use std::io::Read; use craft_pdf::syntax::content::TypedIter; @@ -279,7 +280,14 @@ fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool { /// of its own, within a budget for the whole file: a file that spends it is refused. Linear in /// the file (#3586), a run of `<` included (#3924 review). pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { - let mut scan = FileScan { pdf, budget: pdf.len().saturating_mul(4).saturating_add(1 << 20), endstream: None, line: None, read_from: Vec::new() }; + let mut scan = FileScan { + pdf, + budget: pdf.len().saturating_mul(4).saturating_add(1 << 20), + endstream: None, + lines: BTreeMap::new(), + strings: None, + read_from: Vec::new(), + }; scan.read().is_err() } @@ -324,6 +332,85 @@ fn string_end(pdf: &[u8], at: usize, limit: usize) -> Result { Err(i.min(limit)) } +/// How far past its `(` a string read in skipped text may end to be read again each time it is +/// entered (bytes); one ending further is looked up in [`StringEnds`]. +const NEAR_STRING: usize = 64; + +/// Is the byte at `at` escaped, a `\` before it that isn't escaped itself? Reading a string from +/// any `(` before it sees the same, as the run of `\` before it lies after that `(`. +fn escaped(pdf: &[u8], at: usize) -> bool { + pdf.get(..at).unwrap_or_default().iter().rev().take_while(|b| **b == b'\\').count() % 2 == 1 +} + +/// Where the literal strings read from the file's `(` end, for those ending more than +/// [`NEAR_STRING`] bytes on (#3924 review). As whether a byte is escaped doesn't depend on where +/// reading started ([`escaped`]), the string from any `(` ends after the first `)` that takes the +/// count of unescaped `(` less unescaped `)` from the file's start below the count with that `(` +/// (counted even when escaped). So all are found in three passes over the file: forward for the +/// count at its end, back for the `(` whose string never ends (the count never falls below theirs +/// again), and forward pairing the others with a stack. +struct StringEnds { + /// Each `(` whose string ends more than [`NEAR_STRING`] bytes on, and where, in file order. + far: Vec<(usize, usize)>, +} + +impl StringEnds { + /// The ends in `pdf`; `None` when more than one in 16 bytes of it are kept or pending (only a + /// file built for it nests strings so deep). + fn of(pdf: &[u8]) -> Option { + let most = pdf.len() / 16 + 1024; + // What the byte at `i` adds to the count: an escaped `(` adds nothing but is one too. + let step = |i: usize| match pdf.get(i) { + Some(b'(') => Some(i64::from(!escaped(pdf, i))), + Some(b')') => (!escaped(pdf, i)).then_some(-1), + _ => None, + }; + let total: i64 = (0..pdf.len()).filter_map(step).sum(); + // The `(` whose string never ends: none after it takes the count below its own. + let mut endless = vec![0u64; pdf.len() / 64 + 1]; + let (mut count, mut lowest) = (total, i64::MAX); + for i in (0..pdf.len()).rev() { + let Some(d) = step(i) else { continue }; + if pdf.get(i) == Some(&b'(') + && count <= lowest + && let Some(word) = endless.get_mut(i / 64) + { + *word |= 1 << (i % 64); + } + lowest = lowest.min(count); + count -= d; + } + let (mut far, mut open, mut count) = (vec![], Vec::<(usize, i64)>::new(), 0); + for i in 0..pdf.len() { + let Some(d) = step(i) else { continue }; + count += d; + if pdf.get(i) == Some(&b'(') { + if endless.get(i / 64).is_some_and(|word| word >> (i % 64) & 1 == 0) { + open.push((i, count)); + } + } else { + while let Some(&(at, _)) = open.last().filter(|(_, level)| *level > count) { + open.pop(); + if i + 1 - at > NEAR_STRING { + far.push((at, i + 1)); + } + } + } + if far.len() + open.len() > most { + return None; + } + } + far.sort_unstable(); + Some(StringEnds { far }) + } + + /// Where the string from the `(` at `at` ends, if it ends more than [`NEAR_STRING`] bytes on; + /// `None` for one that doesn't end. + fn far(&self, at: usize) -> Option { + self.far.binary_search_by_key(&at, |(open, _)| *open).ok().and_then(|n| self.far.get(n)).map(|(_, end)| *end) + } +} + /// Past the hex string opening at `at` (a `<`): hex digits and white space, then `>`. `Err` with /// how far it got for one the reader fails on. fn hex_end(pdf: &[u8], at: usize, limit: usize) -> Result { @@ -356,8 +443,11 @@ struct FileScan<'a> { budget: usize, /// Where an `endstream` was last looked for, and the first one found there (`None`: none). endstream: Option<(usize, Option)>, - /// Where a comment's end of line was last looked for, and where it is. - line: Option<(usize, usize)>, + /// The comments read in skipped text: each one's end of line, and where reading it began. + lines: BTreeMap, + /// The ends of strings read in skipped text far from their `(` ([`StringEnds`]), found once + /// the first such string is read. + strings: Option, /// The `<<` a dictionary in skipped text was read from, a bit a byte of the file (empty /// until one is). read_from: Vec, @@ -446,7 +536,9 @@ impl FileScan<'_> { let mut opens = std::collections::VecDeque::new(); let (mut depth, mut limit, mut i, mut work) = (0usize, at.saturating_add(MAX_SKIPPED_DICT), at, 0usize); while let Some(&b) = pdf.get(i).filter(|_| i < limit) { - let next = match pdf.get(i..i + 2) { + // Where to read on (`Err`: the dictionary ends there, read or unreadable), and the + // bytes read to get there. + let (next, read) = match pdf.get(i..i + 2) { Some(b"<<") => { self.mark(i); limit = limit.max(i.saturating_add(MAX_SKIPPED_DICT)); @@ -455,30 +547,29 @@ impl FileScan<'_> { } opens.push_back(i); depth += 1; - Ok(i + 2) + (Ok(i + 2), 2) } Some(b">>") => { depth = depth.saturating_sub(1); if let Some(open) = opens.pop_back() { self.closed(open, i + 2)?; } - if depth == 0 { Err(i + 2) } else { Ok(i + 2) } + (if depth == 0 { Err(i + 2) } else { Ok(i + 2) }, 2) } _ => match b { - b'(' => string_end(pdf, i, limit), - b'<' => hex_end(pdf, i, limit), - b'%' => { - i = self.line_end(i)?; - continue; + // Strings and comments are charged as they are first read. + b'(' => (self.string_end(i)?.ok_or(pdf.len()), 0), + b'%' => (Ok(self.line_end(i)?), 0), + b'<' => { + let end = hex_end(pdf, i, limit); + let (Ok(n) | Err(n)) = end; + (end, n.saturating_sub(i)) } - _ => Ok(i + 1), + _ => (Ok(i + 1), 1), }, }; - let (Ok(n) | Err(n)) = next; - work += n.saturating_sub(i); - if next.is_err() { - break; - } + work += read; + let Ok(n) = next else { break }; i = n; } self.charge(work) @@ -501,21 +592,44 @@ impl FileScan<'_> { Ok(()) } - /// The end of the comment at `at` ([`comment_end`]), the last one found kept: comments in - /// skipped text are read again from many places. + /// The end of the comment at `at` ([`comment_end`]). Comments in skipped text are read from + /// many places, so each one's end is kept: a byte is read as comment once. fn line_end(&mut self, at: usize) -> Result { - if let Some((from, end)) = self.line + // The first comment kept that ends at or after `at`. + let kept = self.lines.range(at..).next().map(|(end, from)| (*end, *from)); + if let Some((end, from)) = kept && from <= at - && at <= end { return Ok(end); } - let end = comment_end(self.pdf, at); - self.charge(end - at)?; - self.line = Some((at, end)); + // Read up to the next end of line, or to where that comment was read from. + let upto = kept.map_or(self.pdf.len(), |(_, from)| from); + let rest = self.pdf.get(at..upto).unwrap_or_default(); + let end = match rest.iter().position(|b| matches!(b, b'\n' | b'\r')) { + Some(n) => at + n, + None => kept.map_or(self.pdf.len(), |(end, _)| end), + }; + self.charge(rest.len().min(end - at))?; + self.lines.insert(end, at); Ok(end) } + /// Past the literal string at `at` (a `(`), as the reader reads it; `None` for one that + /// doesn't end. A string ending within [`NEAR_STRING`] bytes of its `(` is read again each + /// time a reading enters it; one ending further is looked up in [`StringEnds`], found for the + /// whole file in three passes (linear, so not charged) the first time one is needed. So each + /// time a string is entered costs at most [`NEAR_STRING`] bytes, charged as one. + fn string_end(&mut self, at: usize) -> Result, Unsafe> { + self.charge(1)?; + if let Ok(end) = string_end(self.pdf, at, at.saturating_add(NEAR_STRING)) { + return Ok(Some(end)); + } + if self.strings.is_none() { + self.strings = Some(StringEnds::of(self.pdf).ok_or(Unsafe)?); + } + Ok(self.strings.as_ref().and_then(|s| s.far(at))) + } + fn mark(&mut self, at: usize) { if self.read_from.is_empty() { self.read_from = vec![0; self.pdf.len() / 64 + 1]; @@ -770,6 +884,13 @@ mod tests { // reader starting at the run's first `<<` reads it inside the others). image(&[&run[..], b"\n", bad].concat()), image(&[&run[..], b" /Type /XRef /DecodeParms 9 0 R >> stream\nx\nendstream"].concat()), + // Strings in it that end far from their `(` (looked up, not read again): one, one + // holding others, one from an escaped `(`, and the same after a flat area of + // ` <<(` whose strings never end. + image(&[&b"<< /Type /XRef /Pad ("[..], &[b'x'; 200], b") /DecodeParms 9 0 R >> stream\nx\nendstream"].concat()), + image(&[&b"<< /Type /XRef /Pad (a (b) "[..], &[b'x'; 200], b" (c (d)) \\) ) /DecodeParms 9 0 R >> stream\nx\nendstream"].concat()), + image(&[&b"<< /Type /XRef /Pad \\("[..], &[b'x'; 200], b") /DecodeParms 9 0 R >> stream\nx\nendstream"].concat()), + image(&[&b" <<(".repeat(1 << 14)[..], b"\n", bad].concat()), ]; let unread: Vec<_> = cases.iter().filter(|bad| !unsafe_file_streams(bad)).map(|bad| String::from_utf8_lossy(&bad[..bad.len().min(80)])).collect(); @@ -795,6 +916,26 @@ mod tests { } } + #[test] + fn a_flat_area_that_opens_a_string_after_each_dictionary_opens() { + // #3924 review: in a flat area of ` <<(` (CMYK or RGBA 32, 60, 60, 40) each `<<` was read + // as a dictionary, and each read its never-ending string to 64 KiB past it: the budget + // was spent and the file refused. A string ending far from its `(` is now read once. + let noise: Vec = (0u32..1 << 18).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect(); + for (what, data) in [ + (" <<(", b" <<(".repeat(1 << 18)), + ("\\0<<(", b"\0<<(".repeat(1 << 18)), + ("( <<", b"( <<".repeat(1 << 18)), + (" <<( then noise", [&b" <<(".repeat(1 << 16)[..], &noise].concat()), + ] { + let pdf = [format!("1 0 obj << /Length {} >> stream\n", data.len()).as_bytes(), &data[..], b"\nendstream endobj\n"].concat(); + let t = std::time::Instant::now(); + assert!(!unsafe_file_streams(&pdf), "{what}"); + let took = t.elapsed(); + assert!(took < std::time::Duration::from_secs(5), "{what} took {took:?}"); + } + } + #[test] fn strings_and_comments_dense_with_dictionaries_are_scanned_in_bounded_time() { // Each `<<` in a string or comment the reader could start at is read as a dictionary of diff --git a/docs/gaps.md b/docs/gaps.md index e2e64bad0..eec7dc4f6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -613,7 +613,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv |---|---|---| | 2026-10-24 | minor | G14 shrinks: pen pressure and the pen's eraser end on macOS, read from AppKit's tablet events (#852; not yet tried on a Mac); estimate 8–16 h → 4–10 h. | | 2026-10-24 | minor | G11: Shape Builder pieces: the planar map contracts the stubs the sweep left between two cuts of a line's passes a rounding error apart (bounded by each cluster's extent), so dense hatches at exact binary steps no longer add a speck per crossing; estimate unchanged, #937 open. | -| 2026-10-24 | minor | G10: the PDF pre-open stream scan reads the file as the reader does (literal and hex strings, comments, stream data skipped), so a `stream` or `>>` in a string or comment, or a comment before `stream`, no longer hides a cross-reference or object stream from it (#3919 review); a `<<` in skipped text that the reader could start a dictionary at (after white space, `>>` or `obj`) is read as one too, each about once, so a run of `<` in an uncompressed image (grey 60) no longer spends the budget and refuses the file (#3924 review). No entry closed, estimates unchanged. | +| 2026-10-24 | minor | G10: the PDF pre-open stream scan reads the file as the reader does (literal and hex strings, comments, stream data skipped), so a `stream` or `>>` in a string or comment, or a comment before `stream`, no longer hides a cross-reference or object stream from it (#3919 review); a `<<` in skipped text that the reader could start a dictionary at (after white space, `>>` or `obj`) is read as one too, each about once, and a string or comment it enters is read once however many such readings enter it, so a run of `<` (grey 60) or a flat area of ` <<(` (CMYK 32, 60, 60, 40) in an uncompressed image no longer spends the budget and refuses the file (#3924 review). No entry closed, estimates unchanged. | | 2026-10-24 | minor | G4: images in OpenType SVG glyph documents draw, export and outline with the glyph; colour fonts are done but for CPAL palette variables; estimate unchanged. | | 2026-10-24 | minor | G4: bitmap glyphs (emoji) go with type that effects reshape, into Expand Appearance, envelope Expand and EMF/WMF export, as images that hold their own pixels until a document takes them in; estimate unchanged. | | 2026-10-24 | minor | G10: Fix #3586: the PDF pre-open scan reads a file dense with `stream` keywords in linear time; each keyword walked back up to 64 KiB through every dictionary before it, so 1 MiB of `<> >>stream` took minutes. No entry closed, estimates unchanged. |