Fix #3586: the PDF pre-open scan reads a file dense with stream keywords in linear time

Each `stream` keyword walked back up to 64 KiB through every dictionary before it, so 1 MiB of `<</A <</B 1>> >>stream` took minutes. A walk back now stops where the previous keyword's walk began, and a stream without a Length shares the search for the next `endstream`.
This commit is contained in:
Taha-Mahmoodi
2026-10-24 07:35:07 +07:00
committed by bflatastic
parent cdd3b949ba
commit ea55f89c24
2 changed files with 37 additions and 8 deletions
+36 -8
View File
@@ -271,7 +271,12 @@ fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool {
/// here).
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);
let mut from = 0;
// 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<Option<usize>> = None;
while let Some(at) = find(from, b"stream") {
from = at + 6;
if at >= 3 && pdf.get(at - 3..at) == Some(b"end") {
@@ -282,8 +287,10 @@ pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool {
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 >= 2 && close - i < 1 << 16 {
while i >= floor + 2 {
match pdf.get(i - 2..i) {
Some(b">>") => (depth, i) = (depth + 1, i - 2),
Some(b"<<") => {
@@ -308,12 +315,16 @@ pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool {
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 end = dict
.get::<usize>(b"Length")
.and_then(|n| start.checked_add(n))
.filter(|e| *e <= pdf.len())
.or_else(|| find(start, b"endstream"))
.unwrap_or(pdf.len());
let length = dict.get::<usize>(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;
}
@@ -463,5 +474,22 @@ 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 `<<`.
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()));
}
#[test]
fn a_file_dense_with_stream_keywords_is_scanned_in_linear_time() {
// #3586: each `stream` keyword walked back up to 64 KiB, through every dictionary before
// it, so 1 MiB of keywords took minutes. Now the whole scan takes milliseconds.
for unit in [&b"<</A <</B 1>> >>stream"[..], b">>stream", b"<</Type/XRef>>stream"] {
let pdf = unit.repeat((1 << 20) / unit.len());
let t = std::time::Instant::now();
assert!(!unsafe_file_streams(&pdf));
let took = t.elapsed();
assert!(took < std::time::Duration::from_secs(5), "{} took {took:?}", String::from_utf8_lossy(unit));
}
}
}
+1
View File
@@ -605,6 +605,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
|---|---|---|
| 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 `<</A <</B 1>> >>stream` took minutes. No entry closed, estimates unchanged. |
| 2026-10-24 | minor | Ranked table: one row each for G2 and G13 (doc merges had left G2 twice and G13 three times, at three hour estimates); G13 keeps the newest, 13–23 h; scores and hours unchanged |
| 2026-10-23 | minor | G5: Select › Start Global Edit on paths, compound paths and symbol instances (shape match, Appearance and Size, artboard range and canvas, proportional edits as one undo step; #3521); Global Edit of similar groups and Shift-click exclusion remain. Estimate unchanged. |
| 2026-10-23 | minor | G5: symbol libraries (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu, Other Library…, Save Symbols, User Defined, adding and placing library symbols as one undo step; `crates/engine/src/tests_symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`); brush libraries remain. Estimate unchanged. |