diff --git a/Cargo.lock b/Cargo.lock index 6518430017..47d73be440 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3936,7 +3936,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "craft-quit", @@ -3962,7 +3962,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.12.0" +version = "0.13.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.12.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3985,7 +3985,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.12.0" +version = "0.13.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3996,7 +3996,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.12.0" +version = "0.13.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.12.0" +version = "0.13.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4036,11 +4036,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.12.0" +version = "0.13.0" [[package]] name = "pdfcraft-content" -version = "0.12.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4048,7 +4048,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "hayro-syntax", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.12.0" +version = "0.13.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.12.0" +version = "0.13.0" dependencies = [ "aes", "md-5", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.12.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "craft-docpath", @@ -4147,14 +4147,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.12.0" +version = "0.13.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.12.0" +version = "0.13.0" dependencies = [ "flate2", "proptest", @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.12.0" +version = "0.13.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.12.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4187,11 +4187,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.12.0" +version = "0.13.0" [[package]] name = "pdfcraft-js" -version = "0.12.0" +version = "0.13.0" dependencies = [ "boa_engine", "chrono", @@ -4199,7 +4199,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.12.0" +version = "0.13.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4212,14 +4212,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.12.0" +version = "0.13.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.12.0" +version = "0.13.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4229,7 +4229,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.12.0" +version = "0.13.0" dependencies = [ "image", "pdfcraft-annot", @@ -4242,11 +4242,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.12.0" +version = "0.13.0" [[package]] name = "pdfcraft-organize" -version = "0.12.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.12.0" +version = "0.13.0" dependencies = [ "async-channel", "futures-lite", @@ -4267,7 +4267,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.12.0" +version = "0.13.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4278,7 +4278,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.12.0" +version = "0.13.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4306,7 +4306,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.12.0" +version = "0.13.0" dependencies = [ "bytemuck", "flate2", @@ -4325,7 +4325,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.12.0" +version = "0.13.0" dependencies = [ "aes", "aws-lc-rs", @@ -4360,14 +4360,15 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.12.0" +version = "0.13.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.12.0" +version = "0.13.0" dependencies = [ "arboard", "base64 0.22.1", + "block2 0.6.2", "bytemuck", "chrono", "craft-quit", @@ -4391,6 +4392,7 @@ dependencies = [ "pdfcraft-platform", "pdfcraft-render", "pdfcraft-xfa", + "plist", "png 0.18.1", "pollster 1.0.1", "rfd", @@ -4405,11 +4407,12 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "winsafe", + "xattr", ] [[package]] name = "pdfcraft-web" -version = "0.12.0" +version = "0.13.0" dependencies = [ "eframe", "egui", @@ -4421,7 +4424,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.12.0" +version = "0.13.0" dependencies = [ "base64 0.22.1", "image", @@ -4435,7 +4438,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.12.0" +version = "0.13.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -4662,6 +4665,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -4889,6 +4905,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -7261,7 +7286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] diff --git a/Cargo.toml b/Cargo.toml index 5841b50c68..92f51290d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.12.0" +version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.12.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.12.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.12.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.12.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.12.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.12.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.12.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.12.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.12.0" } -pdfcraft-model = { path = "crates/model", version = "0.12.0" } -pdfcraft-content = { path = "crates/content", version = "0.12.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.12.0" } -pdfcraft-print = { path = "crates/print", version = "0.12.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.12.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.12.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.12.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.12.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.12.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.12.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.12.0" } -pdfcraft-js = { path = "crates/js", version = "0.12.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.12.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.12.0" } -pdfcraft-export = { path = "crates/export", version = "0.12.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.12.0" } -pdfcraft-create = { path = "crates/create", version = "0.12.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.12.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.12.0" } -pdfcraft-render = { path = "crates/render", version = "0.12.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.12.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.12.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.12.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.12.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.13.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.13.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.13.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.13.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.13.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.13.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.13.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" } +pdfcraft-model = { path = "crates/model", version = "0.13.0" } +pdfcraft-content = { path = "crates/content", version = "0.13.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.13.0" } +pdfcraft-print = { path = "crates/print", version = "0.13.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.13.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" } +pdfcraft-js = { path = "crates/js", version = "0.13.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.13.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" } +pdfcraft-export = { path = "crates/export", version = "0.13.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.13.0" } +pdfcraft-create = { path = "crates/create", version = "0.13.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.13.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" } +pdfcraft-render = { path = "crates/render", version = "0.13.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.13.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.13.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false } # pdf hayro = "0.7.1" diff --git a/ROADMAP.md b/ROADMAP.md index bc057209c2..27c8e1b6bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -189,6 +190,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. - **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged. diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index fe8eb292c7..87cfce5acb 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream}; /// A Figure element (after the role map). #[derive(Clone, Debug, PartialEq)] @@ -23,6 +23,8 @@ pub enum AltError { Untagged, #[error("object {0} is not a figure")] NotAFigure(u32), + #[error("the page has more content than can be edited")] + TooLarge, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -48,6 +50,8 @@ pub enum ArtifactError { AmbiguousParent, #[error("couldn't update the parent tree: {0}")] ParentTree(String), + #[error("the page has more content than can be edited")] + TooLarge, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), } @@ -157,22 +161,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option Vec { + page_content_within(doc, page, &mut DecodeBudget::page()) +} + +/// The page's content streams, decoded against `budget` and joined with a space. Streams that +/// don't decode, or arrive after the budget is spent, are left out. +pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec { let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b' '); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), + let mut out = Vec::new(); + for data in budget.decode_contents(doc, c).into_iter().flatten() { + out.extend_from_slice(&data); + out.push(b' '); } + out } fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { @@ -375,9 +376,19 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; let props = page_properties(doc, &page.dict); let mut streams: Vec)>> = Vec::with_capacity(entries.len()); + let mut budget = DecodeBudget::page(); for entry in &entries { match &*doc.resolve(entry) { - Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))), + Object::Stream(s) => { + let data = match budget.decode(s) { + Ok(data) => data, + // Content past the budget may hold more of the figure: editing the rest + // would leave that part marked as a figure the tree no longer has. + Err(_) if budget.exhausted() => return Err(AltError::TooLarge), + Err(_) => Vec::new(), + }; + streams.push(Some((s.clone(), data))); + } _ => streams.push(None), } } @@ -474,7 +485,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact let pages = pdfcraft_model::pages(doc); let page_ref = element.page.ok_or(ArtifactError::NoPage)?; let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?; - let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops; + let mut content = Vec::new(); + if let Some(c) = page.dict.get(b"Contents") { + let mut budget = DecodeBudget::page(); + for data in budget.decode_contents(doc, c) { + match data { + Ok(data) => { + content.extend_from_slice(&data); + content.push(b' '); + } + // The page's /Contents is replaced with what was decoded: content past the + // budget would be dropped from the page. + Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge), + Err(_) => {} + } + } + } + let mut ops = pdfcraft_content::parse(&content).ops; let mut found = HashSet::new(); for op in &mut ops { let id = (op.op == b"BDC") diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index 8f7221b2c0..fd5f0d305f 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; use pdfcraft_model::Page; use crate::{Finding, Rule}; @@ -20,6 +20,8 @@ pub(crate) struct Scan { pub untagged: BTreeMap, /// Page → fonts used. pub fonts: BTreeMap>, + /// Pages read only in part: content or forms past the page's decode budget are missing. + pub incomplete: BTreeSet, } const MAX_FORM_DEPTH: usize = 12; @@ -30,38 +32,25 @@ impl Scan { for &p in list { let Some(page) = pages.get(p) else { continue }; let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let data = page_content(doc, &page.dict); - let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() }; + let mut budget = DecodeBudget::page(); + let data = crate::alt::page_content_within(doc, &page.dict, &mut budget); + if budget.remaining() == 0 { + s.incomplete.insert(p); + } + let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget }; w.content(&data, &res, false, 0); } s } } -fn page_content(doc: &Document, page: &Dict) -> Vec { - let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b' '); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), - } -} - struct Walk<'a> { doc: &'a Document, scan: &'a mut Scan, page: usize, forms: HashSet, + /// Decoded bytes and form entries left for this page. + budget: DecodeBudget, } impl Walk<'_> { @@ -116,14 +105,27 @@ impl Walk<'_> { self.scan.has_images = true; untagged += usize::from(!inside); } - Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => { + Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => { + if !self.budget.enter_form() { + self.scan.incomplete.insert(self.page); + continue; + } + let data = match self.budget.decode(x) { + Ok(data) => data, + Err(_) => { + if self.budget.exhausted() { + self.scan.incomplete.insert(self.page); + } + continue; + } + }; + self.forms.insert(r); let inner = x .dict .get(b"Resources") .map(|o| self.doc.resolve(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_else(|| res.clone()); - let data = x.decoded().unwrap_or_default(); self.content(&data, &inner, inside, depth + 1); self.forms.remove(&r); } diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 21d8ea5820..280f4e845f 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -681,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report { } } } + // A page read only in part may hold what the scan didn't find: text that makes the + // document more than images, untagged content, or a font without Unicode mappings. + let unsure = !scan.incomplete.is_empty() + && match rule { + Rule::ImageOnly => !scan.has_text, + Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(), + _ => false, + }; + if unsure { + results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() }); + continue; + } if total > MAX_FINDINGS { let more = total - MAX_FINDINGS; findings.truncate(MAX_FINDINGS); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index ad57477364..399aded3cf 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1252,3 +1252,75 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() { let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); assert_eq!(titles, ["I Overview"]); } + +#[test] +fn repeated_content_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = crate::alt::page_content(&doc, &page); + assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len()); +} + +/// A figure whose marked content goes on in a stream past the page's decode budget is refused, +/// not removed with that content still pointing at it. +#[test] +fn marking_decorative_refuses_content_past_the_decode_budget() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut doc = figures_doc(); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(last)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err()); + assert_eq!(figures(&doc).len(), 2, "the figure stays"); +} + +/// Text past the page's decode budget is unread, so a page that shows images and no text so far +/// can't be called image-only, nor its content all tagged. +#[test] +fn content_past_the_decode_budget_needs_a_manual_check() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>" + .into(), + stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + FONT.into(), + ], + "", + ); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(text)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + let report = check(&doc, &Options::default()); + assert_eq!(status(&report, Rule::ImageOnly), Status::Manual); + assert_eq!(status(&report, Rule::TaggedContent), Status::Manual); +} + +/// Marking a tag as an artifact replaces the page's content with what it decoded, so content past +/// the page's decode budget is refused rather than dropped from the page. +#[test] +fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut doc = figures_doc(); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let last = doc.add(Stream::from_raw(Dict::new(), b"0 0 5 5 re f".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(last)); + let contents = Object::Array(contents); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), contents.clone())).unwrap(); + assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge)); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content"); +} diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 4668881a6c..59cacbbdd9 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/links.rs b/crates/annot/src/links.rs index 95d4080587..53c31d83a6 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,11 +78,15 @@ pub struct LinkItem { pub style: LinkStyle, } +/// 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 { 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 +101,20 @@ 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[..]); + // 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(text.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 +148,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 +171,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 017e066713..b39e41c60a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1559,3 +1559,66 @@ fn a_link_batch_with_a_bad_entry_creates_no_objects() { assert_eq!((annots(&doc, page).len(), doc.scan_objects().count()), before); } } + +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 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. + 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"); +} diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 3df666c190..93394ccfb0 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -16,7 +16,7 @@ //! Positions compare lexicographically in paint order. use pdfcraft_content::{Matrix, Op, Pieces, parse}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -109,6 +109,9 @@ struct Walker<'a> { pieces: &'a Pieces, out: Vec, ops_seen: usize, + /// Decoded bytes and form entries left for the page, its forms included: spending either + /// fails the audit. + budget: DecodeBudget, } impl<'a> Walker<'a> { @@ -271,8 +274,11 @@ impl<'a> Walker<'a> { }) .unwrap_or(Matrix::IDENTITY); let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + if !self.budget.enter_form() { + return Err(AuditError::TooManyForms); + } // A form stream that cannot be decoded fails the audit: it might hide a cover. - let data = stream.decoded_strict()?; + let data = self.budget.decode_strict(stream)?; let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); @@ -304,7 +310,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { /// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint /// positions. A page's content streams are walked once, joined, with one graphics state, so /// state set in an earlier stream is still in force in the later ones. Any content stream that -/// cannot be decoded is an audit failure, never an empty result. +/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`] +/// in all, is an audit failure, never an empty result. pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> { let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let contents: Vec = match page.get(b"Contents") { @@ -314,15 +321,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi _ => vec![c.clone()], }, }; + let mut budget = DecodeBudget::page(); let mut streams = Vec::with_capacity(contents.len()); for c in &contents { if let Object::Stream(s) = &*doc.resolve(c) { - streams.push(s.decoded_strict()?); + streams.push(budget.decode_strict(s)?); } } let pieces = Pieces::join(&streams); let parsed = pieces.parse(); - let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 }; + let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 365889d61a..8277cb0aa6 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -77,6 +77,8 @@ pub enum AuditError { TooManyOps, #[error("form XObjects nest deeper than the audit follows")] TooDeep, + #[error("content walk entered more form XObjects than the audit follows")] + TooManyForms, #[error("page has a malformed /Annots entry")] BadAnnots, #[error(transparent)] diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 4ca3b0bf5b..3d0c80e0b9 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -592,3 +592,36 @@ fn reading_blocks_respect_stream_comments_and_split_operands() { assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); } } + +#[test] +fn repeated_stream_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents, + // and by 300 draws of a form holding it. + let mut doc = Document::new_empty(); + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams"); + + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20])); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws"); +} + +#[test] +fn form_entries_past_the_page_allowance_fail_the_audit() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // An empty form decodes to no bytes, so only the count of entries bounds drawing it again. + let mut doc = Document::new_empty(); + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::from_raw(form_dict, Vec::new())); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index ab42728c21..bb478d18e9 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -78,6 +78,9 @@ fn is_delim(b: u8) -> bool { matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') } +/// Longest unreadable number token retried from its next byte. Real numbers are far shorter. +const MAX_RETRIED_TOKEN: usize = 64; + /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { parse_pieces(data, &[]) @@ -171,9 +174,19 @@ impl Iterator for Ops<'_> { lx.pos = data.len(); } Err(_) => { - // Skip the offending byte and carry on. - lx.pos = at + 1; - *skipped += 1; + let read = lx.pos.saturating_sub(at); + #[cfg(test)] + tests::RESCANNED.with(|c| c.set(c.get() + read)); + let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit(); + if number && read > MAX_RETRIED_TOKEN { + // A long unreadable number is skipped whole: retrying from each of its + // bytes would read the rest of it again every time. + *skipped += read; + } else { + // Skip the offending byte and carry on. + lx.pos = at + 1; + *skipped += 1; + } } } continue; @@ -623,9 +636,20 @@ impl Pieces { /// for byte. Returns every piece's new bytes (unchanged pieces come back equal). pub fn splice(&self, edits: impl IntoIterator, Vec)>) -> Vec> { let mut out: Vec> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect(); + // Only the pieces from the one holding `from` up to `to`: one call per edit must not + // visit every piece. let copy = |out: &mut Vec>, from: usize, to: usize| { - for (i, piece) in out.iter_mut().enumerate() { + if from >= to { + return; + } + let first = self.piece_of(from); + for (i, piece) in out.iter_mut().enumerate().skip(first) { + #[cfg(test)] + tests::PIECE_VISITS.with(|c| c.set(c.get() + 1)); let r = self.range(i); + if r.start >= to { + break; + } let (a, b) = (from.max(r.start), to.min(r.end)); if a < b { piece.extend_from_slice(self.data.get(a..b).unwrap_or_default()); diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index f18aeaaece..c5c7a3aaac 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -1,5 +1,51 @@ use super::*; +thread_local! { + /// Bytes the lexer read before an operand failed, on this thread. + pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +thread_local! { + /// Pieces [`Pieces::splice`] looked at while copying, on this thread. + pub(crate) static PIECE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn splicing_visits_each_piece_a_few_times() { + let n = 5_000; + let pieces: Vec = (0..n).map(|i| format!("({i}) Tj")).collect(); + let joined = Pieces::join(&pieces); + let parsed = joined.parse(); + let edits: Vec<_> = parsed.ops.iter().map(|op| (op.span.clone(), b"(x) Tj".to_vec())).collect(); + assert_eq!(edits.len(), n); + PIECE_VISITS.with(|c| c.set(0)); + let out = joined.splice(edits); + let visits = PIECE_VISITS.with(|c| c.get()); + assert!(visits <= 4 * n, "{visits} piece visits for {n} pieces"); + assert!(out.iter().all(|p| p == b"(x) Tj")); + // A replacement spanning two pieces stays whole in the first and leaves the rest of the second. + let two = Pieces::join(&["q (a)", "Tj Q"]); + let tj = two.parse().ops.into_iter().find(|o| o.is("Tj")).map(|o| o.span).unwrap_or_default(); + assert_eq!(two.splice([(tj, b"(b) Tj".to_vec())]), [b"q (b) Tj".to_vec(), b" Q".to_vec()]); +} + +#[test] +fn a_long_unreadable_number_is_read_once() { + for junk in ["+", "5"] { + let n = 20_000; + let src = format!("{}{} 1 0 0 1 0 0 cm", junk.repeat(n), if junk == "5" { "x" } else { "" }); + RESCANNED.with(|c| c.set(0)); + let p = parse(src.as_bytes()); + let rescanned = RESCANNED.with(|c| c.get()); + assert!(rescanned <= 2 * n, "{rescanned} bytes read for a {n}-byte token"); + assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + } + // A short damaged number still recovers byte by byte. + let p = parse(b"-+5 0 Td"); + assert_eq!((p.ops.len(), p.skipped), (1, 1)); + assert_eq!(p.ops[0].nums::<2>(), Some([5.0, 0.0])); +} + #[test] fn operators_keep_operands_and_spans() { let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q"; diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs new file mode 100644 index 0000000000..1ddd42eaf5 --- /dev/null +++ b/crates/cos/src/budget.rs @@ -0,0 +1,220 @@ +//! A shared allowance for operations that decode many streams. + +use std::collections::HashSet; + +use pdfcraft_filters::FilterError; + +use crate::{CosError, Document, ObjRef, Object, Stream}; + +/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every +/// form XObject it enters, each occurrence counted again. +pub const MAX_PAGE_DECODED: usize = 256 << 20; +/// Form XObjects one page's content walk may enter, each entry counted again. +pub const MAX_PAGE_FORMS: usize = 50_000; + +/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or +/// the error, each with that count. +type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usize)>; + +/// What one operation may still decode and enter across all the streams it reads. +/// +/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose +/// `/Contents` names the same compressed stream thousands of times, or a form drawn thousands of +/// times from other forms: each occurrence would get a fresh allowance. A `DecodeBudget` is +/// shared by every decode of one operation, so the total decoded bytes and form entries stay +/// under fixed limits however the file repeats its references. A decode that runs past the +/// remaining allowance spends all of it, so a bomb referenced many times is inflated once. +#[derive(Debug)] +pub struct DecodeBudget { + /// The whole allowance, for errors. + limit: usize, + bytes: usize, + forms: usize, + seen: HashSet, +} + +impl DecodeBudget { + /// An allowance of `bytes` decoded bytes and `forms` form entries. + pub fn new(bytes: usize, forms: usize) -> Self { + Self { limit: bytes, bytes, forms, seen: HashSet::new() } + } + + /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. + pub fn page() -> Self { + Self::new(MAX_PAGE_DECODED, MAX_PAGE_FORMS) + } + + /// Decoded bytes still allowed. + pub fn remaining(&self) -> usize { + self.bytes + } + + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. + pub fn exhausted(&self) -> bool { + self.bytes == 0 || self.forms == 0 + } + + /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without + /// decoding, once the allowance is spent. + pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, usize::MAX, Stream::decoded_within_counted) + } + + /// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding + /// past `max` bytes is an error that charges `max`, the work it took to find out. + pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result, CosError> { + self.charge(stream, max, Stream::decoded_within_counted) + } + + /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to + /// the allowance. + pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, usize::MAX, Stream::decoded_strict_within_counted) + } + + /// Decodes `stream` within `max` and the allowance, charging what every filter stage + /// decoded: a stage's output counts even when a later stage shrinks it, and a corrupt stream + /// pays for the bytes decoded before the error, so referencing a stream again and again + /// spends the allowance by the work each decode does. + fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result, CosError> { + if self.bytes == 0 && !stream.raw.is_empty() { + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); + } + if max < self.bytes { + let result = decode(stream, max); + let spent = match &result { + Ok((_, spent)) => *spent, + Err((FilterError::LimitExceeded(_), _)) => max, + Err((_, spent)) => *spent, + }; + self.bytes = self.bytes.saturating_sub(spent); + return result.map(|(data, _)| data).map_err(|(e, _)| CosError::Filter(e.to_string())); + } + match decode(stream, self.bytes) { + Ok((data, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Ok(data) + } + Err((FilterError::LimitExceeded(_), _)) => { + self.bytes = 0; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) + } + Err((e, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Err(CosError::Filter(e.to_string())) + } + } + } + + /// Counts one form entry. `false` once [`DecodeBudget::new`]'s `forms` entries are spent: + /// the caller leaves that form out. + pub fn enter_form(&mut self) -> bool { + match self.forms.checked_sub(1) { + Some(left) => { + self.forms = left; + true + } + None => false, + } + } + + /// `true` the first time `r` is passed to this budget, for callers that need each + /// referenced stream only once. + pub fn first_visit(&mut self, r: ObjRef) -> bool { + self.seen.insert(r) + } + + /// Each stream `contents` names, decoded against the allowance, in order. `contents` is a + /// page's `/Contents`: one stream, or an array whose non-stream entries are left out. + /// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its + /// place, so the caller chooses between skipping it and giving up. + pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec, CosError>> { + match &*doc.resolve(contents) { + Object::Stream(s) => vec![self.decode(s)], + Object::Array(list) => list + .iter() + .filter_map(|entry| match &*doc.resolve(entry) { + Object::Stream(s) => Some(self.decode(s)), + _ => None, + }) + .collect(), + _ => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dict; + + #[test] + fn repeated_references_share_one_allowance() { + let mut doc = Document::new_empty(); + let bomb = doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]))); + let contents = Object::Array(vec![Object::Ref(bomb); 64]); + let mut budget = DecodeBudget::new(10 << 20, 1); + let decoded = budget.decode_contents(&doc, &contents); + assert_eq!(decoded.len(), 64); + assert_eq!(decoded.iter().filter(|d| d.is_ok()).count(), 10); + let total: usize = decoded.iter().flatten().map(Vec::len).sum(); + assert_eq!(total, 10 << 20); + assert!(budget.exhausted()); + // A decode past the allowance spends it, so a bomb is inflated at most once more. + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err)); + assert_eq!(budget.remaining(), 0); + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") }; + assert!(budget.decode_strict(stream).is_err()); + assert_eq!(budget.remaining(), 0); + + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_within(stream, 1000).is_err()); + assert_eq!(budget.remaining(), (10 << 20) - 1000); + assert_eq!(budget.decode_within(stream, 2 << 20).map(|d| d.len()).ok(), Some(1 << 20)); + assert_eq!(budget.remaining(), (9 << 20) - 1000); + + assert!(budget.enter_form()); + assert!(!budget.enter_form()); + assert!(budget.first_visit(bomb)); + assert!(!budget.first_visit(bomb)); + } + + /// A corrupt stream fails a strict decode after decoding part of it: each reference pays + /// for that part, so references to it can't decode without limit. + #[test] + fn failed_strict_decodes_charge_what_they_decoded() { + let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]); + let cut = whole.raw.len() / 2; + let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec()); + let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0); + assert!(partial > 0, "a cut flate stream decodes in part"); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_strict(&corrupt).is_err()); + assert_eq!(budget.remaining(), (10 << 20) - partial); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_within(&corrupt, 1 << 30).is_ok()); + assert_eq!(budget.remaining(), (10 << 20) - partial); + } + + /// A chain whose later stage shrinks the data pays for every stage's output: a Flate stage + /// that inflates to hex text, which ASCIIHex then halves, charges both. + #[test] + fn decodes_charge_every_stage_of_a_chain() { + let hex: Vec = std::iter::repeat_n(b"ab".as_slice(), 1 << 19).flatten().copied().collect(); + let flate = Stream::flate(Dict::new(), &hex); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let chained = Stream::from_raw(dict, flate.raw.to_vec()); + assert_eq!(chained.decoded().map(|d| d.len()).ok(), Some(1 << 19)); + let charged = (1 << 20) + (1 << 19); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert_eq!(budget.decode(&chained).map(|d| d.len()).ok(), Some(1 << 19)); + assert_eq!(budget.remaining(), (10 << 20) - charged); + assert!(budget.decode_within(&chained, 2 << 20).is_ok()); + assert_eq!(budget.remaining(), (10 << 20) - 2 * charged); + assert!(budget.decode_strict(&chained).is_ok()); + assert_eq!(budget.remaining(), (10 << 20) - 3 * charged); + } +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index c3f6c6b748..892c8e04de 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, Weak}; use crate::CosError; use crate::object::{Dict, ObjRef, Object}; -use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; +use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). @@ -405,6 +405,8 @@ impl std::fmt::Debug for ReadState { #[derive(Clone)] pub struct Document { data: Arc>, + /// Where `endstream` occurs in `data`; see [`EndstreamIndex`]. + endstreams: Arc, source_identity: Arc<()>, entries: Arc, trailer: Dict, @@ -494,12 +496,22 @@ impl ObjectReader { } } +#[cfg(test)] +thread_local! { + /// Cross-reference sections read on this thread. + static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// is measured in full. +const OWNED_BYTES_DEPTH: usize = 256; + /// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the -/// reader's object count). +/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured +/// and counts as too large to keep. fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { - if depth > 4 { - return 0; + if depth > OWNED_BYTES_DEPTH { + return usize::MAX; } match object { Object::String(s) => s.bytes.len(), @@ -532,6 +544,7 @@ impl Document { pub fn new_empty() -> Self { let mut doc = Document { data: Arc::new(Vec::new()), + endstreams: Arc::default(), source_identity: Arc::new(()), entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), @@ -614,6 +627,7 @@ impl Document { }; let mut doc = Document { data, + endstreams: Arc::default(), source_identity: Arc::new(()), entries: Arc::new(XrefIndex::empty()), trailer: Dict::new(), @@ -978,7 +992,7 @@ impl Document { match self.xref_entry(num) { None | Some(XrefEntry::Free { .. }) => true, Some(XrefEntry::InFile { offset, .. }) => { - let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); + let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num); // Offsets may count from a header that does not start the file. usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) } @@ -1176,7 +1190,7 @@ impl Document { length }; let off = offset as usize; - let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) { + let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), _ => None, }; @@ -1242,7 +1256,7 @@ impl Document { if p > 0 && !is_whitespace(data[p - 1]) { continue; } - if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None) + if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams) && id.num == num { found = Some((id, o)); // keep the last (newest) definition @@ -1740,6 +1754,12 @@ impl Document { let mut revisions = Vec::new(); let mut oddities = Vec::new(); let mut visited = HashSet::new(); + // Hybrid streams already read, by offset: their entries, or `None` when unreadable. A newer + // section has merged every entry of one into `entries`, so an older section that names it + // again only has its own table compared with them, without decoding the stream again. + // At most MAX_XREF_OBJECTS entries are kept in all; a stream past that is read again. + let mut hybrids: HashMap>> = HashMap::new(); + let (mut hybrid_rows, mut noted) = (0usize, HashSet::new()); let mut next = Some(first); while let Some(off) = next.take() { if !visited.insert(off) || visited.len() > 10_000 { @@ -1770,11 +1790,24 @@ impl Document { revisions.push(Revision { xref_offset: off, is_stream, header_relative }); // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. - if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { + let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0); + if let Some((x, read)) = hybrid.and_then(|x| hybrids.get(&x).map(|read| (x, read))) { + if noted.insert(x) { + log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once")); + } + if let Some(stm) = read { + for (num, old) in § { + // The table places the object somewhere other than the stream does. + if !matches!(old, XrefEntry::Free { .. }) && stm.get(num).is_some_and(|e| e != old) { + odd.push(*num); + } + } + } + } else if let Some(x) = hybrid { let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm, &mut odd, log) { + let read = match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { - for (num, e) in stm { + for (&num, &e) in &stm { match sect.get(&num) { None | Some(XrefEntry::Free { .. }) => { sect.insert(num, e); @@ -1784,8 +1817,17 @@ impl Document { Some(_) => {} } } + Some(stm) } - Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")), + Err(e) => { + log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")); + None + } + }; + let rows = read.as_ref().map_or(0, BTreeMap::len); + if let Some(total) = hybrid_rows.checked_add(rows).filter(|total| *total <= MAX_XREF_OBJECTS) { + hybrid_rows = total; + hybrids.insert(x, read); } } oddities.extend(odd.into_iter().map(|num| (off, num))); @@ -1814,6 +1856,8 @@ impl Document { odd: &mut Vec, log: &mut Vec, ) -> Result<(Dict, bool), CosError> { + #[cfg(test)] + SECTION_READS.with(|c| c.set(c.get() + 1)); let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -2051,7 +2095,7 @@ impl Document { if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) { continue; } - if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None) + if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None, &self.endstreams) && s.dict.name(b"Type") == Some(b"XRef") && let Some(r) = rank(at, &s.dict) && trailer.as_ref().is_none_or(|(best, _)| r > *best) @@ -2993,6 +3037,56 @@ endobj } } + #[test] + fn streams_without_length_share_one_endstream_search() { + let n = 2_000; + let mut file = b"%PDF-1.7\n".to_vec(); + for k in 1..=n { + file.extend_from_slice(format!("{k} 0 obj\n<< >>\nstream\nxx\n").as_bytes()); + } + file.extend_from_slice(b"endstream\nendobj\n"); + file.extend_from_slice( + format!( + "{0} 0 obj\n<< /Type /Catalog /Pages {1} 0 R >>\nendobj\n{1} 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n", + n + 1, + n + 2 + ) + .as_bytes(), + ); + file.extend_from_slice(format!("trailer\n<< /Root {} 0 R /Size {} >>\n%%EOF\n", n + 1, n + 3).as_bytes()); + let len = file.len(); + crate::parser::ENDSTREAM_SCAN.with(|c| c.set(0)); + let doc = Document::open(Arc::new(file)).unwrap(); + for k in [1, n / 2, n] { + assert!(matches!(doc.try_get(k).as_deref(), Ok(Object::Stream(_))), "object {k}"); + } + let scanned = crate::parser::ENDSTREAM_SCAN.with(|c| c.get()); + assert!(scanned <= 4 * len, "{scanned} bytes searched in a {len}-byte file"); + } + + #[test] + fn editor_cache_counts_deeply_nested_strings() { + let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20])); + for _ in 0..8 { + nested = Object::Array(vec![nested]); + } + let mut source = Document::new_empty(); + let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect(); + source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); + let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); + let doc = Document::open(bytes).unwrap(); + assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let root = doc.get(doc.root().unwrap()); + for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { + let Object::Ref(reference) = reference else { panic!("indirect object") }; + drop(doc.get(*reference)); + let cache = doc.cache.lock().unwrap(); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let strings = cache.values().filter(|o| o.as_array().is_some()).count(); + assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); + } + } + #[test] fn editor_cache_stays_bounded() { let mut source = Document::new_empty(); @@ -3141,6 +3235,55 @@ endobj out } + /// Many revisions naming one hybrid /XRefStm read it once. + #[test] + fn a_hybrid_stream_named_by_many_revisions_is_read_once() { + let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap(); + let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1; + let revisions = 200; + for _ in 0..revisions { + let at = out.len(); + out.extend_from_slice( + format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + prev = at; + } + let doc = Document::open(Arc::new(out)).unwrap(); + SECTION_READS.with(|c| c.set(0)); + let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + let reads = SECTION_READS.with(|c| c.get()); + assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1); + assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3)); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + assert_compact_matches_materialized(&doc); + } + + /// An older section that names an /XRefStm a newer section already read still has its own + /// table compared with that stream, so an entry it sets aside is recorded for it. + #[test] + fn a_repeated_hybrid_stream_records_each_sections_conflicts() { + let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap(); + let catalog = find(&out, b"1 0 obj", 0, out.len()).unwrap(); + let base = rfind(&out, b"\nxref\n", 0).unwrap() + 1; + // This section's table places object 3 at the catalog; the stream puts it in object stream 4. + let older = out.len(); + out.extend_from_slice( + format!( + "xref\n3 1\n{catalog:010} 00000 n \ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {base} >>\nstartxref\n{older}\n%%EOF\n" + ) + .as_bytes(), + ); + let newer = out.len(); + out.extend_from_slice( + format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {older} >>\nstartxref\n{newer}\n%%EOF\n").as_bytes(), + ); + let doc = Document::open(Arc::new(out)).unwrap(); + assert_eq!(doc.xref_oddities(), [(older as u64, 3)]); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + } + fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option { doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text()))) } diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 152daed2de..f3be66b9a1 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -13,6 +13,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod budget; mod bytes; mod document; mod object; @@ -21,6 +22,7 @@ mod parser; mod security; mod writer; +pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 438b8d5f35..c9b2ff9a36 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -76,10 +76,37 @@ impl PdfString { /// A name object (without the leading `/`, with `#xx` escapes already decoded). pub type Name = Vec; +#[cfg(test)] +thread_local! { + /// Key comparisons and index lookups made by [`Dict`] on this thread. + static KEY_COMPARES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Entries from which a [`Dict`] keeps a key index. Smaller dictionaries (nearly all of them) +/// search their entries; a parsed file controls the key count, and a linear search per +/// inserted key would make a dictionary of many distinct keys quadratic to build. +const INDEX_FROM: usize = 32; + /// An ordered dictionary. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Default)] pub struct Dict { entries: Vec<(Name, Object)>, + /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries. + /// Boxed so that every `Dict`, and so every `Object`, grows by one pointer, not by a map. + #[allow(clippy::box_collection)] + index: Option>>, +} + +impl PartialEq for Dict { + fn eq(&self, other: &Self) -> bool { + self.entries == other.entries + } +} + +impl std::fmt::Debug for Dict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Dict").field("entries", &self.entries).finish() + } } impl Dict { @@ -87,26 +114,53 @@ impl Dict { Self::default() } + fn position(&self, key: &[u8]) -> Option { + if let Some(index) = &self.index { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + return index.get(key).copied(); + } + self.entries.iter().position(|(k, _)| { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + k == key + }) + } + pub fn get(&self, key: &[u8]) -> Option<&Object> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) + self.position(key).and_then(|i| self.entries.get(i)).map(|(_, v)| v) } pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> { - self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v) + let i = self.position(key)?; + self.entries.get_mut(i).map(|(_, v)| v) } /// Insert or replace, keeping the position of an existing key. pub fn set(&mut self, key: impl Into, value: impl Into) { let key = key.into(); let value = value.into(); - match self.entries.iter_mut().find(|(k, _)| *k == key) { + match self.position(&key).and_then(|i| self.entries.get_mut(i)) { Some((_, v)) => *v = value, - None => self.entries.push((key, value)), + None => { + let at = self.entries.len(); + if let Some(index) = &mut self.index { + index.insert(key.clone(), at); + } else if at + 1 >= INDEX_FROM { + let index = self.entries.iter().enumerate().map(|(i, (k, _))| (k.clone(), i)).chain([(key.clone(), at)]).collect(); + self.index = Some(Box::new(index)); + } + self.entries.push((key, value)); + } } } pub fn remove(&mut self, key: &[u8]) -> Option { - let i = self.entries.iter().position(|(k, _)| k == key)?; + let i = self.position(key)?; + if let Some(index) = &mut self.index { + index.remove(key); + index.values_mut().filter(|at| **at > i).for_each(|at| *at -= 1); + } Some(self.entries.remove(i).1) } @@ -204,6 +258,21 @@ impl Stream { .collect() } + /// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an + /// array of names (an indirect reference, say): the data is not unfiltered, so it must not + /// read as decoded. + fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> { + let readable = match self.dict.get(b"Filter") { + None | Some(Object::Null | Object::Name(_)) => true, + Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()), + Some(_) => false, + }; + if !readable { + return Err(pdfcraft_filters::FilterError::Unsupported("/Filter is not a name or an array of names".into())); + } + Ok(self.filters()) + } + /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data /// decoded before the damage is returned). Streams with image codecs fail with /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage. @@ -214,26 +283,60 @@ impl Stream { /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than /// [`MAX_DECODED`]: decoding past `max` bytes is an error. pub fn decoded_within(&self, max: usize) -> Result, CosError> { - let chain = self.filters(); + let decode = |chain: &[(Filter, Params)], data: &[u8], max| { + pdfcraft_filters::decode_tolerant(chain, data, max).map(|(v, _)| (v, 0)).map_err(|e| (e, 0)) + }; + self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_within`] for a caller charging an allowance: the filter error is kept + /// typed, so a caller can tell a spent limit from corrupt data, `max` bounds all filter + /// stages together, and the result gives the bytes they decoded (the work done, on success + /// and on failure). + pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> { + let decode = |chain: &[(Filter, Params)], data: &[u8], max| { + pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, _, spent)| (v, spent)) + }; + self.decode_chain(max, decode) + } + + /// The stream decoded through its filters by `decode`, or copied when it has none, within + /// `max` and [`MAX_DECODED`]. + fn decode_chain( + &self, + max: usize, + decode: impl FnOnce(&[(Filter, Params)], &[u8], usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)>, + ) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> { + let max = max.min(MAX_DECODED); + let chain = self.readable_filters().map_err(|e| (e, 0))?; if chain.is_empty() { - return self.raw_within(max); + return self + .raw_within_typed(max) + .map(|v| { + let n = v.len(); + (v, n) + }) + .map_err(|e| (e, 0)); } - pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string())) + decode(&chain, &self.raw, max) } /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { - let chain = self.filters(); - if chain.is_empty() { - return self.raw_within(MAX_DECODED); - } - pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) + let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0)); + self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_strict`] bounded by `max` and counted like + /// [`Stream::decoded_within_counted`]. + pub(crate) fn decoded_strict_within_counted(&self, max: usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> { + self.decode_chain(max, pdfcraft_filters::decode_counted) } /// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded. /// Any corruption in that first filter is an error. pub fn decoded_first_filter_strict(&self, max: usize) -> Result, CosError> { - let mut chain = self.filters(); + let mut chain = self.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?; chain.truncate(1); if chain.is_empty() { return self.raw_within(max); @@ -242,11 +345,15 @@ impl Stream { } fn raw_within(&self, max: usize) -> Result, CosError> { + self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string())) + } + + fn raw_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { let max = max.min(MAX_DECODED); // An empty decoder chain still produces an owned output buffer: check its limit // before cloning, including streams whose /Crypt filter was applied on load. if self.raw.len() > max { - return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string())); + return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); } Ok(self.raw.to_vec()) } @@ -382,6 +489,53 @@ impl From for Object { mod tests { use super::*; + #[test] + fn a_filter_that_is_not_a_direct_name_is_not_decoded_as_absent() { + let stream = |filter: Object| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), filter); + Stream::from_raw(d, b"48 69>".to_vec()) + }; + let hex = Object::Name(b"ASCIIHexDecode".to_vec()); + assert_eq!(stream(hex.clone()).decoded_strict().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Array(vec![hex.clone()])).decoded().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Null).decoded_strict().ok(), Some(b"48 69>".to_vec())); + for filter in [ + Object::Ref(ObjRef::new(5, 0)), + Object::Array(vec![Object::Ref(ObjRef::new(5, 0))]), + Object::Int(3), + Object::Array(vec![hex, Object::Int(1)]), + ] { + let s = stream(filter.clone()); + assert!(s.decoded_strict().is_err(), "{filter:?}"); + assert!(s.decoded().is_err(), "{filter:?}"); + assert!(s.decoded_first_filter_strict(1 << 20).is_err(), "{filter:?}"); + } + } + + #[test] + fn parsing_many_distinct_keys_is_not_quadratic() { + let n = 20_000; + let body: String = (0..n).map(|i| format!("/K{i} {i} ")).collect(); + let text = format!("<< {body}/K7 true >>"); + KEY_COMPARES.with(|c| c.set(0)); + let Ok(Object::Dict(d)) = crate::parser::Lexer::new(text.as_bytes(), 0).object() else { panic!("not a dict") }; + let compares = KEY_COMPARES.with(|c| c.get()); + assert!(compares < 8 * n, "{compares} key comparisons for {n} keys"); + // Order and replace-in-place semantics stay those of a plain ordered dictionary. + assert_eq!(d.len(), n); + assert_eq!(d.iter().next().map(|(k, _)| k.as_slice()), Some(&b"K0"[..])); + assert_eq!(d.get(b"K7"), Some(&Object::Bool(true))); + assert_eq!(d.iter().nth(7).map(|(_, v)| v), Some(&Object::Bool(true))); + let mut d = d; + assert_eq!(d.remove(b"K3"), Some(Object::Int(3))); + assert_eq!(d.get(b"K4"), Some(&Object::Int(4))); + assert_eq!(d.iter().nth(3).map(|(k, _)| k.as_slice()), Some(&b"K4"[..])); + d.set(b"K3".to_vec(), Object::Null); + assert_eq!(d.iter().last().map(|(k, _)| k.as_slice()), Some(&b"K3"[..])); + assert!(d.get(b"K20000").is_none()); + } + #[test] fn text_strings_round_trip() { for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] { diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 0458055961..1e9b45ad61 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; } @@ -324,18 +342,8 @@ impl<'a> Lexer<'a> { if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); - let mut end = cleaned.len(); - while end > 0 { - // Only cut at character boundaries: a token can hold multi-byte UTF-8 (fuzzing - // found ".—", which panicked here). - if cleaned.is_char_boundary(end) - && let Ok(v) = cleaned[..end].parse::() - { - return Ok(Object::Real(v)); - } - end -= 1; - } - return Ok(Object::Real(0.0)); + let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); + return Ok(Object::Real(v.unwrap_or(0.0))); } // Tolerate a doubled sign ("--280") like the real branch does. let t = s.trim_start_matches('+'); @@ -350,6 +358,46 @@ impl<'a> Lexer<'a> { } } +/// Length of the longest prefix of `s` that reads as a decimal number: a sign, digits with at +/// most one `.`, and an exponent with digits. Zero when that prefix has no digit. Found in one +/// pass so a long damaged token is converted once, not once per shorter prefix. +fn real_prefix_len(s: &[u8]) -> usize { + let digits = |from: usize| s.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count()); + let mut at = usize::from(matches!(s.first(), Some(b'+' | b'-'))); + let int = digits(at); + at += int; + let mut frac = 0; + if s.get(at) == Some(&b'.') { + frac = digits(at + 1); + at += 1 + frac; + } + if int + frac == 0 { + return 0; + } + if matches!(s.get(at), Some(b'e' | b'E')) { + let sign = usize::from(matches!(s.get(at + 1), Some(b'+' | b'-'))); + let exp = digits(at + 1 + sign); + if exp > 0 { + at += 1 + sign + exp; + } + } + at +} + +#[cfg(test)] +thread_local! { + /// Bytes searched for `endstream` on this thread. + pub(crate) static ENDSTREAM_SCAN: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Bytes handed to number conversion on this thread. + static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn parse_counted(s: &str) -> Result { + #[cfg(test)] + NUMBER_BYTES.with(|c| c.set(c.get() + s.len())); + s.parse() +} + fn hexval(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), @@ -362,17 +410,30 @@ fn hexval(b: u8) -> Option { /// Parse an indirect object at `offset`: `N G obj [stream…endstream] endobj`. /// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen). pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, None, offset, resolve_length) + parse_indirect_in(data, None, offset, resolve_length, None) } /// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of /// being copied out of it. +/// `endstreams` is the document's [`EndstreamIndex`] for `data`. pub(crate) fn parse_indirect_shared( data: &Arc>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: &EndstreamIndex, ) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, Some(data), offset, resolve_length) + parse_indirect_in(data, Some(data), offset, resolve_length, Some(endstreams)) +} + +/// Where `endstream` occurs in a document's bytes, found once, the first time a stream's +/// /Length is wrong or missing. Searching the rest of the file for each such stream instead +/// makes many of them ending at one late `endstream` quadratic to read. +pub(crate) type EndstreamIndex = std::sync::OnceLock>; + +fn endstream_offsets(data: &[u8]) -> Vec { + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len())); + data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect() } fn parse_indirect_in( @@ -380,6 +441,7 @@ fn parse_indirect_in( shared: Option<&Arc>>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: Option<&EndstreamIndex>, ) -> Result<(ObjRef, Object), CosError> { let mut lx = Lexer::new(data, offset); lx.skip_ws(); @@ -439,7 +501,11 @@ fn parse_indirect_in( let len = match declared { Some(l) if valid(l) => l as usize, // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). - _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start, + _ => { + find_endstream(data, start, endstreams) + .ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? + - start + } }; let range = start..start + len; let raw = match shared { @@ -455,9 +521,20 @@ fn parse_indirect_in( } /// Offset where stream data ends (before the EOL preceding "endstream"). -fn find_endstream(data: &[u8], from: usize) -> Option { - let hay = data.get(from..)?; - let at = hay.windows(9).position(|w| w == b"endstream")? + from; +fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option { + let at = match endstreams { + Some(index) => { + let index = index.get_or_init(|| endstream_offsets(data)); + index.get(index.partition_point(|&p| p < from)).copied()? + } + None => { + let hay = data.get(from..)?; + let found = hay.windows(9).position(|w| w == b"endstream"); + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9))); + found? + from + } + }; let mut end = at; if end > from && data[end - 1] == b'\n' { end -= 1; @@ -483,6 +560,22 @@ mod tests { assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5)); } + #[test] + fn a_damaged_real_is_converted_once() { + let token = format!("1.{}{}", "5".repeat(2_000), "x".repeat(20_000)); + NUMBER_BYTES.with(|c| c.set(0)); + let Ok(Object::Real(v)) = Lexer::new(token.as_bytes(), 0).object() else { panic!("not a real") }; + let converted = NUMBER_BYTES.with(|c| c.get()); + assert!(converted <= token.len(), "{converted} bytes converted for a {}-byte token", token.len()); + assert!((v - 1.5555).abs() < 1e-3); + // The longest numeric prefix wins, as before. + for (t, want) in + [("1.2.3", 1.2), ("--5.", -5.0), ("+.5x", 0.5), ("5.-", 5.0), ("1.5e3", 1500.0), ("1.5e+", 1.5), ("-.x", 0.0), (".", 0.0), ("-inf.", 0.0)] + { + assert_eq!(Lexer::new(t.as_bytes(), 0).object().ok(), Some(Object::Real(want)), "{t}"); + } + } + fn obj(s: &str) -> Object { Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}")) } @@ -598,4 +691,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()); + } } diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index bd5ab5271c..115876d9c4 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); @@ -400,7 +403,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 a214977675..2007932f74 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(); diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 82b598688a..58e96e7d4f 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -138,9 +138,51 @@ pub struct SecurityHandler { key: Vec, auth: Auth, recovered_user: Option>, + methods: Methods, +} + +/// The crypt filter methods of a handler, resolved once: a document controls both the number of +/// filters and the number of strings, so a search of `/CF` per string would multiply them. +#[derive(Clone, Debug)] +struct Methods { + string: Method, + stream: Method, + embedded_file: Method, + named: std::collections::HashMap, Method>, +} + +impl Methods { + fn new(dict: &EncryptDict) -> Self { + let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len()); + for (name, method) in &dict.crypt_filters { + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + // The first of two filters with one name wins, as a search would find it. + named.entry(name.clone()).or_insert(*method); + } + let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named }; + methods.string = methods.get(&dict.str_f); + methods.stream = methods.get(&dict.stm_f); + methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f }); + methods + } + + fn get(&self, name: &[u8]) -> Method { + if name == b"Identity" || name.is_empty() { + return Method::Identity; + } + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + self.named.get(name).copied().unwrap_or(Method::Identity) + } } impl SecurityHandler { + fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self { + let methods = Methods::new(&dict); + Self { dict, key, auth, recovered_user, methods } + } + /// Authenticate with `password` (tried as owner password, then user password). `None` /// tries the empty password, which opens documents that only have an owner password. pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result { @@ -177,7 +219,7 @@ impl SecurityHandler { } r => return Err(CryptError::Unsupported(format!("revision {r}"))), }; - Ok(Self { dict, key, auth, recovered_user }) + Ok(Self::new(dict, key, auth, recovered_user)) } /// Upgrade to owner access with the owner (permissions) password, as when a restricted @@ -235,26 +277,16 @@ impl SecurityHandler { self.dict.encrypt_metadata } - fn method_named(&self, name: &[u8]) -> Method { - if name == b"Identity" || name.is_empty() { - return Method::Identity; - } - self.dict.crypt_filters.iter().find(|(n, _)| n == name).map(|(_, m)| *m).unwrap_or(Method::Identity) - } - /// The method for strings or a kind of stream. fn method(&self, stream: Option) -> Method { if self.dict.v < 4 { return Method::Rc4; } match stream { - None => self.method_named(&self.dict.str_f), - Some(StreamKind::Normal) => self.method_named(&self.dict.stm_f), - Some(StreamKind::EmbeddedFile) => { - let eff = if self.dict.ef_f.is_empty() { &self.dict.stm_f } else { &self.dict.ef_f }; - self.method_named(eff) - } - Some(StreamKind::Named(n)) => self.method_named(n), + None => self.methods.string, + Some(StreamKind::Normal) => self.methods.stream, + Some(StreamKind::EmbeddedFile) => self.methods.embedded_file, + Some(StreamKind::Named(n)) => self.methods.get(n), } } @@ -642,7 +674,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { (d.v, d.r, d.length_bits) = (5, 6, 256); @@ -671,7 +703,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result = const { std::cell::Cell::new(0) }; +} + +#[test] +fn crypt_filters_are_looked_up_once_per_handler() { + let plain = new(Algorithm::Aes128, "", "o", -1); + let mut d = plain.dict().clone(); + let n = 5_000; + let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect(); + filters.append(&mut d.crypt_filters); + d.crypt_filters = filters; + FILTER_COMPARES.with(|c| c.set(0)); + let h = SecurityHandler::open(d, ID, None).unwrap(); + let strings = 1_000; + let enc = plain.encrypt_string(7, 0, b"secret"); + for _ in 0..strings { + assert_eq!(h.decrypt_string(7, 0, &enc), b"secret"); + assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s"); + } + assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x"); + let compares = FILTER_COMPARES.with(|c| c.get()); + assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons"); +} + #[test] fn identity_crypt_filters_leave_data_alone() { let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone(); diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index d1d50e4502..7d745c0c10 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let crop = p.crop(doc); let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); - let streams = crate::text::content_streams(doc, &p.dict); + let streams = crate::text::content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 75bb12f47d..6f14271be4 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -9,6 +9,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; +use crate::text::content_streams; /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] @@ -73,23 +74,12 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() +/// Image draws one page's inventory lists at most: a form drawn many times from other forms +/// would otherwise multiply a few hundred bytes into millions of records. +pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; + +fn too_many_images() -> EditError { + EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images")) } /// The page's streams joined into the one stream they are: an operator may span two of them. @@ -237,7 +227,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&content_streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -285,6 +275,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect, visible_rect: intersection(rect, state.placement.clip), @@ -358,6 +351,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi if s.dict.name(b"Subtype") != Some(b"Image") { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip), @@ -381,8 +377,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; - let pieces = joined(&streams(doc, &p.dict)); + let mut decode = pdfcraft_cos::DecodeBudget::page(); + let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?); + let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) }; walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -483,7 +480,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = content_streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -531,7 +528,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = content_streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -755,7 +752,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } let images = page_images(doc, page)?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = content_streams(doc, &p.dict)?; let mut plans = std::collections::BTreeMap::new(); for index in indexes { let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cf3860237b..1395fb1c35 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6355,3 +6355,96 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ] ); } + +/// A one-page document from the page's /Contents and /Resources, built in code. +fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.add(Object::Null); + let (contents, resources) = build(&mut doc); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), contents), + (b"Resources".to_vec(), Object::Dict(resources)), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + // One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents. + let doc = page_with(|doc| { + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + (Object::Array(vec![Object::Ref(spaces); 300]), Dict::new()) + }); + match text_lines(&doc, 0) { + Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()), + Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"), + } +} + +#[test] +fn reading_images_stops_at_the_page_record_limit() { + // 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images + // per level-two form, 160,000 in all, from a few hundred bytes. + let doc = page_with(|doc| { + let image: Dict = [ + (b"Subtype".to_vec(), Object::Name(b"Image".to_vec())), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ] + .into_iter() + .collect(); + let mut inner = doc.add(Stream::from_raw(image, vec![0])); + let draws = "/X Do ".repeat(20); + let resources = |inner: pdfcraft_cos::ObjRef| -> Dict { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() + }; + for _ in 0..3 { + let mut d = resources(inner); + d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect(); + inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes())); + } + let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes())); + (Object::Ref(page), resources(inner)) + }); + if let Ok(images) = crate::reading_images(&doc, 0) { + assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); + } +} + +#[test] +fn reading_spends_one_decode_budget_on_content_and_forms() { + // 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget, + // together they don't, so reading leaves the form out and says so. + let doc = page_with(|doc| { + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form, &body)); + let mut content = b"/X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Stream::flate(Dict::new(), &content)); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + (Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()) + }); + assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99e..12ed6a2075 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -113,10 +113,12 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result budget { continue; } @@ -615,7 +617,16 @@ struct Ts { unreadable: Option>, } -pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +/// The page's content streams as listed, each with its decoded data (empty when it doesn't +/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading +/// or rewriting a page from part of its content would lose the rest. +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + content_streams_within(doc, page, &mut DecodeBudget::page()) +} + +/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on +/// spending. +pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result)>, EditError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -623,15 +634,19 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec vec![c.clone()], }, }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() + let mut out = Vec::with_capacity(list.len()); + for o in list { + let data = match &*doc.resolve(&o) { + Object::Stream(s) => match budget.decode(s) { + Ok(data) => data, + Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), + Err(_) => Vec::new(), + }, + _ => Vec::new(), + }; + out.push((o, data)); + } + Ok(out) } /// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators @@ -1284,7 +1299,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let mut carry = Carry::new(); // The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them // joined, so an operator whose operands end one piece still has them. - let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); + let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?; @@ -1490,15 +1505,23 @@ pub const MAX_FORM_DEPTH: usize = 50; pub const MAX_FORM_VISITS: usize = 50_000; /// One page's walk through the form XObjects it draws: how many it entered (the page's -/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread. -#[derive(Default)] +/// [`MAX_FORM_VISITS`]), the bytes the page's content and forms may still decode to, and whether +/// a limit left a form unread. pub(crate) struct FormBudget { visits: usize, - /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past - /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. + decode: DecodeBudget, + /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`], comes past + /// [`MAX_FORM_VISITS`] or past the decode budget: what it draws is missing from the walk. pub(crate) truncated: bool, } +impl FormBudget { + /// The walk of a page's forms, spending what `decode` has left after the page's content. + pub(crate) fn new(decode: DecodeBudget) -> Self { + Self { visits: 0, decode, truncated: false } + } +} + /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. pub(crate) struct FormCall { @@ -1519,7 +1542,7 @@ pub(crate) fn form_call( path: &[pdfcraft_cos::ObjRef], budget: &mut FormBudget, ) -> Option { - let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS; + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0; // Once a limit has cut the page short, refusing the rest costs nothing more. if limited && budget.truncated { return None; @@ -1546,13 +1569,16 @@ pub(crate) fn form_call( }); // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let data = match budget.decode.decode(s) { + Ok(data) => data, + Err(_) if budget.decode.remaining() == 0 => { + budget.truncated = true; + return None; + } + Err(_) => Vec::new(), + }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { - obj, - data: s.decoded().unwrap_or_default(), - resources: own.unwrap_or_else(|| resources.clone()), - ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)), - }) + Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -1612,8 +1638,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); - let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; + let mut decode = DecodeBudget::page(); + let streams = content_streams_within(doc, &p.dict, &mut decode)?; + let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) }; let mut carry = Carry::new(); // Read the page's streams as the one stream they are, in one pass, as rendering and editing // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) @@ -2235,7 +2262,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; @@ -2440,7 +2467,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; @@ -2486,14 +2513,17 @@ pub fn reading_blocks(doc: &Document, page: usize) -> Result, Edi pub struct ReadingBlocks { /// The paragraphs, as [`reading_blocks`] returns them. pub blocks: Vec, - /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than - /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their - /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses + /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than + /// [`MAX_FORM_VISITS`], or forms that run past what the page's content left of its decode + /// budget. Reading leaves those forms out to stay bounded on hostile files, so their text + /// is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses /// their images too. pub truncated: bool, } -/// [`reading_blocks`], saying whether the limits that bound reading left text out. +/// [`reading_blocks`], saying whether the limits that bound reading left text out. A page +/// whose `/Contents` alone runs past its decode budget is an [`EditError::Invalid`], not a +/// truncated result. pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result { let (lines, truncated) = reading_lines(doc, page)?; Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated }) @@ -2661,7 +2691,7 @@ fn rewrite_block_from( { return rewrite_styled_block(doc, page, &b, &lines, text, style); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // Explicit line breaks stay (#1443); within a line, runs of white space become one space. @@ -3044,7 +3074,7 @@ pub(crate) fn selectable_blocks( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; Ok(group_blocks(&lines) .into_iter() @@ -3133,7 +3163,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize return Ok(()); } let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let lines = text_lines(doc, page)?; let blocks = group_blocks(&lines); let mut selected = std::collections::BTreeSet::new(); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 928280d5d8..a775594944 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -772,7 +772,7 @@ pub(super) fn rewrite_styled_block( row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - let streams = content_streams(doc, &p.dict); + let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and diff --git a/crates/filters/README.md b/crates/filters/README.md index df64e4b7c0..959180d87a 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -17,14 +17,17 @@ pub struct Params { predictor, colors, bits_per_component, columns, early_change pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) } decode(chain, data, max_output) -> Result, FilterError> +decode_counted(chain, data, max_output) -> Result<(Vec, usize /*decoded*/), (FilterError, usize /*decoded*/)> decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), FilterError> +decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)> encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 ``` - `chain` is applied first to last, as in a `/Filter` array. -- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`. +- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`. - `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. +- `decode_counted` and `decode_tolerant_counted` are for a caller charging a budget (`pdfcraft_cos::DecodeBudget`). `max_output` bounds all stages together (each stage gets what the earlier ones left), and they give the bytes every stage that ran decoded, on success and on failure. A stage's output counts as its decoder produced it, before a predictor or a later stage shrinks it, and a failed decode pays for what it decoded first. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. ## Behaviour diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9a0124d859..22a78aefdb 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -137,52 +137,95 @@ pub(crate) type Step = Result, Failure>; /// Decodes `data` through `chain` (first element applied first). /// /// Image codecs, `Crypt` and unknown filters anywhere in the chain give -/// [`FilterError::Unsupported`] (callers keep such data encoded). `max_output` bounds -/// every intermediate buffer. +/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than +/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer. pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> { - run(chain, data, max_output, true).map(|(v, _)| v) + run(chain, data, max_output, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e) +} + +/// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages +/// decode together, each stage getting what the earlier ones left, and the result gives those +/// bytes, on success and on failure. A stage's output counts even when a later stage or its +/// predictor shrinks it. +pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, usize), (FilterError, usize)> { + run(chain, data, max_output, true, true).map(|(v, _, spent)| (v, spent)) } /// Like [`decode`], but on corrupt data returns what was decoded before the error and /// reports `partial = true` (the behaviour viewers expect). The remaining filters of the /// chain still run on the partial data. Limit and unsupported errors are still errors. pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool), FilterError> { - run(chain, data, max_output, false) + run(chain, data, max_output, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e) } -fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec, bool), FilterError> { +/// [`decode_tolerant`] bounded and counted as [`decode_counted`] is. +pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool, usize), (FilterError, usize)> { + run(chain, data, max_output, false, true) +} + +/// Longest filter chain decoded. Real streams use one to three filters; every stage may produce +/// up to the output limit, so the chain length multiplies the work. +pub const MAX_CHAIN: usize = 8; + +/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and +/// the bytes decoded before it. `max` bounds each stage, or with `cumulative` all of them +/// together. +fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool, cumulative: bool) -> Result<(Vec, bool, usize), (FilterError, usize)> { + if chain.len() > MAX_CHAIN { + return Err((FilterError::Unsupported(format!("a chain of {} filters", chain.len())), 0)); + } if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) { - return Err(FilterError::Unsupported(f.name().to_owned())); + return Err((FilterError::Unsupported(f.name().to_owned()), 0)); } let mut cur: Cow<[u8]> = Cow::Borrowed(data); let mut partial = false; + let mut spent = 0usize; for (filter, params) in chain { - cur = Cow::Owned(match decode_one(filter, params, &cur, max) { + #[cfg(test)] + STAGE_BYTES.with(|c| c.set(c.get() + cur.len())); + let limit = if cumulative { max.saturating_sub(spent) } else { max }; + let (step, work) = decode_one(filter, params, &cur, limit); + spent = spent.saturating_add(work); + cur = Cow::Owned(match step { Ok(v) => v, Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => { partial = true; out } - Err(f) => return Err(f.error), + Err(Failure { error: FilterError::LimitExceeded(_), .. }) => return Err((FilterError::LimitExceeded(max), spent)), + Err(f) => return Err((f.error, spent)), }); } - Ok((cur.into_owned(), partial)) + Ok((cur.into_owned(), partial, spent)) } -fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> Step { +/// One stage's result and the bytes its decoder produced (before any predictor). +fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> (Step, usize) { match filter { Filter::Flate => with_predictor(flate::decode(data, max), params, "FlateDecode"), Filter::Lzw => with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode"), - Filter::AsciiHex => asciihex::decode(data, max), - Filter::Ascii85 => ascii85::decode(data, max), - Filter::RunLength => runlength::decode(data, max), - other => Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }), + Filter::AsciiHex => counted(asciihex::decode(data, max)), + Filter::Ascii85 => counted(ascii85::decode(data, max)), + Filter::RunLength => counted(runlength::decode(data, max)), + other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }), 0), } } -/// Applies the predictor to a decoder's result, including to partial output on failure. -fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step { - match step { +/// A step with the bytes it produced, its output or its partial output. +fn counted(step: Step) -> (Step, usize) { + let n = match &step { + Ok(v) => v.len(), + Err(f) => f.partial.len(), + }; + (step, n) +} + +/// Applies the predictor to a decoder's result, including to partial output on failure. The +/// count is the decoder's output: a PNG predictor drops a tag byte per row, but the decoder +/// produced it. +fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usize) { + let (step, n) = counted(step); + let step = match step { Ok(v) => predictor::decode(params, v, name), Err(Failure { error, partial }) => { let partial = match predictor::decode(params, partial, name) { @@ -191,7 +234,8 @@ fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step { }; Err(Failure { error, partial }) } - } + }; + (step, n) } /// Encodes `data` with `filter`. Supported: Flate (zlib, level 6), LZW, ASCIIHex, @@ -218,8 +262,27 @@ pub(crate) fn is_pdf_whitespace(b: u8) -> bool { matches!(b, 0 | 9 | 10 | 12 | 13 | 32) } +#[cfg(test)] +thread_local! { + /// Bytes handed to decoding stages on this thread. + static STAGE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests { + #[test] + fn a_long_filter_chain_is_refused_before_decoding() { + // `FF FF` run-length decodes to itself, so every stage of the chain repeats the work. + let data = vec![0xFF; 4096]; + let chain = |n: usize| vec![(super::Filter::RunLength, super::Params::default()); n]; + super::STAGE_BYTES.with(|c| c.set(0)); + let long = super::decode(&chain(1000), &data, 1 << 20); + let read = super::STAGE_BYTES.with(|c| c.get()); + assert!(read <= 16 * data.len(), "{read} bytes decoded through the chain"); + assert!(matches!(long, Err(super::FilterError::Unsupported(_))), "{long:?}"); + assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); + } + use super::*; #[test] @@ -343,4 +406,35 @@ mod tests { assert!(partial); assert_eq!(&data[..out.len()], &out[..]); } + + /// A counted decode bounds what all stages decode together: Flate inflating 1 MiB of hex + /// that ASCIIHex halves is 1.5 MiB of work, past a 1.25 MiB allowance. A plain decode keeps + /// the limit per stage. + #[test] + fn a_counted_decode_bounds_all_stages_together() { + let hex: Vec = std::iter::repeat_n(b"ab".as_slice(), 1 << 19).flatten().copied().collect(); + let data = encode_flate(&hex); + let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())]; + let max = (1 << 20) + (1 << 18); + assert_eq!(decode(&chain, &data, max).map(|d| d.len()).ok(), Some(1 << 19)); + assert!(matches!(decode_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max)); + assert_eq!(decode_counted(&chain, &data, 2 << 20).map(|(_, spent)| spent).ok(), Some((1 << 20) + (1 << 19))); + } + + /// A PNG predictor drops a tag byte per row, but Flate produced it: the count includes it, + /// also when the predictor fails on a bad tag. + #[test] + fn a_counted_decode_counts_output_before_the_predictor() { + let params = Params { predictor: 12, ..Params::default() }; + let rows = vec![7u8; 1 << 16]; + let data = encode(&Filter::Flate, ¶ms, &rows).unwrap(); + let chain = [(Filter::Flate, params.clone())]; + let (out, spent) = decode_counted(&chain, &data, usize::MAX).unwrap(); + assert!(out == rows); + assert_eq!(spent, 2 << 16); + let bad: Vec = std::iter::repeat_n([0u8, 7], 1 << 15).flatten().chain([9, 7]).collect(); + let (e, spent) = decode_counted(&chain, &encode_flate(&bad), usize::MAX).unwrap_err(); + assert!(matches!(e, FilterError::Corrupt { .. }), "{e}"); + assert_eq!(spent, bad.len()); + } } diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index a5bb011e00..2b7b4e17e1 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"); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 3ddd16a9b4..6e7ac7a8c2 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -92,6 +92,15 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) +} + +/// The largest embedded sfnt program read for the editor. +const MAX_SFNT: usize = 16 << 20; + +/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: +/// each program decoded is charged whether or not it turns out to be usable. +pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?; @@ -99,7 +108,7 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -275,6 +284,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 +691,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() { @@ -1281,7 +1293,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); } } @@ -1639,6 +1651,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(); @@ -2257,4 +2301,20 @@ mod encoding_tests { assert_eq!(m.text_of(0x61).as_deref(), Some("a")); assert_eq!(m.decode(b"abc"), "abc"); } + + #[test] + fn unusable_programs_still_spend_the_decode_budget() { + // 128 resources naming one 1 MiB program that isn't a font: each decode is charged, + // so an enumeration stops decoding once the shared budget is spent. + let mut doc = Document::new_empty(); + let program = doc.add(pdfcraft_cos::Stream::flate(Dict::new(), &vec![0; 1 << 20])); + let descriptor: Dict = [(b"FontFile2".to_vec(), Object::Ref(program))].into_iter().collect(); + let descriptor = doc.add(descriptor); + let font: Dict = [(b"FontDescriptor".to_vec(), Object::Ref(descriptor))].into_iter().collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + for _ in 0..128 { + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_none()); + } + assert_eq!(budget.remaining(), 0); + } } diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 8128b6a719..cdc89e5632 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec { } } -fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option { +fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { - b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()), + b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), @@ -145,13 +145,14 @@ pub fn field_actions(doc: &Document, name: &str) -> Result wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; // An action chain (/Next) counts as its first action here. - if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) { + if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) { out.push((t, a)); } } diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 85284b8cc5..7bd87a2efc 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -14,7 +14,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString}; mod actions; pub mod af; @@ -264,7 +264,7 @@ fn name_obj(text: &str) -> Object { const MAX_LAYER_STATE: usize = 1024; /// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): // clicking it picks a picture, as `buttonImportIcon()` does. if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { @@ -345,7 +345,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option af::button_script(&script(doc, &action)?), + b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), _ => return None, }) } @@ -371,20 +371,30 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { (!parts.is_empty()).then(|| parts.join(".")) } -/// The JavaScript of an action dictionary (`/JS` string or stream). -fn script(doc: &Document, action: &Object) -> Option { +/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode +/// in all, however many actions name the same stream. +const MAX_SCRIPT_BYTES: usize = 64 << 20; + +/// A budget for the script streams of one reading of the form. +fn script_budget() -> DecodeBudget { + DecodeBudget::new(MAX_SCRIPT_BYTES, 0) +} + +/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against +/// `budget`, and is `None` once it is spent. +fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { let a = doc.resolve(action); let js = a.as_dict()?.get(b"JS")?.clone(); match &*doc.resolve(&js) { Object::String(s) => Some(s.to_text()), - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } /// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only /// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { (widgets.len() == 1) .then(|| doc.get(widgets[0])) @@ -393,7 +403,7 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { }); let mut out = af::Actions::default(); let Some(aa) = aa else { return out }; - let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a)); + let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); if let Some(f) = js(b"F") { match af::parse_format(&f) { Some(fm) => out.format = fm, @@ -580,7 +590,7 @@ fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut seen = Seen { fields: std::collections::HashSet::new(), scripts: script_budget() }; if let Some(af) = &af { for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { if let Some(r) = f.as_ref() { @@ -604,7 +614,7 @@ fn adopt_page_fields( pages: &[ObjRef], base: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, ) { for p in pages { @@ -613,7 +623,7 @@ fn adopt_page_fields( let Some(annots) = annots.as_array() else { continue }; for e in annots { let Some(r) = e.as_ref() else { continue }; - if seen.contains(&r) { + if seen.fields.contains(&r) { continue; } let obj = doc.get(r); @@ -624,7 +634,7 @@ fn adopt_page_fields( if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { continue; } - if let Some(root) = unlisted_root(doc, r, seen) { + if let Some(root) = unlisted_root(doc, r, &seen.fields) { walk(doc, root, base, page_of, seen, out, 0); } } @@ -756,16 +766,23 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re /// Read the field at `r` and the fields under it into `out`, with what they inherit from /// `parent`: kids with a name are fields of their own, kids without are this field's widgets. +/// What one reading of the form's fields has used: the field dictionaries read, and the script +/// budget their actions share. +struct Seen { + fields: std::collections::HashSet, + scripts: DecodeBudget, +} + fn walk( doc: &Document, r: ObjRef, parent: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, depth: usize, ) { - if depth > 64 || !seen.insert(r) { + if depth > 64 || !seen.fields.insert(r) { return; } let obj = doc.get(r); @@ -808,9 +825,10 @@ fn walk( return; } let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let actions = actions_of(doc, d, &widget_refs); - let button = - (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten(); + let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); + let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) + .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) + .flatten(); let ff = inh.ff.unwrap_or(0); let kind = match inh.ft.as_deref() { Some(b"Tx") => FieldKind::Text, diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index f5b8274459..7dd7bfde6c 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() }; let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); + let mut scripts = crate::script_budget(); // Kids stay unresolved on the stack, and each node is read once: a node listed many times, // or among its own kids, must not multiply the work. let mut stack = vec![tree]; @@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { Object::String(s) => s.to_text(), _ => continue, }; - if let Some(js) = crate::script(doc, v) { + if let Some(js) = crate::script(doc, v, &mut scripts) { out.push((key, js)); } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 2b20428650..6a7c04492f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2998,3 +2998,43 @@ fn a_script_reading_many_fields_merges_its_reads_in_one_pass() { let compared = NAMES_COMPARED.with(std::cell::Cell::get); assert!(compared <= 4 * n, "{compared} names compared for {n} reads"); } + +/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream. +fn repeated_script_references() -> Document { + use pdfcraft_cos::Stream; + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20])); + let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))])); + let fields: Vec = (0..300) + .map(|i| { + Object::Ref(doc.add(dict(vec![ + (b"FT", Object::Name(b"Tx".to_vec())), + (b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))), + (b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())), + (b"AA", dict(vec![(b"C", Object::Ref(action))])), + ]))) + }) + .collect(); + let names: Vec = + (0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect(); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_script_references_share_one_decode_budget() { + let doc = repeated_script_references(); + let limit = MAX_SCRIPT_BYTES; + let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum(); + assert!(named > 0 && named <= limit, "{named} bytes of document scripts"); + let all = fields(&doc); + assert_eq!(all.len(), 300); + let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum(); + assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); +} diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index a29397339e..4ff77b74c8 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -15,7 +15,7 @@ use std::rc::Rc; use crate::Limits; use crate::xfa::{ HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, - resolve_som, run_within, + reset_names, resolve_som, run_within, }; /// Deepest nesting the parser follows. @@ -1466,8 +1466,7 @@ impl Interp<'_> { } "resetdata" => { let list = self.arg_str(args, 0)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - self.h.push(XfaEffect::ResetData(names)); + self.h.push(XfaEffect::ResetData(reset_names(&list))); Ok(Val::Null) } "print" => { @@ -1618,19 +1617,13 @@ impl Interp<'_> { "presence" => { let p = self.to_str(&v); if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { - if let Some(n) = self.h.nodes.get_mut(i) { - n.presence = p.clone(); - } - self.h.push(XfaEffect::SetPresence { som, presence: p }); + self.h.set_state(i, XfaEffect::SetPresence { som, presence: p }); } } "access" => { let a = self.to_str(&v); if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { - if let Some(n) = self.h.nodes.get_mut(i) { - n.access = a.clone(); - } - self.h.push(XfaEffect::SetAccess { som, access: a }); + self.h.set_state(i, XfaEffect::SetAccess { som, access: a }); } } other => { @@ -1646,6 +1639,8 @@ impl Interp<'_> { return Err(format!("{som} has no {other} to assign to")); } for k in kids { + // Every destination copies the value: charge each copy. + self.charge(self.cost(&v))?; self.assign_to(Val::Node(k), "rawValue", v.clone())?; } } @@ -1654,6 +1649,7 @@ impl Interp<'_> { } Val::List(items) => { for it in items { + self.charge(self.cost(&v))?; self.assign_to(it, prop, v.clone())?; } Ok(()) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index ae94feb0aa..f2221f7509 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -203,6 +203,66 @@ struct Host { console: Vec, requests: Vec, doc: DocInfo, + /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. + kept: usize, + /// Alerts, console lines and requests the limits left out. + dropped: usize, +} + +impl Host { + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped = self.dropped.saturating_add(1); + false + } + } + } + + fn alert(&mut self, m: String) { + if self.alerts.len() >= xfa::MAX_ALERTS { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.alerts.push(m); + } + } + + fn print(&mut self, m: String) { + if self.console.len() >= xfa::MAX_CONSOLE { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.console.push(m); + } + } + + /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] + /// requests and the run's byte budget. + fn request(&mut self, r: Request) { + if self.requests.len() >= xfa::MAX_EFFECTS { + self.dropped = self.dropped.saturating_add(1); + return; + } + let r = match r { + Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), + Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), + Request::Submit(u) => Request::Submit(xfa::clip(u)), + Request::Focus(n) => Request::Focus(xfa::clip(n)), + other => other, + }; + let bytes = match &r { + Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + if self.room(bytes) { + self.requests.push(r); + } + } } type Shared = RefCell; @@ -223,6 +283,19 @@ fn text(v: &JsValue, ctx: &mut Context) -> JsResult { Ok(v.to_string(ctx)?.to_std_string_escaped()) } +/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer +/// string. +fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + let js = v.to_string(ctx)?; + // Two UTF-16 units per character at most: the clip still sees more than a line's worth. + let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); + let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; + Ok(xfa::clip(js.to_std_string_escaped())) +} + fn arg(args: &[JsValue], i: usize) -> JsValue { args.get(i).cloned().unwrap_or_default() } @@ -440,7 +513,7 @@ fn field_object(ctx: &mut Context, name: &str) -> JsObject { Ok(JsValue::undefined()) } fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - host(c)?.borrow_mut().requests.push(Request::Focus(name.to_std_string_escaped())); + host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); Ok(JsValue::undefined()) } @@ -494,12 +567,12 @@ fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsRes fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let names = strings(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::Reset(names)); + host(ctx)?.borrow_mut().request(Request::Reset(names)); Ok(JsValue::undefined()) } fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Print); + host(ctx)?.borrow_mut().request(Request::Print); Ok(JsValue::undefined()) } @@ -512,7 +585,7 @@ fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult text(&a, ctx)?, }; - host(ctx)?.borrow_mut().requests.push(Request::Submit(url)); + host(ctx)?.borrow_mut().request(Request::Submit(url)); Ok(JsValue::undefined()) } @@ -534,38 +607,38 @@ fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { match v.as_object() { Some(o) if !o.is_array() && !o.is_callable() => { let m = o.get(JsString::from(key), ctx)?; - text(&m, ctx) + text_line(&m, ctx) } - _ => text(v, ctx), + _ => text_line(v, ctx), } } fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = message(&arg(args, 0), "cMsg", ctx)?; - host(ctx)?.borrow_mut().alerts.push(m); + host(ctx)?.borrow_mut().alert(m); // The OK button. Ok(JsValue::from(1)) } fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Beep); + host(ctx)?.borrow_mut().request(Request::Beep); Ok(JsValue::undefined()) } fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u)); + host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); Ok(JsValue::undefined()) } @@ -575,8 +648,8 @@ fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { } fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = text(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().console.push(m); + let m = text_line(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } @@ -752,7 +825,11 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult let mut i = 0; while i < b.len() { let c = b[i]; - let run = b[i..].iter().take_while(|x| **x == c).count(); + // Only date letters read as runs; a literal is copied one character at a time, so + // measuring its run would rescan a long literal stretch at every position. + let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; + #[cfg(test)] + PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); let push_num = |out: &mut String, v: i64, n: usize| { if n >= 2 { out.push_str(&format!("{v:02}")); @@ -1011,6 +1088,10 @@ fn event_object(ctx: &mut Context, e: &Event) -> JsObject { // stack a script can use: brackets and prefix operators by the limits below, and every other // construct (a few KiB per level, several bytes of source each) by the length limit. +/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. +#[cfg(test)] +pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; /// Largest width or precision accepted by `util.printf`. @@ -1179,6 +1260,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { @@ -1209,6 +1292,9 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc 0 { + out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); + } } out } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 5b6adef53c..01a4362400 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -244,6 +244,46 @@ fn document_requests_and_console() { ); } +/// Bytes of text a run handed back in its messages, console lines and requests. +fn kept_bytes(o: &Outcome) -> usize { + let req = |r: &Request| match r { + Request::Reset(names) => names.iter().map(String::len).sum(), + Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + o.console.iter().chain(&o.alerts).map(String::len).sum::() + o.requests.iter().map(req).sum::() +} + +#[test] +fn console_alerts_and_requests_are_capped_in_count_and_bytes() { + let o = go( + "var s = 'x'; while (s.length < 8192) s += s; \ + for (var i = 0; i < 1500; i++) { console.println(s); app.alert(s); } \ + var a = []; for (var i = 0; i < 2000; i++) a.push(s); \ + for (var i = 0; i < 5; i++) this.resetForm(a); \ + for (var i = 0; i < 12000; i++) app.launchURL('u');", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len()); + assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len()); + assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len()); + assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097)); + assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o)); + assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last()); +} + +#[test] +fn printd_reads_a_long_literal_run_in_linear_work() { + crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); + let o = go("event.value = util.printd(new Array(20001).join('x'), new Date(2024, 0, 5)).length;", &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + assert_eq!(o.value, "20000"); + // Other tests format short dates at the same time; they add a few dozen bytes at most. + let scanned = crate::PRINTD_SCANNED.load(std::sync::atomic::Ordering::Relaxed); + assert!(scanned < 40_000, "printd scanned {scanned} bytes for a 20000-byte format"); +} + #[test] fn document_level_functions_are_available() { let o = run( @@ -415,6 +455,27 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// A long value written again and again stops at the run's byte budget, and the write that + /// does not fit is left out of the field the script reads as well as of the effect. + #[test] + fn a_value_past_the_byte_budget_is_left_out_of_reads_and_effects_alike() { + let o = go( + "var s = 'x'; while (s.length < 65536) s += s; \ + for (var i = 0; i < 300; i++) details.note.rawValue = ('00000' + i).slice(-6) + s.slice(6); \ + details.note.rawValue.slice(0, 6)", + "form1[0].page1[0].total[0]", + "calculate", + ); + assert_eq!(o.error, None, "{o:?}"); + let set: Vec<&str> = o + .effects + .iter() + .filter_map(|e| if let XfaEffect::SetValue { value, .. } = e { Some(value.get(..6).unwrap_or(value)) } else { None }) + .collect(); + assert_eq!(set.len(), 1); + assert_eq!(o.result.as_deref(), set.first().copied()); + } + /// The script runs through `eval`, so code built from strings at run time is held to the /// script's own size and nesting checks instead of being refused outright. #[test] @@ -625,6 +686,74 @@ mod xfa_model { assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); } + /// Past the byte budget, adding or removing a row is left out of the snapshot as well as the + /// effects, so the script never counts rows the form will not get. + #[test] + fn rows_changed_past_the_byte_budget_are_left_out_of_the_snapshot_too() { + let o = go( + "var s = 'x'; while (s.length < 4096) s += s; for (var i = 0; i < 9000; i++) xfa.host.gotoURL(s); \ + table._row.removeInstance(0); var added = table._row.addInstance(1); (added === null) + ':' + table._row.count", + "form1[0].page1[0].go[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert!(o.notes.iter().any(|n| n.contains("MiB")), "{:?}", o.notes); + assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. }))); + assert_eq!(o.result.as_deref(), Some("true:2")); + } + + /// Past the effect cap, presence and access set on a new object are left out of the snapshot + /// as well as the effects; one set again on an object that already has its effect still lands. + #[test] + fn presence_and_access_past_the_effect_cap_are_left_out_of_the_snapshot_too() { + let read = "qty.presence + ':' + qty.access + ':' + price.presence"; + let before = go(read, "form1[0].page1[0]", "click").result; + let o = go( + &format!( + "price.presence = 'invisible'; for (var i = 0; i < 20000; i++) xfa.host.beep(); qty.presence = 'hidden'; qty.access = 'readOnly'; price.presence = 'hidden'; {read}" + ), + "form1[0].page1[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.effects.len(), MAX_EFFECTS); + let unchanged = before.as_deref().and_then(|b| b.rsplit_once(':')).map(|(qty, _)| format!("{qty}:hidden")); + assert_eq!(o.result, unchanged); + assert!(o.effects.contains(&XfaEffect::SetPresence { som: "form1[0].page1[0].price[0]".into(), presence: "hidden".into() })); + assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::SetPresence { som, .. } | XfaEffect::SetAccess { som, .. } if som.contains("qty")))); + } + + /// Past the effect cap, a value written to a field with no effect yet is left out of reads as + /// well as the effects; a field written again before the cap still takes its new value. + #[test] + fn a_value_past_the_effect_cap_is_left_out_of_reads_and_effects_alike() { + let o = go( + "price.rawValue = 1; for (var i = 0; i < 20000; i++) xfa.host.beep(); qty.rawValue = 9; price.rawValue = 3; qty.rawValue + ':' + price.rawValue", + "form1[0].page1[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.effects.len(), MAX_EFFECTS); + assert_eq!(o.result.as_deref(), Some("4:3")); + assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { som, .. } if som.contains("qty")))); + } + + #[test] + fn urls_and_reset_lists_are_clipped_and_deduplicated() { + let o = go( + "var s = 'x'; while (s.length < 65536) s += s; for (var i = 0; i < 600; i++) xfa.host.gotoURL(s + i); \ + var r = 'a,'; while (r.length < 262144) r += r; xfa.host.resetData(r);", + "form1[0].page1[0]", + "initialize", + ); + assert_eq!(o.error, None); + let urls: Vec<&String> = o.effects.iter().filter_map(|e| if let XfaEffect::LaunchUrl(u) = e { Some(u) } else { None }).collect(); + assert_eq!(urls.len(), 600); + assert!(urls.iter().all(|u| u.chars().count() <= 4_097)); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 1); + } + #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -676,6 +805,54 @@ mod formcalc { o.result.unwrap_or_default() } + /// Assigning one long value to many fields at once stops at the run's byte budget and is + /// charged per copy, and a reset list names each field once. + #[test] + fn bulk_assignments_and_reset_lists_are_bounded() { + let f = |i: usize| super::xfa_model::field("f", &format!("form1[0].page1[0].f[{i}]"), "", false); + let root = XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: (0..1300).map(f).collect(), + ..Default::default() + }], + ..Default::default() + }; + let ev = XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() }; + let run = |script: &str| run_formcalc(script, &ev, &XfaDoc::default(), &root, Limits::default()); + let o = run("var s = \"x\"\nwhile (Len(s) < 65536) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error, None, "{o:?}"); + let kept: usize = o.effects.iter().map(|e| if let XfaEffect::SetValue { value, .. } = e { value.len() } else { 0 }).sum(); + assert!(kept <= MAX_PAYLOAD, "{kept} bytes of values kept"); + assert!(o.notes.iter().any(|n| n.contains("left out")), "{:?}", o.notes); + // 1300 copies of a 1 MiB value are more work than a script may do. + let o = run("var s = \"x\"\nwhile (Len(s) < 1048576) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error.as_deref(), Some("the script ran too long"), "{:?}", o.notes); + let o = run("var r = \"a,\"\nwhile (Len(r) < 262144) do r = Concat(r, r) endwhile\n$host.resetData(r)\n$host.resetData(r)"); + assert_eq!(o.error, None, "{o:?}"); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 2); + } + + /// FormCalc presence and access past the effect cap are left out of the snapshot too. + #[test] + fn formcalc_presence_and_access_past_the_effect_cap_are_left_out_of_the_snapshot_too() { + let o = go( + "for i = 1 upto 20000 do $host.beep() endfor\nqty.presence = \"hidden\"\nqty.access = \"readOnly\"\nConcat(qty.presence, \":\", qty.access)", + "form1[0].page1[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.effects.len(), MAX_EFFECTS); + let before = go("Concat(qty.presence, \":\", qty.access)", "form1[0].page1[0]", "click"); + assert_eq!(o.result, before.result); + } + /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script /// sets the value. #[test] diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 490408a106..8a60e8ba9c 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -8,7 +8,7 @@ //! children, then its siblings, then each ancestor's children. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use boa_engine::object::ObjectInitializer; use boa_engine::object::builtins::{JsArray, JsProxy}; @@ -158,7 +158,10 @@ pub const MAX_ALERTS: usize = 100; /// Most console lines one run may print. pub const MAX_CONSOLE: usize = 1_000; /// Longest message or console line kept, in characters. -const MAX_LINE: usize = 4_096; +pub(crate) const MAX_LINE: usize = 4_096; +/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names +/// and the values it sets. Past it, more are left out and noted. +pub(crate) const MAX_PAYLOAD: usize = 32 << 20; /// Longest value a script may put in a field (characters); longer ones are cut and noted. const MAX_VALUE: usize = 65_536; @@ -179,6 +182,47 @@ pub(crate) fn clip_value(v: String) -> (String, bool) { } } +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// The (property, SOM) an effect setting a property of an object is merged by, see +/// [`XHost::set_at`]. +fn effect_key(e: &XfaEffect) -> Option<(u8, String)> { + match e { + XfaEffect::SetValue { som, .. } => Some((0, som.clone())), + XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), + XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), + _ => None, + } +} + +/// Bytes of text `e` holds. +fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0, + } +} + /// A script's completion value (what a calculate script puts in its field) cut to /// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. pub(crate) fn clip_result(out: &mut XfaOutcome) { @@ -232,6 +276,9 @@ pub(crate) struct XHost { clipped_values: usize, dropped_alerts: usize, dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, } impl XHost { @@ -256,15 +303,71 @@ impl XHost { clipped_values: 0, dropped_alerts: 0, dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } } } /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted - /// and dropped. + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. pub(crate) fn push(&mut self, e: XfaEffect) { - let e = match e { + let e = self.clip_effect(e); + if self.room(payload(&e)) { + self.record(e); + } + } + + /// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made + /// only when the effect that applies it will be kept, so a script never sees a change its + /// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is + /// counted when there is no room. + fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool { + let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len()); + if !replaces && self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return false; + } + self.room(bytes) + } + + /// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or + /// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept. + pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) { + if !self.reserve(&e, payload(&e)) { + return; + } + if let Some(n) = self.nodes.get_mut(i) { + match &e { + XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), + XfaEffect::SetAccess { access, .. } => n.access = access.clone(), + _ => {} + } + } + self.record(e); + } + + /// `e` with its text cut to the lengths an effect keeps. + fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { + match e { XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), XfaEffect::SetValue { som, value } => { let (value, cut) = clip_value(value); if cut { @@ -273,14 +376,12 @@ impl XHost { XfaEffect::SetValue { som, value } } other => other, - }; - let key = match &e { - XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), - XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), - XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), - _ => None, - }; - if let Some(key) = key { + } + } + + /// Keep `e`, whose bytes are already charged, against the count limits. + fn record(&mut self, e: XfaEffect) { + if let Some(key) = effect_key(&e) { if let Some(&at) = self.set_at.get(&key) && at >= self.barrier && let Some(slot) = self.effects.get_mut(at) @@ -316,7 +417,7 @@ impl XHost { pub(crate) fn print(&mut self, line: String) { if self.console.len() >= MAX_CONSOLE { self.dropped_console += 1; - } else { + } else if self.room(line.len()) { self.console.push(line); } } @@ -333,6 +434,9 @@ impl XHost { if self.dropped_console > 0 { out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } if self.clipped_values > 0 { out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); } @@ -343,20 +447,28 @@ impl XHost { /// holds a value; writing one into a subform would put text where another viewer expects /// a data group). Returns whether it was. pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get_mut(i) else { return false }; + let Some(n) = self.nodes.get(i) else { return false }; if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { return false; } + let som = n.som.clone(); // Cut here, not only in the effect: later reads in the script see what is saved, and // the snapshot stays bounded however often a long value is written. let (text, cut) = clip_value(text); - n.value = text.clone(); - n.formatted = None; - let som = n.som.clone(); if cut { self.clipped_values += 1; } - self.push(XfaEffect::SetValue { som, value: text }); + // The snapshot's copy and the effect's are charged together, before either is kept: + // past the byte budget or the effect cap the write is left out of both. + let effect = XfaEffect::SetValue { som, value: text.clone() }; + if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text; + n.formatted = None; + } + self.record(effect); true } } @@ -590,13 +702,7 @@ fn renumber(h: &mut XHost, idx: usize, depth: usize) { let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); (parent_som, n.name.clone(), n.index, n.children.clone()) }; - let som = if name.starts_with('#') || name.is_empty() { - parent_som - } else if parent_som.is_empty() { - format!("{name}[{index}]") - } else { - format!("{parent_som}.{name}[{index}]") - }; + let som = child_som(parent_som, &name, index); if let Some(n) = h.nodes.get_mut(idx) { n.som = som; } @@ -605,6 +711,17 @@ fn renumber(h: &mut XHost, idx: usize, depth: usize) { } } +/// The SOM of instance `index` of the node `name` under a parent at `parent_som`. +fn child_som(parent_som: String, name: &str, index: usize) -> String { + if name.starts_with('#') || name.is_empty() { + parent_som + } else if parent_som.is_empty() { + format!("{name}[{index}]") + } else { + format!("{parent_som}.{name}[{index}]") + } +} + /// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { @@ -672,7 +789,14 @@ impl XHost { let live = instances(h, parent, name); let &first = live.first()?; let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); - if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED { + if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES { + return None; + } + // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. + let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); + let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); + let som = child_som(parent_som, &name, live.len()); + if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { return None; } let new = clone_subtree(h, first, Some(parent), 0)?; @@ -687,8 +811,7 @@ impl XHost { } renumber(h, new, 0); h.added += 1; - let som = h.nodes.get(new).map(|n| n.som.clone()).unwrap_or_default(); - h.push(XfaEffect::AddInstance { som }); + h.record(XfaEffect::AddInstance { som }); Some(new) } @@ -725,6 +848,9 @@ impl XHost { return; } let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); + if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { + return; + } if let Some(n) = h.nodes.get_mut(gone) { n.gone = true; } @@ -735,7 +861,7 @@ impl XHost { } renumber(h, inst, 0); } - h.push(XfaEffect::RemoveInstance { som }); + h.record(XfaEffect::RemoveInstance { som }); } } @@ -958,20 +1084,14 @@ fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult value.clone(), _ => return Ok(JsValue::from(true)), }; - if let Some(n) = h.nodes.get_mut(idx) { - n.presence = p.clone(); - } - h.push(XfaEffect::SetPresence { som, presence: p }); + h.set_state(idx, XfaEffect::SetPresence { som, presence: p }); } "access" => { let a = match value.as_str() { "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), _ => return Ok(JsValue::from(true)), }; - if let Some(n) = h.nodes.get_mut(idx) { - n.access = a.clone(); - } - h.push(XfaEffect::SetAccess { som, access: a }); + h.set_state(idx, XfaEffect::SetAccess { som, access: a }); } _ => {} } @@ -1024,8 +1144,7 @@ fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let list = text(&arg(args, 0), ctx)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - effect(ctx, XfaEffect::ResetData(names))?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; Ok(JsValue::undefined()) } diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index dda45a75ae..d52d31dcc6 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/snap.rs b/crates/measure/src/snap.rs index 37ee2975f7..930ffe3053 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 259caeb5f3..8d69a05cf0 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -427,3 +427,37 @@ fn scale_lookup_reads_viewports_within_bounds() { let read = VIEWPORT_NUMBERS_READ.with(std::cell::Cell::get); assert!(read <= 4 * 1024, "{read} viewport numbers read"); } + +#[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()); +} +#[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"); +} diff --git a/crates/model/README.md b/crates/model/README.md index bfc6e42e3b..09a0603913 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -17,6 +17,9 @@ L3/L4 crates need: The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners. Repeated child references are skipped before copying attributes, with first depth-first occurrence order preserved. Returned page dictionaries still own their effective attributes. +A walk copies at most `MAX_INHERITED_COPIES` (1 GiB, approximate heap bytes) of inherited +values into the leaves; past it the walk stops, as it does past its leaf cap, and the pages +after that point are not listed. `organize`, `annot` and `forms` still carry their own small walkers; they move here as the model grows (ADR-0004). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 38bf36f222..886684c6d7 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -82,14 +82,36 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] { } /// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep -/// trees are cut off rather than followed. +/// trees are cut off rather than followed, as is a tree whose inherited values would take more +/// than [`MAX_INHERITED_COPIES`] bytes to copy into its leaves. pub fn pages(doc: &Document) -> Vec { PageTree::new(doc).collect() } +/// Approximate bytes of inherited values copied into leaf dictionaries by one page walk. +pub const MAX_INHERITED_COPIES: usize = 1 << 30; + +/// Approximate heap bytes of `o`: one [`Object`] per node plus string, name and key bytes. +/// Values nested deeper than the parser allows weigh as too large. +fn weight(o: &Object, depth: usize) -> usize { + if depth > 256 { + return usize::MAX; + } + let own = std::mem::size_of::(); + match o { + Object::String(s) => own.saturating_add(s.bytes.len()), + Object::Name(n) => own.saturating_add(n.len()), + Object::Array(a) => a.iter().fold(own, |n, v| n.saturating_add(weight(v, depth + 1))), + Object::Dict(d) => d.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + Object::Stream(s) => s.dict.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + _ => own, + } +} + // Retain the owner of each inherited value rather than deep-cloning direct Resources on -// every edge. Only the final Page dictionaries need owned copies of those values. -type Inherited = [Option>; 4]; +// every edge. Only the final Page dictionaries need owned copies of those values. Each owner +// carries the value's weight, measured once where the value is declared. +type Inherited = [Option<(std::sync::Arc, usize)>; 4]; struct Children { owner: std::sync::Arc, @@ -112,16 +134,23 @@ struct PageTree<'a> { // One cursor per ancestor, never one entry per child. This also preserves the first // depth-first occurrence's attributes when malformed trees share a child. pending: Vec, + // Weight left for copying inherited values into leaves. A large direct value on an + // ancestor of many leaves would otherwise be copied once per leaf. + budget: usize, } impl<'a> PageTree<'a> { fn new(doc: &'a Document) -> Self { + Self::with_budget(doc, MAX_INHERITED_COPIES) + } + + fn with_budget(doc: &'a Document, budget: usize) -> Self { let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))); let pending = top .into_iter() .map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() }) .collect(); - Self { doc, seen: Default::default(), pending } + Self { doc, seen: Default::default(), pending, budget } } } @@ -149,8 +178,8 @@ impl Iterator for PageTree<'_> { let Some(d) = obj.as_dict() else { continue }; let mut inherited = parent.inherited.clone(); for (key, owner) in INHERITABLE.iter().zip(&mut inherited) { - if d.contains(key) { - *owner = Some(obj.clone()); + if let Some(value) = d.get(key) { + *owner = Some((obj.clone(), weight(value, 0))); } } let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); @@ -165,8 +194,15 @@ impl Iterator for PageTree<'_> { let mut dict = d.clone(); for (key, owner) in INHERITABLE.iter().zip(&inherited) { if !dict.contains(key) - && let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key)) + && let Some((owner, weight)) = owner + && let Some(value) = owner.as_dict().and_then(|d| d.get(key)) { + // Past the budget the walk is cut off, like a tree too large to follow. + let Some(left) = self.budget.checked_sub(*weight) else { + self.pending.clear(); + return None; + }; + self.budget = left; dict.set(key.to_vec(), value.clone()); } } @@ -209,6 +245,29 @@ mod tests { Document::open(Arc::new(out)).unwrap() } + #[test] + fn inherited_copies_stop_at_a_budget() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone(); + let kids: Vec<_> = (0..200).map(|_| Object::Ref(d.add(leaf.clone()))).collect(); + let mut resources = Dict::new(); + resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 8192])); + let resources = Object::Dict(resources); + let each = weight(&resources, 0); + d.update_dict(root, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(kids)); + dict.set(b"Resources".to_vec(), resources); + }) + .unwrap(); + let budget = 1 << 20; + let pages: Vec = PageTree::with_budget(&d, budget).collect(); + let copied: usize = pages.iter().filter_map(|p| p.dict.get(b"Resources")).map(|r| weight(r, 0)).sum(); + assert!(copied <= budget, "{copied} bytes of inherited values copied into {} pages", pages.len()); + assert_eq!(pages.len(), budget / each); + assert_eq!(super::pages(&d).len(), 200); + } + #[test] fn repeated_children_do_not_queue_resource_copies() { let mut d = doc(0); diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index 44e1655460..4fa6e21646 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -18,7 +18,7 @@ //! drops them. use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample}; use crate::{OptimizeError, Report, Stage}; @@ -232,7 +232,9 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> /// Merge the fragment runs of one page; returns how many fragments were merged. fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; - let parsed = parse(&content_bytes(doc, &contents)); + // A page whose content isn't all known is left alone: rewriting it would drop the rest. + let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; + let parsed = parse(&data); if parsed.skipped > 0 { return Ok(0); } diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 1d8a59e253..896542285d 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Matrix, Pieces}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; @@ -23,21 +23,11 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { None } -pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => { - let pieces: Vec<_> = a - .iter() - .filter_map(|c| match &*doc.resolve(c) { - Object::Stream(s) => Some(s.decoded().unwrap_or_default()), - _ => None, - }) - .collect(); - Pieces::join(&pieces).data().to_vec() - } - _ => Vec::new(), - } +/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream +/// doesn't decode or the budget runs out: the content is not all known. +pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option> { + let pieces = budget.decode_contents(doc, contents).into_iter().collect::, _>>().ok()?; + Some(Pieces::join(&pieces).data().to_vec()) } /// Forms nested deeper than this aren't followed. @@ -57,6 +47,8 @@ struct Walk<'a> { /// Forms walked at least once. seen: HashSet, rewalks: usize, + /// Decoded bytes left for the page being walked. + budget: DecodeBudget, /// False once a use may have been missed (depth or work limit). complete: bool, out: HashMap, @@ -135,23 +127,46 @@ impl Walk<'_> { } self.rewalks += 1; } + if !self.budget.enter_form() { + self.complete = false; + return; + } + let Ok(data) = self.budget.decode(s) else { + self.complete = false; + return; + }; self.path.push(r); - self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner); + self.content(&data, own.as_ref().unwrap_or(resources), inner); self.path.pop(); } } /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// -/// If forms nest too deeply or are drawn too many times to measure every use, every image is -/// given `INFINITY` (never resampled), since its largest use may be among those missed. +/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within +/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be +/// among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() }; + let mut walk = Walk { + doc, + path: Vec::new(), + walked: HashSet::new(), + seen: HashSet::new(), + rewalks: 0, + budget: DecodeBudget::page(), + complete: true, + out: HashMap::new(), + }; for p in pages { let Some(d) = doc.get(*p).as_dict().cloned() else { continue }; let Some(contents) = d.get(b"Contents") else { continue }; let res = page_resources(doc, *p).unwrap_or_default(); - walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + walk.budget = DecodeBudget::page(); + let Some(data) = content_bytes(doc, contents, &mut walk.budget) else { + walk.complete = false; + continue; + }; + walk.content(&data, &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); } if !walk.complete { walk.out.values_mut().for_each(|v| *v = f64::INFINITY); @@ -471,3 +486,17 @@ mod dimension_tests { assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); } } + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream}; + + #[test] + fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents = Object::Array(vec![Object::Ref(stream); 300]); + let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300)); + } +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index fd1ef3cd94..6cf53d4fc9 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1023,6 +1023,19 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } +/// Each form entry counts against the page's form allowance, even for a form that decodes to +/// nothing and so costs no decoded bytes. +#[test] +fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let empty = form(&mut doc, None, ""); + let draws = "/E Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1); + page(&mut doc, &[("E", empty), ("Im", img)], &format!("q 72 0 0 72 36 36 cm /Im Do Q {draws}")); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); +} + #[test] fn image_resolution_keeps_comments_across_content_pieces() { for ending in ["", "\n", "\r", "\r\n"] { diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 80f90fb2a4..024dfa5c36 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -1354,19 +1354,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<( let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]); let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360); // Content: the page's streams, decoded and joined. - let mut content = Vec::new(); - if let Some(c) = own.get(b"Contents") { - let list = match &*src.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }; - for o in list { - if let Object::Stream(s) = &*src.resolve(&o) { - content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?); - content.push(b'\n'); - } - } - } + let content = match own.get(b"Contents") { + Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?, + None => Vec::new(), + }; let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); let resources = match d.get(b"Resources") { Some(r) => copier.copy_value(dst, r, None)?, diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index b5f5e65ffe..a42c342994 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object}; use crate::OrganizeError; @@ -223,23 +223,16 @@ impl Pruner<'_> { } } -/// The page's content, concatenated (`/Contents` may be one stream or an array). `None` when a -/// stream can't be decoded: the page may draw names that can't be seen. -fn content_bytes(doc: &Document, contents: &Object) -> Option> { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().ok(), - Object::Array(a) => { - let mut out = Vec::new(); - for c in a { - if let Object::Stream(s) = &*doc.resolve(c) { - out.extend(s.decoded().ok()?); - out.push(b'\n'); - } - } - Some(out) - } - _ => Some(Vec::new()), +/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded +/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget +/// runs out: the page may draw names that can't be seen. +pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { + let mut out = Vec::new(); + for data in DecodeBudget::page().decode_contents(doc, contents) { + out.extend(data?); + out.push(b'\n'); } + Ok(out) } /// The resources of a page (as `walk` reports them) with their owner. @@ -321,9 +314,9 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result {} - Some(data) => pruner.content(&data, &res_dict, owner, 0), - None => { + Ok(data) if data.is_empty() => {} + Ok(data) => pruner.content(&data, &res_dict, owner, 0), + Err(_) => { pruner.protect_type3_resources(&res_dict, owner); pruner.distrust(&res_dict, owner); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7a80e38e4c..bc833257f2 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3132,3 +3132,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() { assert!(walk.next().is_none()); assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]); } + +/// One page whose /Contents names the same 1 MiB stream 300 times (a few kilobytes on disk). +fn repeated_contents() -> Document { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let pages = doc.add(Object::Null); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300])), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let src = repeated_contents(); + let mut dst = Document::new_empty(); + if let Ok((form, _)) = page_as_form(&mut dst, &src, 0) { + let Object::Stream(s) = &*dst.get(form) else { panic!("not a stream") }; + let len = s.decoded().unwrap().len(); + assert!(len <= pdfcraft_cos::MAX_PAGE_DECODED + 300, "{len} bytes"); + } +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index afd6a3bab1..13ca8997e3 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 874f6bbee0..52e6903e60 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -476,3 +476,15 @@ fn annotation_pages_are_found_in_one_pass() { let read = ANNOT_ENTRIES_READ.with(std::cell::Cell::get); assert!(read <= 2 * n, "{read} entries read for {n} annotations"); } + +#[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"); + } +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 6839e5f6b0..9d47eb1bf9 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -18,7 +18,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; pub mod range; pub mod spool; @@ -331,11 +331,14 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool { let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut score = 0i64; - text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score); + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); score > 0 } -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) { +fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); for op in pdfcraft_content::parse(data).ops { match op.op.as_slice() { @@ -358,8 +361,11 @@ fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: } let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if let Ok(body) = form.decoded() { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score); + if !budget.enter_form() { + return; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); } } _ => {} @@ -676,24 +682,22 @@ fn n(v: f64) -> String { if s == "-0" { "0".into() } else { s.into() } } -fn decoded_contents(doc: &Document, page: &Dict) -> Vec { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; +/// The page's content streams decoded against `budget` and joined; streams that don't decode +/// are left out. `None` when the budget ran out, so part of the content is missing. +fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; let mut out = Vec::new(); - for o in list { - if let Object::Stream(s) = &*doc.resolve(&o) - && let Ok(d) = s.decoded() - { - out.extend_from_slice(&d); - out.push(b'\n'); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b'\n'); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} } } - out + Some(out) } /// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that @@ -770,16 +774,24 @@ fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option, ) -> Result { - let page = pdfcraft_model::pages(doc).swap_remove(index); + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; let crop = page.crop(doc); let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; // Wrap the page content so a state it leaves behind can't affect the annotations. if !body.is_empty() { let mut wrapped = b"q\n".to_vec(); @@ -841,10 +853,24 @@ fn page_form( Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) } +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + /// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a /// printer or is the user's own copy); callers must check the print permission first. pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = pdfcraft_model::pages(src); + let pages = source_pages(src); let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); let sheets = layout(&sizes, settings)?; let mut doc = src.clone(); @@ -861,7 +887,7 @@ pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError let form = match forms.get(&pl.page) { Some(f) => *f, None => { - let f = page_form(&mut doc, pl.page, settings.content, &mut promoted)?; + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; forms.insert(pl.page, f); f } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 99634f1aa1..fe3a1e29ac 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -192,6 +192,17 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3); } +#[test] +fn imposing_lists_the_pages_once() { + let n = 150; + let doc = fixture(n); + PAGES_LISTED.with(|c| c.set(0)); + let out = impose(&doc, &settings((0..n).collect(), Layout::multiple(2))).unwrap(); + let listed = PAGES_LISTED.with(|c| c.get()); + assert!(listed <= 2 * n, "{listed} pages listed to print {n}"); + assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), n / 2); +} + #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; @@ -877,3 +888,42 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() { } mod appearances; + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = decoded_contents(&doc, &page, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= pdfcraft_cos::MAX_PAGE_DECODED + 300)); +} + +/// 20 draws of a form that draws the next form 20 times, four levels down: 168,420 form +/// entries from a few hundred bytes. +fn form_fanout(doc: &mut Document) -> Dict { + let draws = "/X Do ".repeat(20); + let mut form = |content: &str, inner: Option| { + let mut d: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + if let Some(inner) = inner { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + doc.add(Stream::from_raw(d, content.as_bytes().to_vec())) + }; + let mut inner = form("BT 0 1 -1 0 0 0 Tm (a) Tj ET", None); + for _ in 0..3 { + inner = form(&draws, Some(inner)); + } + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() +} + +#[test] +fn text_direction_stops_at_the_form_budget() { + let mut doc = Document::new_empty(); + let res = form_fanout(&mut doc); + let mut score = 0; + text_direction(&doc, "/X Do ".repeat(20).as_bytes(), &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::page()); + assert!(score > 0 && score <= pdfcraft_cos::MAX_PAGE_FORMS as i64, "score {score}"); +} diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index 8d6f56ff6f..acf8ece6f1 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -147,7 +147,8 @@ fn promotion_preserves_streams_and_explicit_subtypes() { for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] { let (mut doc, ap, _) = widget_fixture(subtype.clone(), Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0])); let original = doc.get(ap); - let form = page_form(&mut doc, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); + let pages = pdfcraft_model::pages(&doc); + let form = page_form(&mut doc, &pages, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); assert_eq!(doc.get(ap), original, "original in the print clone"); let promoted = xobject(&doc, doc.get(form).as_dict().unwrap(), b"PCPrA0"); if subtype.is_some() { diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index b4186fd4e9..9851c2e8c0 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -106,10 +106,16 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, + /// Form entries and decoded bytes left for this page, its content streams already charged: + /// shared forms drawn from other shared forms would otherwise multiply the work with every + /// level. + budget: DecodeBudget, } impl<'a> Scope<'a> { - pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self { + /// A scope whose form walk spends `budget`, what is left of the page's allowance after its + /// content streams were decoded. + pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self { Scope { rects, mode, @@ -124,6 +130,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, + budget, } } @@ -1272,8 +1279,25 @@ fn xobject( return Some(None); } // Strict, like page content: a Form read only in part counts as undecodable. - let data = match s.decoded_strict() { + let data = if scope.budget.enter_form() { + scope.budget.decode_strict(s) + } else { + Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects"))) + }; + let data = match data { Ok(d) => d, + // Past the page's budget nothing more is read: apply refuses the page, and + // verification counts the form as content that may remain. + Err(e) if scope.budget.exhausted() => { + if scope.mode == Mode::Apply { + out.error = Some(e); + } else if scope.everywhere() { + scope.layer_blocks += 1; + } else { + out.residue += 1; + } + return None; + } Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; out.replaced.push(name); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index e67b373a11..fce9f432a9 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -227,8 +227,20 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { (out, unreadable) } -/// A page's content streams: (the references or inline objects as listed, their decoded data). +/// A page's content streams: (the references or inline objects as listed, their decoded data), +/// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { + page_streams_within(doc, page, index, &mut pdfcraft_cos::DecodeBudget::page()) +} + +/// [`page_streams`], decoded against `budget`, which the walk of the page's forms then goes on +/// spending. +pub(crate) fn page_streams_within( + doc: &Document, + page: &Dict, + index: usize, + budget: &mut pdfcraft_cos::DecodeBudget, +) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -241,7 +253,7 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< match &*doc.resolve(o) { // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which // still draws in viewers that read further; anything not read can't be cleaned. - Object::Stream(s) => data.push(s.decoded_strict().map_err(|_| RedactError::Unreadable(index + 1))?), + Object::Stream(s) => data.push(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } @@ -394,10 +406,11 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); // 1. Content. - let (list, data) = page_streams(doc, &page.dict, pi)?; + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report); + let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot @@ -599,13 +612,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - let (list, data) = page_streams(doc, &page.dict, pi)?; + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; // The overlay stream draws text of its own: the overlay label is allowed. let original: Vec> = list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect(); let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut scratch = Report::default(); - let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); + let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget); residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue; } if residue > 0 { diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 5b2918f01c..0b6c04fdf0 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -8,7 +8,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::interp::{Mode, Scope, process}; -use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; +use crate::{RedactError, Report, annots_of, listed_annotations, page_streams, page_streams_within}; /// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -356,7 +356,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); - let Ok((list, data)) = page_streams(doc, &page.dict, pi) else { + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else { if write { return Err(RedactError::Unreadable(pi + 1)); } @@ -368,7 +369,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut report = Report::default(); let mode = if write { Mode::Apply } else { Mode::Verify }; - let mut scope = Scope::new(&[], mode, &mut report); + let mut scope = Scope::new(&[], mode, &mut report, budget); if text { let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index e35d4f907d..2478e0b31b 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -48,10 +48,11 @@ fn content(doc: &Document, page: usize) -> String { /// Glyphs (and inline images) of page `page` under `rects` (the verifier's count). fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize { let p = pdfcraft_model::pages(doc).swap_remove(page); - let (_, data) = page_streams(doc, &p.dict, page).unwrap(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap(); let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut rep = Report::default(); - let mut scope = Scope::new(rects, Mode::Verify, &mut rep); + let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget); process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue } @@ -2478,6 +2479,23 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } +#[test] +fn form_entries_share_one_budget_per_page() { + // 20 draws of a form that draws the next 20 times, four levels down: 160,000 glyphs from a + // few hundred bytes. + let draws = "/X Do ".repeat(20); + let mut objs = vec![stream("/Type /XObject /Subtype /Form /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (A) Tj ET")]; + for inner in 7..10 { + objs.push(stream(&format!("/Type /XObject /Subtype /Form /Resources << /XObject << /X {inner} 0 R >> >>"), draws.as_bytes())); + } + let mut doc = one_page(draws.as_bytes(), "/XObject << /X 10 0 R >>", objs); + let residue = under(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]]); + assert!(residue > 0 && residue <= pdfcraft_cos::MAX_PAGE_FORMS, "{residue} glyphs counted"); + // Applying refuses the page rather than leave forms past the budget unread. + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err()); +} + /// A name tree whose levels each list the next level twice: a walk that follows every path /// visits 2^levels nodes. #[test] @@ -2696,3 +2714,34 @@ fn repeated_name_tree_kids_count_towards_the_node_budget() { let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid"); } + +/// Content streams and the forms they draw spend one page allowance: 130 MiB of content and a +/// 130 MiB form are each under it, together past it. +#[test] +fn content_and_forms_share_one_budget_per_page() { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = one_page(b"", "", vec![]); + let mut content = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET /X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), &content))); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form = doc.add(Object::Stream(Stream::flate(form, &body))); + doc.update_dict(ObjRef::new(3, 0), |d| { + let mut xobjects = Dict::new(); + xobjects.set(b"X".to_vec(), Object::Ref(form)); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0))); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"Contents".to_vec(), Object::Ref(content)); + }) + .unwrap(); + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read"); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index e9afb4ec47..953a96d211 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -38,6 +38,31 @@ 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; + +/// Web-address text the link listing, and name, description and embedded-file key text the +/// attachment listing, keep together. Many links or attachments can share one long string, which +/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its +/// address, an attachment under its generic name without a description, and an embedded file whose +/// key does not fit is not listed. +const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 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; @@ -676,7 +701,8 @@ impl<'a> Inspector<'a> { if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { (info.outline, info.outline_more) = self.outline_window(first); } - self.annotations(info); + let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; + self.annotations(info, &mut text_budget); self.transitions(info); let mut field_seen = HashSet::new(); if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) @@ -720,8 +746,10 @@ impl<'a> Inspector<'a> { if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { let mut seen = HashSet::new(); - for (name, spec) in self.name_tree(ef, &mut seen, 0) { - info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name })); + for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) { + // The key is kept a second time when it is also the name. + let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone()); + info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback)); } } info.has_javascript |= names.get(b"JavaScript").is_ok(); @@ -809,6 +837,12 @@ impl<'a> Inspector<'a> { self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) } + /// The object a reference ends at after following any chain of references; `None` for a + /// direct object or a broken reference. + fn resolved_id(&self, o: &Object) -> Option { + self.doc.dereference(o).ok().and_then(|(id, _)| id) + } + fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> { match self.resolve(o) { Object::Dictionary(d) => Some(d), @@ -818,16 +852,19 @@ impl<'a> Inspector<'a> { } fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let o = self.resolve(d.get(key).ok()?); - let s = match o { - Object::String(bytes, _) => text_string(bytes), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - let s = s.trim_matches('\0').trim().to_string(); + let s = trimmed_text(self.resolve(d.get(key).ok()?))?; (!s.is_empty()).then_some(s) } + /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims + /// short or to nothing still spends its raw length. + fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { + let o = self.resolve(d.get(key).ok()?); + let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None }; + let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?; + (!text.is_empty()).then_some(text) + } + fn name(&self, d: &Dictionary, key: &[u8]) -> Option { self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned()) } @@ -916,7 +953,15 @@ impl<'a> Inspector<'a> { } } - fn name_tree(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32) -> Vec<(String, &'a Object)> { + /// The entries of an embedded-files name tree. Each string key is spent from `text_budget` + /// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out. + fn name_tree( + &self, + node: &'a Dictionary, + seen: &mut HashSet<*const Dictionary>, + depth: u32, + text_budget: &mut usize, + ) -> Vec<(String, &'a Object)> { let mut out = Vec::new(); if depth > 32 || !seen.insert(node as *const _) { return out; @@ -925,7 +970,10 @@ impl<'a> Inspector<'a> { for pair in pairs.chunks(2) { if let [k, v] = pair { let key = match self.resolve(k) { - Object::String(bytes, _) => text_string(bytes), + Object::String(bytes, _) => match spend(bytes, text_budget, text_string) { + Some(key) => key, + None => continue, + }, _ => String::new(), }; out.push((key, v)); @@ -935,13 +983,53 @@ impl<'a> Inspector<'a> { if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { for k in kids { if let Some(d) = self.dict(k) { - out.extend(self.name_tree(d, seen, depth + 1)); + out.extend(self.name_tree(d, seen, depth + 1, text_budget)); } } } out } + /// The first entry of an embedded-files name tree whose key is `key`, in the order + /// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key + /// object or `/Names` array that several places reference only once (a second visit cannot + /// match, since the first would have returned). + fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> { + if depth > 32 || !seen.nodes.insert(node as *const _) { + return None; + } + let names = node.get(b"Names").ok(); + if let Some(names) = names + && self.resolved_id(names).is_none_or(|id| seen.objects.insert(id)) + && let Object::Array(pairs) = self.resolve(names) + { + for pair in pairs.chunks(2) { + if let [k, v] = pair { + if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) { + continue; + } + let found = match self.resolve(k) { + Object::String(bytes, _) => text_string(bytes) == key, + _ => key.is_empty(), + }; + if found { + return Some(v); + } + } + } + } + if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { + for k in kids { + if let Some(d) = self.dict(k) + && let Some(v) = self.name_tree_find(d, key, seen, depth + 1) + { + return Some(v); + } + } + } + None + } + // ── outline ───────────────────────────────────────────────────────────────────────────── /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, @@ -1141,21 +1229,44 @@ impl<'a> Inspector<'a> { // ── annotations ───────────────────────────────────────────────────────────────────────── - fn annotations(&self, info: &mut DocInfo) { - for (&id, &page) in &self.page_index { + /// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]. + fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) { + 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). Both are told apart by the object + // a reference resolves to, since an indirect object may itself hold a reference. + let mut seen_arrays = HashSet::new(); + let mut seen_annotations = 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(annots) = page_dict.get(b"Annots") + && let Some(r) = self.resolved_id(annots) + && !seen_arrays.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 Some(r) = self.resolved_id(a) + && !seen_annotations.insert(r) + { + continue; + } let Some(d) = self.dict(a) else { continue }; let Some(subtype) = self.name(d, b"Subtype") else { continue }; if subtype == "FileAttachment" && let Ok(fs) = d.get(b"FS") { - let name = self.text(d, b"Contents").unwrap_or_else(|| "attachment".into()); - info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index })); + let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget); + info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); } if subtype == "Link" { - if let Some(link) = self.link(page, index, d) { + if let Some(link) = self.link(page, index, d, text_budget) { if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { info.has_javascript = true; } @@ -1170,6 +1281,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 => { @@ -1209,7 +1323,7 @@ impl<'a> Inspector<'a> { } _ => Vec::new(), }; - info.annotations.push(Annotation { + let annotation = Annotation { page, subtype, author: self.text(d, b"T"), @@ -1228,13 +1342,19 @@ impl<'a> Inspector<'a> { intent: self.name(d, b"IT"), fill_sign, points, - }); + }; + kept = kept.saturating_add(retained(&annotation)); + if kept > MAX_ANNOTATION_BYTES { + comments_full = true; + continue; + } + info.annotations.push(annotation); } } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); } - fn link(&self, page: usize, index: usize, d: &Dictionary) -> Option { + fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); let target = if let Ok(dest) = d.get(b"Dest") { let (page, view) = self.dest(dest, 0)?; @@ -1247,7 +1367,11 @@ impl<'a> Inspector<'a> { LinkTarget::Page(page, view) } Some("URI") => { - LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?) + let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; + match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { + Some(uri) => LinkTarget::Uri(uri), + None => LinkTarget::Other("URI".into()), + } } Some("SetOCGState") => self.layer_state(a), Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), @@ -1578,7 +1702,14 @@ impl<'a> Inspector<'a> { out.sort_by(|a, b| a.name.cmp(&b.name)); } - fn attachment(&self, name: String, spec: &Object, source: AttachmentSource) -> Attachment { + /// The attachment's name is the file specification's, else `fallback`'s, else a generic one. + fn attachment( + &self, + spec: &Object, + source: AttachmentSource, + text_budget: &mut usize, + fallback: impl FnOnce(&mut usize) -> Option, + ) -> Attachment { let d = self.dict(spec); let size = d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then( @@ -1595,8 +1726,11 @@ impl<'a> Inspector<'a> { _ => None, }, ); - let display = d.and_then(|d| self.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name); - Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size, source } + let display = d + .and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget))) + .or_else(|| fallback(text_budget)) + .unwrap_or_else(|| "attachment".into()); + Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source } } } @@ -1606,6 +1740,42 @@ fn text_string(bytes: &[u8]) -> String { pdfcraft_cos::PdfString::literal(bytes).to_text() } +/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names` +/// array objects it reached by reference. +#[derive(Default)] +struct NameTreeSeen { + nodes: HashSet<*const Dictionary>, + objects: HashSet, +} + +/// A string or name value as text, without surrounding NULs and white space. +fn trimmed_text(o: &Object) -> Option { + let s = match o { + Object::String(bytes, _) => text_string(bytes), + Object::Name(n) => String::from_utf8_lossy(n).into_owned(), + _ => return None, + }; + Some(s.trim_matches('\0').trim().to_string()) +} + +/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw +/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes +/// out too long spends the raw length, so a string many entries share is converted only until the +/// budget runs out. +fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option { + if bytes.len() > *budget { + return None; + } + let text = convert(bytes); + if text.len() > *budget { + *budget = budget.saturating_sub(bytes.len()); + return None; + } + // The conversion's work follows the raw length, even when the text it keeps is shorter. + *budget = budget.saturating_sub(text.len().max(bytes.len())); + Some(text) +} + fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { if bytes.len() > max_bytes { return None; @@ -1678,8 +1848,7 @@ pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) - AttachmentSource::Document { key } => { let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?; let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?; - let mut seen = HashSet::new(); - insp.name_tree(ef, &mut seen, 0).into_iter().find(|(k, _)| k == key).map(|(_, v)| v).ok_or("attachment not found")? + insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")? } AttachmentSource::Annotation { page, index } => { let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?; @@ -2583,6 +2752,216 @@ 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); + } + + /// A link reached through an indirect object that holds only a reference to it is the same + /// annotation as the link itself, and so is a page array reached the same way: each is listed once. + #[test] + fn an_annotation_reached_through_a_reference_chain_is_listed_once() { + let pdf = "%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 7 0 R >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\ + 5 0 obj 6 0 R endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\ + 7 0 obj [5 0 R 6 0 R] endobj\n\ + 8 0 obj 7 0 R endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF"; + let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let mut saved = Vec::new(); + doc.save_to(&mut saved).expect("saves"); + let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + for reader in [&doc as &dyn Structure, &lazy] { + let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; + Inspector::new(reader).fill(&mut info); + assert_eq!(info.links.len(), 1, "{:?}", info.links); + } + } + + /// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw + /// length, so a string many entries share is converted only a few times. + #[test] + fn rejected_text_conversions_spend_the_budget() { + let mut budget = 10; + let mut conversions = 0; + for _ in 0..10 { + // Each 0xFF becomes the three-byte replacement character. + let kept = spend(&[0xFF; 5], &mut budget, |b| { + conversions += 1; + String::from_utf8_lossy(b).into_owned() + }); + assert_eq!(kept, None); + } + assert_eq!((conversions, budget), (2, 0)); + + let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF"; + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let d = Dictionary::from_iter([("Contents", Object::string_literal(" "))]); + let mut budget = 10; + assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget), None); + assert_eq!(budget, 7); + // Text that trims short spends its raw length. + let d = Dictionary::from_iter([("Contents", Object::string_literal(" x"))]); + let mut budget = 10; + assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget).as_deref(), Some("x")); + assert_eq!(budget, 3); + } + + /// An annotation attachment's `/Contents` spends the text budget only when it names the + /// attachment, so long descriptions of named files leave a later web address listed. + #[test] + fn a_named_attachment_leaves_its_contents_unspent() { + let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /Contents 6 0 R /FS << /F (a.txt) >> >> ".repeat(20); + // Longer than what 20 MiB of charged `/Contents` would leave of the 16 MiB budget. + let uri = format!("https://example.com/{}", "a".repeat(2 << 20)); + 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] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{files} \ + << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({uri}) >> >>] >> endobj\n\ + 6 0 obj ({}) endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + "x".repeat(1 << 20), + ); + 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], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + assert!(info.attachments.iter().all(|a| a.name == "a.txt")); + assert_eq!(info.links.len(), 1); + assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri)); + } + + /// Embedded files whose name-tree keys share one long string keep it, as key and as name, only + /// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`. + #[test] + fn embedded_file_keys_sharing_a_long_string_keep_it_within_a_budget() { + let entries = "4 0 R << /Type /Filespec >> ".repeat(20); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{entries}] >> >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\ + 4 0 obj ({}) endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + "x".repeat(1 << 20), + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let mut info = DocInfo::default(); + Inspector::new(&doc).fill(&mut info); + let kept: usize = + info.attachments.iter().map(|a| a.name.len() + if let AttachmentSource::Document { key } = &a.source { key.len() } else { 0 }).sum(); + assert!(!info.attachments.is_empty()); + // Past the budget an attachment still has the generic name. + let generic = info.attachments.len() * "attachment".len(); + assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES + generic, "{kept} bytes kept"); + } + + /// An embedded file the listing keeps is extracted, though earlier keys the listing could not + /// keep would fill a fresh key budget before it. + #[test] + fn a_listed_embedded_file_is_extracted_past_keys_the_listing_left_out() { + let skipped = "6 0 R 7 0 R ".repeat(9); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{skipped}(b.txt) 9 0 R] >> >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\ + << /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({}) >> >>] >> endobj\n\ + 6 0 obj ({}) endobj\n\ + 7 0 obj << /Type /Filespec /F (a.txt) >> endobj\n\ + 8 0 obj << /Type /EmbeddedFile /Length 5 >> stream\nhello\nendstream endobj\n\ + 9 0 obj << /Type /Filespec /F (b.txt) /EF << /F 8 0 R >> >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + "u".repeat(15 << 20), + "k".repeat(2 << 20), + ); + let bytes = Arc::new(pdf.into_bytes()); + let info = inspect(bytes.clone(), None).expect("opens"); + let listed = info.attachments.iter().find(|a| a.name == "b.txt").expect("b.txt is listed"); + assert_eq!(attachment_data(&bytes, None, listed).as_deref(), Ok(&b"hello"[..])); + } + + /// Links and attachments sharing one long string each keep a copy only within + /// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed. + #[test] + fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() { + const EACH: usize = 20; + let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH); + let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH); + 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] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links} {files}] >> endobj\n\ + 5 0 obj << /S /URI /URI 6 0 R >> endobj\n\ + 6 0 obj ({}) endobj\n\ + 7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + "x".repeat(1 << 20), + ); + 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], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + assert_eq!(info.links.len(), EACH); + assert_eq!(info.attachments.len(), EACH); + let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 }); + let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len)); + let kept: usize = uris.chain(descriptions).sum(); + assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept"); + assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI"))); + } + + /// 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. diff --git a/crates/render/src/structure.rs b/crates/render/src/structure.rs index f6a0ad5d45..bf9c16e23f 100644 --- a/crates/render/src/structure.rs +++ b/crates/render/src/structure.rs @@ -114,6 +114,11 @@ impl Structure for lopdf::Document { fn get_object(&self, id: ObjectId) -> Result<&Object> { self.get_object(id) } + // lopdf's `get_object` already follows an indirect object that holds a reference, so the + // trait's walk would report the first object of a chain. lopdf's own walk reports the last. + fn dereference<'a>(&'a self, object: &'a Object) -> Result<(Option, &'a Object)> { + lopdf::Document::dereference(self, object) + } fn encrypted(&self) -> bool { self.is_encrypted() || self.was_encrypted() || self.trailer.has(b"Encrypt") } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 45c87bfea7..11cde902f9 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -420,6 +420,15 @@ const MAX_CELLS: i64 = 4096; /// their unmarked redraws (the text reads twice, as it would without `/ActualText`). const MAX_REDRAW_WORK: usize = 1 << 22; +/// Most replacement text bytes the redraw dedupe in `TextDevice::finish` copies on one page. +const MAX_REDRAW_TEXT: usize = 1 << 22; + +#[cfg(test)] +thread_local! { + /// Replacement text bytes given to redraws on this thread. + static REDRAW_TEXT_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn cell(v: f32) -> i64 { // Glyph boxes are finite (`draw_glyph` and `merge` drop the rest); `as` saturates. (v / CELL).floor() as i64 @@ -564,6 +573,7 @@ impl TextDevice { redraws.sort_by_key(|(range, _)| range.start); let glyphs = std::mem::take(&mut self.glyphs); let mut redraws = redraws.into_iter().peekable(); + let mut text_left = MAX_REDRAW_TEXT; let mut i = 0; while let Some(g) = glyphs.get(i) { let Some((range, k)) = redraws.next_if(|(range, _)| range.start == i) else { @@ -572,7 +582,18 @@ impl TextDevice { continue; }; let run = glyphs.get(range.clone()).unwrap_or_default(); - let text = self.replaced.get(k).map(|r| r.text.clone()).unwrap_or_default(); + let replacement = self.replaced.get(k).map_or("", |r| r.text.as_str()); + // Each redraw takes its own copy of the replacement, and one long replacement can + // have many redraws: past the budget a redraw keeps its glyphs, as past the work budget. + let Some(left) = text_left.checked_sub(replacement.len()) else { + self.glyphs.extend_from_slice(run); + i = range.end.max(i + 1); + continue; + }; + text_left = left; + let text = replacement.to_owned(); + #[cfg(test)] + REDRAW_TEXT_BYTES.with(|c| c.set(c.get() + text.len())); if !text.chars().all(|c| c.is_control()) { match merge(run, text) { Some(glyph) => self.glyphs.push(glyph), @@ -1871,6 +1892,27 @@ mod tests { extract_page(pdf, page, &InterpreterSettings::default()).unwrap() } + #[test] + fn redraws_of_a_long_replacement_share_a_text_budget() { + let long = "0041".repeat(32 * 1024); + let redraws = 200; + let content = format!( + "BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <>> BDC (x) Tj EMC {} ET", + "1 0 0 1 100 150 Tm (x) Tj ".repeat(redraws) + ); + let pdf = marked_pages(&[&content]); + REDRAW_TEXT_BYTES.with(|c| c.set(0)); + let text = marked_text(&pdf, 0); + let given = REDRAW_TEXT_BYTES.with(|c| c.get()); + assert!(given <= MAX_REDRAW_TEXT, "{given} bytes of replacement text given to {redraws} redraws"); + assert!(text.plain_text().starts_with("AAAA")); + // A short replacement still covers its redraw. + let pdf = marked_pages(&["BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <> BDC (x) Tj EMC 1 0 0 1 100 150 Tm (x) Tj ET"]); + REDRAW_TEXT_BYTES.with(|c| c.set(0)); + assert_eq!(marked_text(&pdf, 0).plain_text(), "fi"); + assert_eq!(REDRAW_TEXT_BYTES.with(|c| c.get()), 2); + } + #[test] fn text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei() { // Everyday content both ways: a string with a byte above 0x7F, and a later word holding diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 82a801dee8..f4fe2c601e 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,27 +982,86 @@ fn finish_validation( } } +/// 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`] 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 = self.doc.resolve(&item); + let Object::Stream(s) = o.as_ref() else { continue }; + 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