From 986a64bc77a2c4725b72e06d057475833ab4737b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:05:32 +0000 Subject: [PATCH 01/25] fix(cos): cap the objects one parsed object may build The parser pushed every array element and dictionary value with only a nesting limit, so a small compressed object stream decoding to a long run of '0 ' became tens of millions of Object values. Each top-level parse now counts the objects it builds and fails past 4M, including inside tolerated junk. --- crates/cos/src/parser.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 045805596..c8b90106c 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -10,6 +10,9 @@ use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; use crate::{Bytes, CosError}; const MAX_DEPTH: usize = 128; +/// Objects one top-level [`Lexer::object`] call may build. Real arrays (page trees, parent +/// trees, widths) stay far below; a decompressed object stream of `0 0 0 ...` does not. +const MAX_NODES: usize = 1 << 22; /// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1). pub fn is_whitespace(b: u8) -> bool { @@ -28,11 +31,13 @@ fn is_regular(b: u8) -> bool { pub struct Lexer<'a> { pub data: &'a [u8], pub pos: usize, + /// Objects built by the current top-level [`Lexer::object`] call. + nodes: usize, } impl<'a> Lexer<'a> { pub fn new(data: &'a [u8], pos: usize) -> Self { - Self { data, pos: pos.min(data.len()) } + Self { data, pos: pos.min(data.len()), nodes: 0 } } fn peek(&self) -> Option { @@ -84,13 +89,22 @@ impl<'a> Lexer<'a> { /// Parse one direct object. References `N G R` are recognised. pub fn object(&mut self) -> Result { + self.nodes = 0; self.object_depth(0) } + fn over_budget(&self) -> bool { + self.nodes > MAX_NODES + } + fn object_depth(&mut self, depth: usize) -> Result { if depth > MAX_DEPTH { return Err(self.err("nesting too deep")); } + self.nodes = self.nodes.saturating_add(1); + if self.over_budget() { + return Err(self.err("object too large")); + } self.skip_ws(); let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) }; match b { @@ -125,6 +139,7 @@ impl<'a> Lexer<'a> { let before = self.pos; match self.object_depth(depth + 1) { Ok(o) => items.push(o), + Err(e) if self.over_budget() => return Err(e), // Tolerate junk inside arrays by skipping one byte. Err(_) if self.pos == before => self.pos += 1, Err(e) => return Err(e), @@ -283,6 +298,9 @@ impl<'a> Lexer<'a> { // Tolerate garbage between entries (seen in damaged files): skip a token. let before = self.pos; let _ = self.object_depth(depth + 1); + if self.over_budget() { + return Err(self.err("object too large")); + } if self.pos == before { self.pos += 1; } @@ -598,4 +616,20 @@ mod tests { let deep = "[".repeat(10_000); assert!(Lexer::new(deep.as_bytes(), 0).object().is_err()); } + + #[test] + fn one_object_has_a_node_budget() { + // A wide array (from a tiny compressed object stream) must not grow without bound. + let wide = |n: usize| format!("[{}]", "0 ".repeat(n)); + let at_cap = Lexer::new(wide(MAX_NODES - 1).as_bytes(), 0).object(); + assert!(matches!(at_cap, Ok(Object::Array(ref items)) if items.len() == MAX_NODES - 1)); + assert!(Lexer::new(wide(MAX_NODES).as_bytes(), 0).object().is_err()); + // The same inside a dictionary value and behind tolerated junk. + assert!(Lexer::new(format!("<< /A {} >>", wide(MAX_NODES)).as_bytes(), 0).object().is_err()); + assert!(Lexer::new(format!("[[{}] ]", "0 ".repeat(MAX_NODES)).as_bytes(), 0).object().is_err()); + // The budget is per top-level object: a lexer reused for many operands keeps working. + let two = wide(MAX_NODES - 1).repeat(2); + let mut lx = Lexer::new(two.as_bytes(), 0); + assert!(lx.object().is_ok() && lx.object().is_ok()); + } } From e07942c8294301cf0ee5ad911d7dc3016bd33c50 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:01 +0000 Subject: [PATCH 02/25] fix(annot): cap the bumps of a cloudy outline The cloud border clamped bumps per edge only, so a polygon with many long edges asked for 10,000 cubic segments on each one. The bump radius now grows so the whole outline draws at most 20,000 bumps plus one per edge. --- crates/annot/src/appearance.rs | 6 ++++++ crates/annot/src/tests.rs | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 4668881a6..59cacbbdd 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() } +/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. +pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum(); // Outward normal of an edge: to the right of travel for counter-clockwise outlines. let sign = if area >= 0.0 { 1.0 } else { -1.0 }; + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + // Each edge rounds to at least one bump, so the total stays within the budget plus one per edge. + let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)); let mut c = String::new(); for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() { let (dx, dy) = (b.0 - a.0, b.1 - a.1); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a65996681..201fe0e08 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1504,3 +1504,11 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } + +#[test] +fn cloud_outline_has_a_bump_budget() { + // 50 long edges would each ask for 10,000 bumps of radius 1. + let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect(); + let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count(); + assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps"); +} From 1695106f98e044cab8892c3b048ad6155f316027 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:01 +0000 Subject: [PATCH 03/25] fix(annot): list a repeated link once and cap its web-address text The link listing copied the action's URI into a new String for every /Annots entry, so one annotation referenced many times, or many small links sharing one long URI string, multiplied that string in memory. The listing skips annotation references it has already listed and keeps at most 16 MiB of web-address text; a link past that lists as an uneditable action, which leaves it unchanged in the file. --- crates/annot/src/links.rs | 29 ++++++++++++++++++++++++----- crates/annot/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 4d3eef632..b6a2c7575 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,11 +78,14 @@ pub struct LinkItem { pub style: LinkStyle, } +/// Web-address text one listing keeps; past it a link lists as [`LinkAction::Other`]. +pub(crate) const MAX_URI_BYTES: usize = 16 << 20; + fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } -fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { +fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction { let dest_page = |dest: &Object| -> Option { let a = doc.resolve(dest); let first = a.as_array()?.first()?.clone(); @@ -97,9 +100,17 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { } let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; match a.name(b"S") { - Some(b"URI") => LinkAction::Uri( - a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(), - ), + Some(b"URI") => { + let uri = a.get(b"URI").map(|u| doc.resolve(u)); + let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); + match uri_budget.checked_sub(bytes.len()) { + Some(left) => { + *uri_budget = left; + LinkAction::Uri(String::from_utf8_lossy(bytes).into_owned()) + } + None => LinkAction::Other("web address too long".into()), + } + } Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), None => LinkAction::Other("no action".into()), @@ -133,8 +144,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle { pub fn list(doc: &Document) -> Vec { let Ok(pages) = page_refs(doc) else { return Vec::new() }; let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut uri_budget = MAX_URI_BYTES; for (pi, p) in pages.iter().enumerate() { for (k, a) in annots(doc, *p).iter().enumerate() { + // One annotation object listed under several entries or pages is one link. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let Some(d) = doc.resolve(a).as_dict().cloned() else { continue }; if d.name(b"Subtype") != Some(b"Link") { continue; @@ -148,7 +167,7 @@ pub fn list(doc: &Document) -> Vec { index: k, obj: a.as_ref(), rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])], - action: action_of(doc, &d, &pages), + action: action_of(doc, &d, &pages, &mut uri_budget), style: style_of(doc, &d), }); } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 201fe0e08..c52732ea8 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1505,6 +1505,42 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } +fn doc_of(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") +} + +#[test] +fn link_listing_skips_repeated_refs_and_caps_web_address_text() { + // One link referenced 1000 times, and 300 small links sharing one 64 KiB address. + let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300)); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(), + "<< /S /URI /URI 6 0 R >>".into(), + format!("({})", "u".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1); + assert_eq!(all.len(), 301, "every distinct link is still listed"); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); +} + #[test] fn cloud_outline_has_a_bump_budget() { // 50 long edges would each ask for 10,000 bumps of radius 1. From 83d8eb085603a6511afca93b82ec1bb42a03eed9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:07:44 +0000 Subject: [PATCH 04/25] fix(create): decode an indexed lookup table within a small cap Image export decoded a stream lookup table under the general 1 GiB stream limit although a table holds at most 1024 bytes, and every image sharing the palette decoded it again. The table now decodes within 64 KiB and a larger one is refused for that image. --- crates/create/src/extract.rs | 7 ++++++- crates/create/src/tests.rs | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 768da4d1f..b1aa5e5b1 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -359,6 +359,9 @@ impl Space { } } +/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that. +const MAX_LOOKUP: usize = 64 << 10; + fn space(doc: &Document, cs: Option<&Object>) -> Result { let Some(cs) = cs else { return Err("the image has no colour space".into()) }; let cs = doc.resolve(cs); @@ -393,7 +396,9 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), - Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?, + Some(Object::Stream(t)) => { + t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())? + } _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 635610569..131cb10d2 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -832,6 +832,15 @@ fn indexed_hival_resolves_indirect_values_and_stream_palettes() { } } +#[test] +fn indexed_lookup_stream_has_a_size_cap() { + // A lookup table needs 256 × 4 bytes at most; a 1 MiB one is refused, not decoded whole. + let mut palette = vec![255, 0, 0, 0, 0, 255]; + palette.resize(1 << 20, 0); + let (doc, image) = indexed_export_fixture(Object::Int(1), palette, true, false, 8, vec![0, 1, 0, 1]); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("lookup table")); +} + #[test] fn indexed_hival_boundary_values_keep_valid_palette_entries() { let palette: Vec = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect(); From 05df6ee4b722943a7b28ccadcb9bc9950d82480d Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:10:15 +0000 Subject: [PATCH 05/25] fix(fonts): decode each candidate font program once, within its size Looking for an already embedded full program decoded every candidate up to 17 MiB, once per resource naming it, so a page whose PCFull resources all alias one compressed stream, or many marked streams, made one text edit inflate gigabytes. Candidates now decode within the program's own length, each once per embedding, and at most 16 in all; the resource check compares its CMap streams within their expected lengths too. --- crates/fonts/src/embedding.rs | 140 ++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 32 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index a5bb011e0..23f34dbb3 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -1,9 +1,10 @@ //! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are //! document inputs or the optional craft-fonts build input, never system-interface fallbacks. +use std::collections::HashMap; use std::fmt::Write as _; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use skrifa::{ FontRef, MetadataProvider, instance::{LocationRef, Size}, @@ -173,7 +174,8 @@ pub fn embed_mapping( return Err("the font family name is invalid".into()); } let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() }; - if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) { + let mut programs = ProgramMatch::new(bytes); + if let Some(resource) = same_resource(doc, fonts, &name, &mut programs, glyph_map, cmap.as_bytes()) { return Ok(resource); } let mut descriptor = Dict::new(); @@ -193,12 +195,7 @@ pub fn embed_mapping( ); // Reuse the program another resource of this page already has, else one registered on another // page; source subset programs stay untouched. - let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { - doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), - _ => None, - }) - }); + let existing = existing_program(doc, fonts, &mut programs); let program = existing.unwrap_or_else(|| { let mut d = Dict::new(); d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); @@ -246,12 +243,64 @@ pub fn embed_mapping( Ok(resource) } -/// The decoded data of the stream `object` refers to. -fn stream_data(doc: &Document, object: Option<&Object>) -> Option> { - match doc.resolve(object?).as_ref() { - Object::Stream(s) => s.decoded_within(17 << 20).ok(), - _ => None, +/// Whether the stream `object` refers to decodes to `expected`. +fn stream_is(doc: &Document, object: Option<&Object>, expected: &[u8]) -> bool { + object.is_some_and(|o| match doc.resolve(o).as_ref() { + Object::Stream(s) => s.decoded_within(expected.len()).is_ok_and(|d| d == expected), + _ => false, + }) +} + +/// Full programs one embedding decodes at most while looking for its own. A real match passes +/// the cheap checks and decodes once; many candidates that pass them come from a hostile file. +const MAX_PROGRAM_DECODES: usize = 16; + +/// Recognises the full program written for `bytes`: each candidate stream decodes at most once +/// and within `bytes.len()`, and at most [`MAX_PROGRAM_DECODES`] decode in all. +struct ProgramMatch<'a> { + bytes: &'a [u8], + known: HashMap, + decodes: usize, +} + +impl<'a> ProgramMatch<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, known: HashMap::new(), decodes: 0 } } + + /// Whether `r`, holding `object`, is the program. + fn is(&mut self, doc: &Document, r: ObjRef, object: &Object) -> bool { + if let Some(&known) = self.known.get(&r) { + return known; + } + let found = match object { + Object::Stream(s) + if s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(self.bytes.len()).ok() + && self.decodes < MAX_PROGRAM_DECODES => + { + self.decodes = self.decodes.saturating_add(1); + s.decoded_within(self.bytes.len()).is_ok_and(|data| data == self.bytes) + } + _ => false, + }; + self.known.insert(r, found); + found + } + + /// Whether the resource's program is this one. + fn written(&mut self, doc: &Document, w: &Written) -> bool { + w.program.as_ref().is_some_and(|r| self.is(doc, r, &*doc.get(r))) + } +} + +/// The program for `programs.bytes` that this page's `PCFull` resources, or else the first +/// objects of the document, already have. +fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) -> Option { + written(doc, fonts) + .find(|w| programs.written(doc, w)) + .map(|w| w.program) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &*o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. @@ -263,21 +312,6 @@ struct Written { program: Object, } -impl Written { - /// Whether its program is `bytes`. - fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) - } -} - -/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was -/// written with is compared before decoding. -fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) -} - /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -291,14 +325,14 @@ fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator Option { +fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut ProgramMatch, glyph_map: &[u8], unicode: &[u8]) -> Option { written(doc, fonts) .find(|w| { let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding")); w.top.name(b"BaseFont") == Some(name.as_bytes()) - && stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode) - && stream_data(doc, glyphs).is_some_and(|d| d == glyph_map) - && w.has_program(doc, bytes) + && stream_is(doc, w.top.get(b"ToUnicode"), unicode) + && stream_is(doc, glyphs, glyph_map) + && programs.written(doc, w) }) .map(|w| w.resource) } @@ -385,6 +419,48 @@ mod tests { assert_eq!(programs(&doc), 2); } + #[test] + fn the_program_search_decodes_each_candidate_once_and_few_in_all() { + let bytes = b"the program".as_slice(); + let decoy = |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"PCFullFont".to_vec(), Object::Bool(true)); + d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); + doc.add(Object::Stream(Stream::flate(d, &[0; 4096]))) + }; + let resource = |doc: &mut Document, program: ObjRef| { + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let mut top = Dict::new(); + top.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)])); + doc.add(Object::Dict(top)) + }; + // 200 resources sharing one decoy program decode it once. + let mut doc = Document::new_empty(); + let shared = decoy(&mut doc); + let mut fonts = Dict::new(); + for i in 0..200 { + let r = resource(&mut doc, shared); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, 1); + // 100 distinct decoys decode a bounded number of times. + let mut doc = Document::new_empty(); + let mut fonts = Dict::new(); + for i in 0..100 { + let program = decoy(&mut doc); + let r = resource(&mut doc, program); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, MAX_PROGRAM_DECODES); + } + #[test] fn the_full_program_is_written_compressed_and_another_page_reuses_it() { let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); From 8da1164f7d303487d3c90861bc2994e9ab89f5e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:10:15 +0000 Subject: [PATCH 06/25] fix(fonts): decode Encoding and ToUnicode CMaps within 16 MiB Both CMap streams decoded under the general 1 GiB stream limit, so a small Flate stream of whitespace inflated to a gigabyte that was then lexed on every font load. They now decode within 16 MiB, like the font programs beside them, and a larger CMap is not read. --- crates/fonts/src/pdf.rs | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939..fb524f65e 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -275,6 +275,9 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few /// thousand. const MAX_CMAP_RANGES: usize = 1 << 16; +/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger +/// one is not decoded at all. +const MAX_CMAP_BYTES: usize = 16 << 20; /// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range /// costs one record however many codes it covers; where two overlap, the one declared later wins. @@ -679,7 +682,7 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = s.decoded() + && let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -1220,7 +1223,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - if let Ok(data) = s.decoded() { + if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } @@ -1496,6 +1499,38 @@ mod tests { assert_eq!(m.encode("abc"), None); } + #[test] + fn cmap_streams_decode_within_a_size_cap() { + // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + for padded in [false, true] { + let mut doc = Document::new_empty(); + let mut stream = |cmap: &[u8]| { + let mut data = cmap.to_vec(); + if padded { + data.resize(MAX_CMAP_BYTES + 1, b' '); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) + }; + let (tu, enc) = (stream(to_unicode), stream(encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); + assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); + } + } + #[test] fn composite_fonts_use_to_unicode() { let mut doc = Document::new_empty(); From a16661ac102e921524459bc991abb4473115ab2f Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:11:27 +0000 Subject: [PATCH 07/25] fix(measure): list a repeated measurement once and cap what a listing keeps The listing read a measurement for every /Annots entry, so one annotation referenced many times, or many small annotations sharing one indirect 2048-point vertex array, multiplied its points and text in memory. The listing skips annotation references it has already read and stops, marked truncated, once the kept points and text pass 32 MiB. --- crates/measure/src/lib.rs | 29 +++++++++++++++++++++++++++-- crates/measure/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 72b6c4b07..4843d0fb6 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -448,10 +448,20 @@ pub struct Unsupported { pub struct Listing { pub measurements: Vec, pub unsupported: Vec, - /// The annotation limit stopped enumeration early. + /// The annotation or retained-size limit stopped enumeration early. pub truncated: bool, } const MAX_ANNOTATIONS: usize = 100_000; +/// Points and text one listing keeps, as counted by [`retained`]: about a thousand +/// measurements of [`MAX_POINTS`] points each. +const MAX_LISTED_BYTES: usize = 32 << 20; +/// The bytes a listed measurement keeps in points and text. +fn retained(m: &Measurement) -> usize { + let s = &m.scale; + [m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()] + .into_iter() + .fold(m.points.len().saturating_mul(std::mem::size_of::()), usize::saturating_add) +} fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result { let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?; let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" }; @@ -471,6 +481,8 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us pub fn list(doc: &Document) -> Listing { let mut out = Listing::default(); let mut count = 0usize; + let mut kept = 0usize; + let mut seen = std::collections::HashSet::new(); for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() { let Some(o) = p.dict.get(b"Annots") else { continue }; let o = doc.resolve(o); @@ -481,6 +493,12 @@ pub fn list(doc: &Document) -> Listing { out.truncated = true; return out; } + // One annotation object under several entries or pages is one measurement. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let o = doc.resolve(a); let Some(d) = o.as_dict() else { continue }; let kind = match d.name(b"IT") { @@ -490,7 +508,14 @@ pub fn list(doc: &Document) -> Listing { _ => continue, }; match read_measurement(doc, d, kind, page, index) { - Ok(m) => out.measurements.push(m), + Ok(m) => { + kept = kept.saturating_add(retained(&m)); + if kept > MAX_LISTED_BYTES { + out.truncated = true; + return out; + } + out.measurements.push(m); + } Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }), } } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 303f9b438..315fedc93 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -403,3 +403,23 @@ fn a_scale_ratio_converts_its_page_side_to_points() { assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}"); } } +#[test] +fn listing_skips_repeated_refs_and_caps_what_it_keeps() { + let mut d = fixture("", "", &[]); + let points: Vec = (0..MAX_POINTS).map(|i| [i as f64 * 0.1, 0.0]).collect(); + add(&mut d, &new(Kind::Perimeter, points), &Meta::default()).unwrap(); + let (r, mut annotation) = annot(&d, 0, 0).unwrap(); + let page = pdfcraft_model::pages(&d)[0].obj; + let set_annots = |d: &mut Document, list: Vec| d.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(list))).unwrap(); + // One annotation referenced 1000 times is one measurement. + set_annots(&mut d, vec![Object::Ref(r); 1000]); + assert_eq!(list(&d).measurements.len(), 1); + // Many small annotations sharing one 2048-point vertex array stop at the retained budget. + let vertices = d.add(annotation.get(b"Vertices").unwrap().clone()); + annotation.set(b"Vertices".to_vec(), Object::Ref(vertices)); + set_annots(&mut d, vec![Object::Dict(annotation); 1200]); + let listing = list(&d); + assert!(listing.truncated); + assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); + assert!(!listing.measurements.is_empty()); +} From a34697a8eb8d67088543effce1411a85cb0604ed Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:12:31 +0000 Subject: [PATCH 08/25] fix(measure): a failed content decode uses up the snap byte budget Snap geometry charged decoded bytes only on success, so a /Contents array repeating one stream that inflates past the 16 MiB limit ran that inflation for every entry without spending any budget. A tolerant decode fails only on an unsupported filter or after producing the remaining budget, so a failure now spends the rest of the budget and later parts are skipped as truncated. --- crates/measure/src/snap.rs | 12 +++++++++--- crates/measure/src/tests.rs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 3cf6bbd17..37e64e8b8 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -214,14 +214,14 @@ impl Walker<'_> { self.geometry.truncated = true; break; } - // An undecodable part is skipped; the rest of the page still snaps. + // An undecodable part is skipped; the parts before it still snap. match s.decoded_within(remaining) { Ok(data) => { joined.extend(data); // A space, as the renderer joins pieces: a trailing comment runs on. joined.push(b' '); } - Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1), + Err(_) => self.decode_failed(remaining), } } } @@ -236,11 +236,17 @@ impl Walker<'_> { match stream.decoded_within(remaining) { Ok(data) => self.ops(&data, resources, initial, depth), Err(_) => { - self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.decode_failed(remaining); Ok(()) } } } + /// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes, + /// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work. + fn decode_failed(&mut self, remaining: usize) { + self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.bytes = self.bytes.saturating_add(remaining); + } fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> { self.bytes = self.bytes.saturating_add(data.len()); self.streams = self.streams.saturating_add(1); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 315fedc93..e9722cb62 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -423,3 +423,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() { assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); assert!(!listing.measurements.is_empty()); } +#[test] +fn a_stream_over_the_byte_budget_uses_it_up_once() { + // 50 references to one Flate stream that inflates past the 16 MiB budget decode once. + let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]); + let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect(); + let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap(); + let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50)); + let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]); + let g = snap::geometry(&d, 0).unwrap(); + assert_eq!(g.unreadable, 1); + assert!(g.truncated); + assert_eq!(g.segments.len(), 1, "the parts before it still snap"); +} From 853a9ae1e33f9c906df7084d20a12eaf5c92ebe4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:13:06 +0000 Subject: [PATCH 09/25] fix(preflight): decode an output-intent profile within 16 MiB Reading the colour space from an ICC header when /N is missing decoded the whole profile under the general 1 GiB stream limit to look at four bytes. The profile now decodes within 16 MiB; a larger one leaves the component count unknown. --- crates/preflight/src/lib.rs | 5 ++++- crates/preflight/src/tests.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 2cda29fea..ce17a4a8d 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -214,6 +214,9 @@ fn has_pdfa_intent(doc: &Document) -> bool { pdfa_intent_profile(doc).is_some() } +/// Largest output-intent profile decoded to read its header; real ICC profiles stay well under it. +const MAX_ICC_PROFILE: usize = 16 << 20; + /// The PDF/A output intent's destination profile: `Some(n)` with the profile's number of colour /// components (`/N`, else the ICC header's data colour space; `None` when neither says), or /// `None` when the document has no PDF/A output intent. @@ -229,7 +232,7 @@ fn pdfa_intent_profile(doc: &Document) -> Option> { let Object::Stream(s) = &*profile else { return Some(None) }; Some(s.dict.int(b"N").or_else(|| { // ICC.1 header: the data colour space is bytes 16–19. - match s.decoded().ok()?.get(16..20)? { + match s.decoded_within(MAX_ICC_PROFILE).ok()?.get(16..20)? { b"GRAY" => Some(1), b"RGB " => Some(3), b"CMYK" => Some(4), diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 80b1c63c4..efd55f28e 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -417,3 +417,15 @@ fn oversized_xmp_is_refused_while_decoding() { assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); } + +#[test] +fn an_output_intent_profile_header_is_read_within_a_size_cap() { + let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]"; + for (size, n) in [(128, Some(4)), (MAX_ICC_PROFILE + 1, None)] { + let mut doc = build(&[&format!("<< /Type /Catalog {intent} >>"), "null"], ""); + let mut profile = vec![0; size]; + profile[16..20].copy_from_slice(b"CMYK"); + doc.set(pdfcraft_cos::ObjRef::new(2, 0), Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &profile))); + assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); + } +} From dfde90787f416f8d4953f861f308a3407f075839 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:14:55 +0000 Subject: [PATCH 10/25] fix(render): list a repeated annotation once and cap what the listing keeps Inspection built an annotation record, with owned copies of its text, for every /Annots entry on every page, so one comment referenced many times, a page-shared /Annots array, or many small comments sharing one long /Contents string multiplied that text in memory. Pages are read in order, an annotation or /Annots array already listed is skipped, and the listing stops once the kept text and geometry pass 64 MiB. --- crates/render/src/inspect.rs | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index e9afb4ec4..6c3bd2c0d 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -38,6 +38,24 @@ const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; +/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest +/// are not listed: real comments stay far below, many small ones sharing one long string do not. +const MAX_ANNOTATION_BYTES: usize = 64 << 20; + +/// The bytes a listed annotation keeps. +fn retained(a: &Annotation) -> usize { + let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; + text.into_iter() + .map(|t| t.as_ref().map_or(0, String::len)) + .chain([ + a.subtype.len(), + std::mem::size_of_val(a.quads.as_slice()), + std::mem::size_of_val(a.points.as_slice()), + std::mem::size_of::(), + ]) + .fold(0, usize::saturating_add) +} + /// At most this many layers are read from all of `/RBGroups` together. const MAX_LAYER_GROUP_ENTRIES: usize = 4096; @@ -1142,10 +1160,26 @@ impl<'a> Inspector<'a> { // ── annotations ───────────────────────────────────────────────────────────────────────── fn annotations(&self, info: &mut DocInfo) { - for (&id, &page) in &self.page_index { + let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect(); + pages.sort_unstable_by_key(|&(_, page)| page); + // An annotation, or a whole `/Annots` array, that several entries or pages list is on the + // first of them (§12.5.2 allows one page per annotation). + let mut seen = HashSet::new(); + let mut kept = 0usize; + for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; + if let Ok(Object::Reference(r)) = page_dict.get(b"Annots") + && !seen.insert(*r) + { + continue; + } let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; for (index, a) in annots.iter().enumerate() { + if let Object::Reference(r) = a + && !seen.insert(*r) + { + continue; + } let Some(d) = self.dict(a) else { continue }; let Some(subtype) = self.name(d, b"Subtype") else { continue }; if subtype == "FileAttachment" @@ -1209,7 +1243,7 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; - info.annotations.push(Annotation { + let annotation = Annotation { page, subtype, author: self.text(d, b"T"), @@ -1228,7 +1262,15 @@ impl<'a> Inspector<'a> { intent: self.name(d, b"IT"), fill_sign, points, - }); + }; + kept = kept.saturating_add(retained(&annotation)); + if kept > MAX_ANNOTATION_BYTES { + break; + } + info.annotations.push(annotation); + } + if kept > MAX_ANNOTATION_BYTES { + break; } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); @@ -2583,6 +2625,33 @@ trailer << /Root 1 0 R >> } } + /// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many + /// small comments sharing one large `/Contents` string stop at the retained-bytes budget. + #[test] + fn repeated_annotations_are_listed_once_within_a_byte_budget() { + let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\ + 5 0 obj [{} {small}] endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\ + 7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + "6 0 R ".repeat(1000), + "x".repeat(64 << 10), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + let notes = &info.annotations; + assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1); + assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's"); + assert!(notes.len() > 1); + assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); + } + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. From 77ee1c8a26c2efadc82952888ffd69ccba36e6de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:16:19 +0000 Subject: [PATCH 11/25] fix(sign): read DSS revocation evidence one stream at a time within caps Revocation checking decoded every /CRLs and /OCSPs entry under the general 1 GiB stream limit and kept them all before parsing any, so one compressed stream referenced many times multiplied decoding and memory. Evidence now decodes lazily, each stream once and within the 2 MiB evidence cap, with at most 1024 entries and 64 MiB per array. --- crates/sign/src/pdf.rs | 47 ++++++++++++++++++-------- crates/sign/src/pdf_discovery_tests.rs | 22 ++++++++++++ crates/sign/src/revocation.rs | 2 +- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee..04598e033 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -975,6 +975,37 @@ fn finish_validation( } } +/// Evidence streams read from one `/DSS` array. +const MAX_DSS_ENTRIES: usize = 1024; +/// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. +const MAX_DSS_BYTES: usize = 64 << 20; + +/// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so +/// each is checked before the next decodes: each stream once and within the evidence size cap, +/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. +fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let mut items = items.into_iter().take(MAX_DSS_ENTRIES); + let mut seen = HashSet::new(); + let mut left = MAX_DSS_BYTES; + std::iter::from_fn(move || { + for item in items.by_ref() { + if left == 0 { + return None; + } + if item.as_ref().is_some_and(|r| !seen.insert(r)) { + continue; + } + let o = doc.resolve(&item); + let Object::Stream(s) = o.as_ref() else { continue }; + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; + left = left.saturating_sub(bytes.len()); + return Some(bytes); + } + None + }) +} + /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the /// certificate is revoked (which invalidates the signature). @@ -985,25 +1016,13 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio let Some(dss) = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned()) else { return false; }; - let pull = |key: &[u8]| -> Vec> { - let mut out = Vec::new(); - for item in dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - let o = doc.resolve(&item); - if let pdfcraft_cos::Object::Stream(s) = &*o - && let Ok(bytes) = s.decoded() - { - out.push(bytes); - } - } - out - }; let issuer = info.chain.get(1).unwrap_or(signer); // A verified revocation wins; "good" from any covering evidence is reported, but never // contradicts a revocation. let mut revoked = None; let mut covered = false; let mut sources = Vec::new(); - for r in pull(b"CRLs") { + for r in dss_evidence(doc, &dss, b"CRLs") { let Ok(list) = CertificateList::parse(&r) else { continue }; sources.push("CRL"); match list.check(signer, issuer, at) { @@ -1016,7 +1035,7 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio } } if revoked.is_none() { - for r in pull(b"OCSPs") { + for r in dss_evidence(doc, &dss, b"OCSPs") { let Ok(resp) = OcspResponse::parse(&r) else { continue }; sources.push("OCSP"); match resp.check(signer, issuer, at) { diff --git a/crates/sign/src/pdf_discovery_tests.rs b/crates/sign/src/pdf_discovery_tests.rs index 66bf7c8a0..9bc444823 100644 --- a/crates/sign/src/pdf_discovery_tests.rs +++ b/crates/sign/src/pdf_discovery_tests.rs @@ -371,3 +371,25 @@ fn only_grew_compares_huge_arrays_without_quadratic_work() { assert!(!only_grew(&doc, &doc, &ints(20_000), &ints(20_001), 0)); assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); } + +#[test] +fn dss_evidence_decodes_each_stream_once_within_its_caps() { + // One small stream listed 1000 times, then streams that inflate past the evidence cap. + let mut doc = fixture(&[]); + let small = doc.add(Object::Stream(Stream::flate(Dict::new(), b"evidence"))); + let mut items = vec![Object::Ref(small); 1000]; + for _ in 0..4 { + items.push(Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![0; crate::revocation::MAX_EVIDENCE + 1]))))); + } + let mut dss = Dict::new(); + dss.set(b"CRLs".to_vec(), Object::Array(items)); + let read: Vec> = dss_evidence(&doc, &dss, b"CRLs").collect(); + assert_eq!(read, vec![b"evidence".to_vec()]); + // Distinct streams stop at the entry and byte budgets. + let items: Vec = + (0..MAX_DSS_ENTRIES + 10).map(|_| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 1 << 16]))))).collect(); + dss.set(b"OCSPs".to_vec(), Object::Array(items)); + let total: usize = dss_evidence(&doc, &dss, b"OCSPs").map(|b| b.len()).sum(); + assert!(total <= MAX_DSS_BYTES, "{total}"); + assert!(dss_evidence(&doc, &dss, b"OCSPs").count() <= MAX_DSS_ENTRIES); +} diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index b4a9d820f..057e0717d 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -8,7 +8,7 @@ use crate::x509::Certificate; use crate::{SignError, Time}; /// A DER size cap: real CRLs and OCSP responses are far smaller. -const MAX_EVIDENCE: usize = 2 * 1024 * 1024; +pub(crate) const MAX_EVIDENCE: usize = 2 * 1024 * 1024; const ID_PKIX_OCSP_BASIC: &str = "1.3.6.1.5.5.7.48.1.1"; const EKU_OCSP_SIGNING: &str = "1.3.6.1.5.5.7.3.9"; From 2e1c029fa82efeaae0b9c339a6b1ad3ea6d90f08 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:26 +0000 Subject: [PATCH 12/25] fix(xfa): every flowed subform instance spends the layout budget Nested repeating subforms multiplied their instances (up to 50 per level over 64 levels), and an empty instance neither measured nor placed anything, so it spent none of the measurement or item budgets. Each instance now counts as a measurement, and layout stops with TooLarge once the budget is spent. --- crates/xfa/src/layout.rs | 3 +++ crates/xfa/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 326e7f741..b4d331255 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -769,6 +769,9 @@ impl Layouter<'_> { self.warn_truncated(node, &ctx.som, instances); let ctx_parent = ctx; for i in 0..instances { + // Every instance spends budget, so nested repeats that place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; let ctx = ctx_parent.with_som(&sf.common, sib + i); let (rect, _, _) = self.cursor(); let (x, w) = avail.unwrap_or((rect.x, rect.w)); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 84c9fc00f..c5de7ad88 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -404,6 +404,18 @@ fn repeated_pictures_are_bounded_in_total_bytes() { assert!(layout_xml(&ok).is_ok(), "one small picture must lay out"); } +#[test] +fn empty_repeating_subforms_spend_the_layout_budget() { + // Four nested levels of 50 empty instances are 6.25 million instances that place nothing + // and measure nothing; each one counts against the layout budget. + let level = ""; + let tpl = format!("", level.repeat(4), "".repeat(4)); + assert!(matches!(layout_xml(&tpl), Err(XfaError::TooLarge(_)))); + // Three levels (125,000 instances) still lay out. + let tpl = format!("", level.repeat(3), "".repeat(3)); + assert!(layout_xml(&tpl).is_ok()); +} + #[test] fn the_packets_can_be_one_stream_or_named_parts() { // Named parts, with the template split from the rest. From 306627ee778ca04498dc4a14f467bf6def134ddc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:19:08 +0000 Subject: [PATCH 13/25] fix(xfa): read a packet stream the XFA array lists again only once The packet reader decoded the stream of every /XFA array entry and charged only the decoded output to its 64 MB budget, so one stream that decodes to almost nothing (Flate over hex whitespace), listed many times, repeated a large decode for free. A stream reference already read is skipped. --- crates/xfa/src/packets.rs | 6 ++++++ crates/xfa/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/xfa/src/packets.rs b/crates/xfa/src/packets.rs index 875cdc894..f79b071c0 100644 --- a/crates/xfa/src/packets.rs +++ b/crates/xfa/src/packets.rs @@ -113,7 +113,13 @@ pub fn read_packets(doc: &Document) -> Result, XfaError> { let needs_rendering = bool_of(catalog.as_dict().and_then(|c| c.get(b"NeedsRendering"))).or_else(|| bool_of(acro.get(b"NeedsRendering"))); let has_fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().map(|a| !a.is_empty())).unwrap_or(false); let mut total = 0usize; + // A stream listed again adds nothing but another decode, which may cost far more than the + // bytes it yields (a Flate stream of hex whitespace). + let mut seen = std::collections::HashSet::new(); let mut read = |o: &Object| -> Result, XfaError> { + if o.as_ref().is_some_and(|r| !seen.insert(r)) { + return Ok(None); + } let s = doc.resolve(o); let Object::Stream(s) = &*s else { return Ok(None) }; let bytes = s.decoded_within(MAX_XDP)?; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index c5de7ad88..03a7d2c5a 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -416,6 +416,23 @@ fn empty_repeating_subforms_spend_the_layout_budget() { assert!(layout_xml(&tpl).is_ok()); } +#[test] +fn a_packet_stream_listed_many_times_is_read_once() { + let mut doc = Document::open(Arc::new(shell(""))).unwrap(); + let packet = pdfcraft_cos::ObjRef::new(5, 0); + let items: Vec = + (0..1000).flat_map(|_| [Object::String(pdfcraft_cos::PdfString::literal(b"template".to_vec())), Object::Ref(packet)]).collect(); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap(); + acro.set(b"XFA".to_vec(), Object::Array(items)); + c.set(b"AcroForm".to_vec(), Object::Dict(acro)); + }) + .unwrap(); + let p = read_packets(&doc).unwrap().unwrap(); + assert_eq!(p.xdp.matches(" Date: Thu, 22 Oct 2026 17:12:57 +0000 Subject: [PATCH 14/25] fix(fonts): let auto-deref pass the candidate objects to the program match Clippy's explicit_auto_deref rejects the explicit derefs. --- crates/fonts/src/embedding.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index 23f34dbb3..2b7b4e17e 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -290,7 +290,7 @@ impl<'a> ProgramMatch<'a> { /// Whether the resource's program is this one. fn written(&mut self, doc: &Document, w: &Written) -> bool { - w.program.as_ref().is_some_and(|r| self.is(doc, r, &*doc.get(r))) + w.program.as_ref().is_some_and(|r| self.is(doc, r, &doc.get(r))) } } @@ -300,7 +300,7 @@ fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) - written(doc, fonts) .find(|w| programs.written(doc, w)) .map(|w| w.program) - .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &*o).then_some(Object::Ref(r)))) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. From 02f0f7396f6b823bc7736fcc0a909b382aed2651 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:04:04 +0000 Subject: [PATCH 15/25] fix(annot): count web-address text after its UTF-8 conversion An address of invalid UTF-8 grows up to three times when converted, so the listing's 16 MiB cap counted fewer bytes than it kept. The cap now counts the converted text; the raw length is still checked first, so an address already over the cap is never converted. --- crates/annot/src/links.rs | 12 ++++++++---- crates/annot/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index b6a2c7575..657488137 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,7 +78,8 @@ pub struct LinkItem { pub style: LinkStyle, } -/// Web-address text one listing keeps; past it a link lists as [`LinkAction::Other`]. +/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link +/// lists as [`LinkAction::Other`]. pub(crate) const MAX_URI_BYTES: usize = 16 << 20; fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -103,10 +104,13 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) Some(b"URI") => { let uri = a.get(b"URI").map(|u| doc.resolve(u)); let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); - match uri_budget.checked_sub(bytes.len()) { - Some(left) => { + // The raw length first, so an address already over the budget is never converted; then the + // converted text, which is up to three times longer where the bytes are not UTF-8. + let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes)); + match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) { + Some((left, text)) => { *uri_budget = left; - LinkAction::Uri(String::from_utf8_lossy(bytes).into_owned()) + LinkAction::Uri(text.into_owned()) } None => LinkAction::Other("web address too long".into()), } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c52732ea8..6eb94b830 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1541,6 +1541,25 @@ fn link_listing_skips_repeated_refs_and_caps_web_address_text() { assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); } +#[test] +fn web_address_cap_counts_the_text_kept_after_conversion() { + // 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a + // three-byte replacement character. + let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /S /URI /URI 5 0 R >>".into(), + format!("<{}>", "FF".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.len(), 200); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(kept > 0); +} + #[test] fn cloud_outline_has_a_bump_budget() { // 50 long edges would each ask for 10,000 bumps of radius 1. From 071130d49868409f68066c55b74c0d7366e90699 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:05:05 +0000 Subject: [PATCH 16/25] fix(render): list links and attachments after the comment budget fills Reaching the 64 MiB comment budget ended the whole annotation scan, so links and file attachments listed after it were missing, and a later JavaScript link left the document unmarked. Past the budget, comments are no longer built, and the scan goes on for links and attachments. --- crates/render/src/inspect.rs | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 6c3bd2c0d..91be4de37 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -1166,6 +1166,9 @@ impl<'a> Inspector<'a> { // first of them (§12.5.2 allows one page per annotation). let mut seen = HashSet::new(); let mut kept = 0usize; + // Once the comment budget is spent, comments are no longer built, but links and attachments + // are still listed. + let mut comments_full = false; for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; if let Ok(Object::Reference(r)) = page_dict.get(b"Annots") @@ -1204,6 +1207,9 @@ impl<'a> Inspector<'a> { { continue; } + if comments_full { + continue; + } let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); let mut color = match d.get(b"C").map(|o| self.resolve(o)) { Ok(Object::Array(c)) if c.len() == 3 => { @@ -1265,13 +1271,11 @@ impl<'a> Inspector<'a> { }; kept = kept.saturating_add(retained(&annotation)); if kept > MAX_ANNOTATION_BYTES { - break; + comments_full = true; + continue; } info.annotations.push(annotation); } - if kept > MAX_ANNOTATION_BYTES { - break; - } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); } @@ -2652,6 +2656,31 @@ trailer << /Root 1 0 R >> assert!(notes.iter().map(retained).sum::() <= MAX_ANNOTATION_BYTES); } + /// Links and attachments after the comment budget fills are still listed, and a JavaScript link + /// among them still marks the document. + #[test] + fn links_and_attachments_past_the_comment_budget_are_listed() { + let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \ + << /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ + << /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\ + 6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + "x".repeat(64 << 10), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + assert!(info.annotations.len() < 1100, "the comment budget filled"); + assert_eq!(info.links.len(), 1); + assert!(info.has_javascript); + assert_eq!(info.attachments.len(), 1); + } + /// Widgets without `/P` are placed on the page that lists them with one pass over the pages' /// `/Annots`, not a search of every page per field: that was O(fields × annotations), a /// second to open a form of 8,000 fields. A widget two pages list is on the first of them. From a887405ea15a459c7f5867c025b26d3afe9f9429 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:07:41 +0000 Subject: [PATCH 17/25] fix(sign): don't report valid when a DSS cap hides revocation evidence The evidence caps counted repeated references to one stream, so a /DSS array could list one benign stream 1024 times ahead of a revoking CRL, and the check never reached it. Repeats are skipped before the entry cap counts anything, and when a cap still stops an array being read in full without a revocation found, the signature's status is unknown rather than valid, with the reason in its details. --- crates/sign/src/pdf.rs | 115 +++++++++++++++++++------ crates/sign/src/pdf_discovery_tests.rs | 12 ++- crates/sign/tests/it/pdf.rs | 31 +++++++ 3 files changed, 126 insertions(+), 32 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 04598e033..f4fe2c601 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -892,7 +892,8 @@ fn finish_validation( // Revocation evidence embedded in the document security store, if any. A verified // revocation is final: later blocks must not soften the verdict. let signer = info.chain.first().cloned(); - let revoked = check_dss_revocation(doc, signer.as_ref(), &at, info); + let revocation = check_dss_revocation(doc, signer.as_ref(), &at, info); + let revoked = revocation == DssRevocation::Revoked; // Changes after signing. info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -929,6 +930,12 @@ fn finish_validation( problems = true; } } + if revocation == DssRevocation::Incomplete { + if !problems { + info.status = Status::Unknown; + } + problems = true; + } if let Some(t) = info.timestamp_time.or(info.signing_time) && !cert.valid_at(t) { @@ -975,46 +982,86 @@ fn finish_validation( } } -/// Evidence streams read from one `/DSS` array. +/// Distinct evidence streams read from one `/DSS` array. const MAX_DSS_ENTRIES: usize = 1024; /// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each. const MAX_DSS_BYTES: usize = 64 << 20; /// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so /// each is checked before the next decodes: each stream once and within the evidence size cap, -/// at most [`MAX_DSS_ENTRIES`] of them and [`MAX_DSS_BYTES`] together. -fn dss_evidence<'a>(doc: &'a Document, dss: &Dict, key: &[u8]) -> impl Iterator> + 'a { - let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let mut items = items.into_iter().take(MAX_DSS_ENTRIES); - let mut seen = HashSet::new(); - let mut left = MAX_DSS_BYTES; - std::iter::from_fn(move || { - for item in items.by_ref() { - if left == 0 { - return None; - } - if item.as_ref().is_some_and(|r| !seen.insert(r)) { +/// at most [`MAX_DSS_ENTRIES`] distinct ones and [`MAX_DSS_BYTES`] together. A stream past a cap +/// sets [`DssEvidence::cut`] and ends the iteration. +struct DssEvidence<'a> { + doc: &'a Document, + items: std::vec::IntoIter, + seen: HashSet, + taken: usize, + left: usize, + /// A cap stopped the array being read in full. + cut: bool, +} + +impl<'a> DssEvidence<'a> { + fn new(doc: &'a Document, dss: &Dict, key: &[u8]) -> Self { + let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + DssEvidence { doc, items: items.into_iter(), seen: HashSet::new(), taken: 0, left: MAX_DSS_BYTES, cut: false } + } +} + +impl Iterator for DssEvidence<'_> { + type Item = Vec; + + fn next(&mut self) -> Option> { + if self.cut { + return None; + } + // Repeats are skipped before the caps count anything, so repeating one stream cannot use + // them up; the scan of the array itself is bounded by the parser's cap on array length. + for item in self.items.by_ref() { + if item.as_ref().is_some_and(|r| !self.seen.insert(r)) { continue; } - let o = doc.resolve(&item); + let o = self.doc.resolve(&item); let Object::Stream(s) = o.as_ref() else { continue }; - let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE.min(left)) else { continue }; - left = left.saturating_sub(bytes.len()); + if self.taken >= MAX_DSS_ENTRIES { + self.cut = true; + return None; + } + self.taken += 1; + // A stream past the evidence cap would not parse as evidence either. + let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE) else { continue }; + let Some(left) = self.left.checked_sub(bytes.len()) else { + self.cut = true; + return None; + }; + self.left = left; return Some(bytes); } None - }) + } +} + +/// What the catalog's `/DSS` says about the signer's certificate. +#[derive(Clone, Copy, Debug, PartialEq)] +enum DssRevocation { + /// Verified evidence revokes it. + Revoked, + /// A cap stopped the evidence being read in full, and what was read does not revoke it. + Incomplete, + /// All of the evidence was read, and none of it revokes it. + NotRevoked, } /// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`. /// Only evidence whose signature verifies against the issuer counts; returns whether the -/// certificate is revoked (which invalidates the signature). -fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option