From 378c8c7ba844cf75f561c50ac6ea756f33628a3f Mon Sep 17 00:00:00 2001 From: AlPaka1234 <356241429+AlPaka1234@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:24:13 +0100 Subject: [PATCH] PDF export: bound CmykScan work on self-referencing Form graphs (#2743) --- crates/pdf/src/default_cmyk.rs | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/pdf/src/default_cmyk.rs b/crates/pdf/src/default_cmyk.rs index 17c64ca7d..64e2ebbb1 100644 --- a/crates/pdf/src/default_cmyk.rs +++ b/crates/pdf/src/default_cmyk.rs @@ -25,6 +25,9 @@ const MAX_DEPTH: usize = 32; /// Maximum object-number probes beyond trailer `/Size` before treating the xref as damaged. const MAX_ID_PROBES: usize = 65_536; +/// Maximum `scan_stream` invocations across a document before aborting `CmykScan`. +const MAX_SCAN_STREAMS: usize = 65_536; + fn damaged() -> PdfError { PdfError::Write("the exported PDF could not be updated with a DefaultCMYK profile cleanly".into()) } @@ -37,6 +40,10 @@ fn depth_limit() -> PdfError { PdfError::Write("the exported PDF exceeds the DefaultCMYK nesting depth limit".into()) } +fn scan_limit() -> PdfError { + PdfError::Write("the exported PDF exceeds the DefaultCMYK stream scan budget".into()) +} + /// Ensures every page and nested resource scope in `bytes` carries `/DefaultCMYK` mapped to an /// `/ICCBased` 4-channel ICC profile stream, unless that scope already defines `/DefaultCMYK`. /// @@ -428,6 +435,7 @@ struct CmykScan<'a> { active_fonts: HashSet, completed_false_streams: HashSet>, completed_false_fonts: HashSet>, + scan_calls: usize, } impl<'a> CmykScan<'a> { @@ -574,6 +582,10 @@ impl<'a> CmykScan<'a> { } fn scan_stream(&mut self, stream: &Stream<'a>, res: Resources<'a>, depth: usize, hit_cycle: &mut bool, check_group: bool) -> Result { + if self.scan_calls >= MAX_SCAN_STREAMS { + return Err(scan_limit()); + } + self.scan_calls += 1; let id = stream.obj_id(); if self.active_streams.contains(&id) { *hit_cycle = true; @@ -980,4 +992,38 @@ mod tests { "Page 1 negative cache for resource-less Form must not hide Page 2 DeviceCMYK resolution" ); } + + #[test] + fn branching_self_referencing_form_chain_hits_scan_budget() { + let make_chain = |levels: usize| { + let xobj_entries: String = (0..levels).map(|i| format!("/F{i} {} 0 R", i + 5)).collect(); + let mut objects = vec![ + b"<>".to_vec(), + b"<>".to_vec(), + format!("<>>>>>").into_bytes(), + b"<>\nstream\n/F0 Do\nendstream".to_vec(), + ]; + for i in 0..levels { + // Omit `/Resources` on each Form so it inherits the page scope rather than holding a + // self-reference inside its own indirect object context. + let ops = if i + 1 < levels { + let next = i + 1; + format!("/F{i} Do /F{next} Do /F{next} Do") + } else { + format!("/F{i} Do") + }; + objects.push(format!("<>\nstream\n{ops}\nendstream", ops.len()).into_bytes()); + } + build_pdf(&objects, "") + }; + + let small = make_chain(4); + assert!(!uses_device_cmyk(&small).unwrap()); + + let deep_branching = make_chain(20); + let err = uses_device_cmyk(&deep_branching).unwrap_err(); + assert_eq!(err.to_string(), scan_limit().to_string()); + let attach_err = attach(deep_branching, None, crate::Standard::None, &mut Vec::new()).unwrap_err(); + assert_eq!(attach_err.to_string(), scan_limit().to_string()); + } }