diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a21cbdfd65..f94d7dbe40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: schedule: - cron: "17 3 * * *" # nightly fuzzing +# The token is only used to check out the code: no job writes to the repository. +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 @@ -74,7 +78,7 @@ jobs: run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1' shell: pwsh - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - name: Format if: runner.os == 'Linux' @@ -112,7 +116,7 @@ jobs: with: toolchain: stable targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: wasm32 - name: Check library crates for wasm32 @@ -136,7 +140,7 @@ jobs: - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: toolchain: stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: fuzz - name: Fetch the pdf.js corpus (seeds) diff --git a/Cargo.lock b/Cargo.lock index a950149243..9129e85d6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,9 +1291,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", "subtle", @@ -3936,7 +3936,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.11.0" +version = "0.13.0" dependencies = [ "chrono", "craft-quit", @@ -3962,7 +3962,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.11.0" +version = "0.13.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.11.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3985,7 +3985,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.11.0" +version = "0.13.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3996,7 +3996,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.11.0" +version = "0.13.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.11.0" +version = "0.13.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4036,18 +4036,19 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.11.0" +version = "0.13.0" [[package]] name = "pdfcraft-content" -version = "0.11.0" +version = "0.13.0" dependencies = [ + "hayro-syntax", "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.11.0" +version = "0.13.0" dependencies = [ "chrono", "hayro-syntax", @@ -4062,7 +4063,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.11.0" +version = "0.13.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4078,7 +4079,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.11.0" +version = "0.13.0" dependencies = [ "aes", "md-5", @@ -4090,7 +4091,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.11.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4105,7 +4106,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.11.0" +version = "0.13.0" dependencies = [ "chrono", "craft-docpath", @@ -4146,14 +4147,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.11.0" +version = "0.13.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.11.0" +version = "0.13.0" dependencies = [ "flate2", "proptest", @@ -4163,7 +4164,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.11.0" +version = "0.13.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4173,7 +4174,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.11.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4186,11 +4187,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.11.0" +version = "0.13.0" [[package]] name = "pdfcraft-js" -version = "0.11.0" +version = "0.13.0" dependencies = [ "boa_engine", "chrono", @@ -4198,7 +4199,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.11.0" +version = "0.13.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4211,14 +4212,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.11.0" +version = "0.13.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.11.0" +version = "0.13.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4228,7 +4229,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.11.0" +version = "0.13.0" dependencies = [ "image", "pdfcraft-annot", @@ -4241,11 +4242,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.11.0" +version = "0.13.0" [[package]] name = "pdfcraft-organize" -version = "0.11.0" +version = "0.13.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4256,7 +4257,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.11.0" +version = "0.13.0" dependencies = [ "async-channel", "futures-lite", @@ -4266,7 +4267,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.11.0" +version = "0.13.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4277,7 +4278,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.11.0" +version = "0.13.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4289,7 +4290,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.11.0" +version = "0.13.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4305,7 +4306,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.11.0" +version = "0.13.0" dependencies = [ "bytemuck", "flate2", @@ -4324,7 +4325,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.11.0" +version = "0.13.0" dependencies = [ "aes", "aws-lc-rs", @@ -4359,14 +4360,15 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.11.0" +version = "0.13.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.11.0" +version = "0.13.0" dependencies = [ "arboard", "base64 0.22.1", + "block2 0.6.2", "bytemuck", "chrono", "craft-quit", @@ -4390,6 +4392,7 @@ dependencies = [ "pdfcraft-platform", "pdfcraft-render", "pdfcraft-xfa", + "plist", "png 0.18.1", "pollster 1.0.1", "rfd", @@ -4404,11 +4407,12 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "winsafe", + "xattr", ] [[package]] name = "pdfcraft-web" -version = "0.11.0" +version = "0.13.0" dependencies = [ "eframe", "egui", @@ -4420,7 +4424,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.11.0" +version = "0.13.0" dependencies = [ "base64 0.22.1", "image", @@ -4434,7 +4438,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.11.0" +version = "0.13.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -4661,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" @@ -4888,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" @@ -7260,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 2012403144..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.11.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.11.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.11.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.11.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.11.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.11.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.11.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.11.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" } -pdfcraft-model = { path = "crates/model", version = "0.11.0" } -pdfcraft-content = { path = "crates/content", version = "0.11.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.11.0" } -pdfcraft-print = { path = "crates/print", version = "0.11.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.11.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" } -pdfcraft-js = { path = "crates/js", version = "0.11.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.11.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" } -pdfcraft-export = { path = "crates/export", version = "0.11.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.11.0" } -pdfcraft-create = { path = "crates/create", version = "0.11.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.11.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" } -pdfcraft-render = { path = "crates/render", version = "0.11.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.11.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.11.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.11.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/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 1a36d64bfd..4206839424 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1414,4 +1414,26 @@ mod tests { let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); assert_eq!(class, Some(super::APP_ID)); } + + // Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI + // launch on Windows), and a panic inside the panic hook aborts the process. The start-up + // path and the hook must write to standard error without it. + #[test] + fn start_up_and_the_panic_hook_never_use_eprintln() { + let source = include_str!("main.rs"); + // Everything before the top-level test module is product code. + let product = source.split("\n#[cfg(test)]\nmod tests {").next().unwrap_or(source); + assert!(product.len() < source.len(), "found the end of the product code"); + let offenders: Vec<_> = product + .lines() + .enumerate() + .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) + .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) + .collect(); + assert!( + offenders.is_empty(), + "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", + offenders.join("\n") + ); + } } diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index e4755ab385..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] { @@ -182,17 +183,51 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } } -/// Where the marked content `ids` is drawn on a page (images, forms and paths). -fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked +/// for, and the bounds of what has been drawn in it so far. +struct Mark { + id: Option, + drawn: Option<[f64; 4]>, +} + +/// Adds `b` to what the innermost open sequence has drawn. +fn draw(marks: &mut [Mark], b: [f64; 4]) { + if let Some(m) = marks.last_mut() { + m.drawn = Some(union(m.drawn, b)); + } +} + +/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it. +fn close(marks: &mut Vec, matching: &mut usize, out: &mut HashMap) { + let Some(m) = marks.pop() else { return }; + if m.id.is_some() { + *matching = matching.saturating_sub(1); + } + let Some(b) = m.drawn else { return }; + if let Some(id) = m.id { + let e = out.entry(id).or_insert(b); + *e = union(Some(*e), b); + } + draw(marks, b); +} + +/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass +/// over the page. What a sequence draws counts for every sequence of `ids` around it too. +pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> HashMap { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); + #[cfg(test)] + crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1)); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; - let mut marks: Vec = Vec::new(); - let mut bbox = None; + // The open marked-content sequences, and how many of them are one of `ids`. + let mut marks: Vec = Vec::new(); + let mut matching = 0usize; + let mut out = HashMap::new(); let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let inside = marks.iter().any(|m| *m); + crate::count_mark_reads(1); + let inside = matching > 0; let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); match op.op.as_slice() { b"q" => ctm.push(top), @@ -206,11 +241,13 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; *t = m.then(&top); } } - b"BMC" => marks.push(false), - b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), - b"EMC" => { - marks.pop(); + b"BMC" => marks.push(Mark { id: None, drawn: None }), + b"BDC" => { + let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n)); + matching += usize::from(id.is_some()); + marks.push(Mark { id, drawn: None }); } + b"EMC" => close(&mut marks, &mut matching, &mut out), b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { path.push(top.apply(x, y)); @@ -231,7 +268,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => { if inside && !matches!(op.op.as_slice(), b"n") { for (x, y) in &path { - bbox = Some(union(bbox, [*x, *y, *x, *y])); + draw(&mut marks, [*x, *y, *x, *y]); } } path.clear(); @@ -251,13 +288,16 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; } _ => top.bbox([0.0, 0.0, 1.0, 1.0]), }; - bbox = Some(union(bbox, unit)); + draw(&mut marks, unit); } - b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))), + b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])), _ => {} } } - bbox + while !marks.is_empty() { + close(&mut marks, &mut matching, &mut out); + } + out } /// The figures, in document order. @@ -266,14 +306,28 @@ pub fn figures(doc: &Document) -> Vec
{ let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let pages = pdfcraft_model::pages(doc); let index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - elements(doc) + let figures: Vec<(Element, Option)> = elements(doc) .into_iter() .filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure")) .map(|e| { let page = e.page.and_then(|p| index.get(&p).copied()); + (e, page) + }) + .collect(); + let mut wanted: HashMap> = HashMap::new(); + for (e, page) in &figures { + if let Some(p) = page { + wanted.entry(*p).or_default().extend(mcids(&e.dict)); + } + } + let bounds: HashMap> = + wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect(); + figures + .into_iter() + .map(|(e, page)| { let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())); - let ids: HashSet = mcids(&e.dict).into_iter().collect(); - let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids)); + let on_page = page.and_then(|p| bounds.get(&p)); + let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); Figure { obj: e.object, page, alt, bbox } }) .collect() @@ -322,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), } } @@ -421,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/headings.rs b/crates/a11y/src/headings.rs index 537ed908d7..41c749ed3c 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); + // The id each open marked-content sequence is in: its own, else the enclosing one's. let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); // Where each id's last glyph ended, in user space. @@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let id = stack.iter().rev().find_map(|m| *m); + crate::count_mark_reads(1); + let id = stack.last().copied().flatten(); match op.op.as_slice() { - b"BMC" => stack.push(None), - b"BDC" => stack.push(match op.operands.get(1) { - Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), - Some(Object::Name(n)) => { - props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + b"BMC" => stack.push(id), + b"BDC" => stack.push( + match op.operands.get(1) { + Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), + Some(Object::Name(n)) => { + props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + } + _ => None, } - _ => None, - }), + .or(id), + ), b"EMC" => { stack.pop(); } @@ -280,17 +285,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { }; for (code, len) in m.codes(&s.bytes) { let trm = tm.then(&gs.ctm); + // A glyph's text is only built while its id still takes more. if let Some(id) = id + && out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4) && let Some(glyph) = m.text_of(code) { let text = out.entry(id).or_default(); - if text.len() < MAX_TITLE * 4 { - let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); - if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { - text.push(' '); - } - text.push_str(&glyph); + let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); + if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { + text.push(' '); } + text.push_str(&glyph); } let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 }; tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm); diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index ff81cc4c97..280f4e845f 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -21,6 +21,22 @@ mod report; mod structure; mod tags; +#[cfg(test)] +thread_local! { + /// Marked-content stack entries read to decide which marks enclose an operator, for the tests + /// that keep that constant per operator. + static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Page content passes made to find figure bounds. + static BOUNDS_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` marked-content stack reads (tests only). +#[inline] +fn count_mark_reads(_n: usize) { + #[cfg(test)] + MARK_READS.with(|c| c.set(c.get().saturating_add(_n))); +} + pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; @@ -665,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 15ca643e83..399aded3cf 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1023,6 +1023,67 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String { ops + " ET" } +/// Marked-content stack reads while `f` runs. +fn mark_reads(f: impl FnOnce()) -> usize { + MARK_READS.with(|c| c.set(0)); + f(); + MARK_READS.with(|c| c.get()) +} + +/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. +fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Stream}; + let mut data = "/Figure << /MCID 0 >> BDC ".to_string(); + data.push_str(&"/X BMC ".repeat(depth)); + data.push_str(&"0 0 1 1 re f ".repeat(ops)); + data.push_str(&"EMC ".repeat(depth + 1)); + let content = doc.add(Stream::flate(Dict::new(), data.as_bytes())); + [(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect() +} + +#[test] +fn marked_text_reads_its_enclosing_id_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + let reads = mark_reads(|| { + crate::headings::mcid_texts(&doc, &page); + }); + // About 15 000 operators; a scan of the stack per operator reads millions of entries. + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + +#[test] +fn figure_bounds_read_the_marked_content_state_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + // No mark encloses figure 7, so a scan of the stack per operator reads all of it. + let reads = mark_reads(|| { + crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7])); + }); + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + +#[test] +fn figure_bounds_take_one_pass_over_each_page() { + let n = 50; + let content: Vec = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect(); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 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 >>".into(), + stream(&content.join(" ")), + format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")), + ]; + objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>"))); + let doc = pdf(&objs, ""); + BOUNDS_PASSES.with(|c| c.set(0)); + let f = figures(&doc); + assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes"); + assert_eq!(f.len(), n); + assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0]))); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); @@ -1191,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/lib.rs b/crates/annot/src/lib.rs index 6edfbf9cb7..d1d74ce410 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -665,17 +665,20 @@ pub fn page_refs(doc: &Document) -> Result, AnnotError> { /// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, /// never one entry per child, so a child listed many times or shared between nodes is skipped by /// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). -/// Cycles end at the seen check and at most a million nodes are visited. +/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once, +/// and at most a million nodes and four million `/Kids` entries are read. struct PageWalk<'a> { doc: &'a Document, seen: std::collections::HashSet, pending: Vec<(std::sync::Arc, usize)>, + /// `/Kids` entries read so far. + examined: usize, } impl<'a> PageWalk<'a> { fn new(doc: &'a Document, top: ObjRef) -> Self { let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); - Self { doc, seen: Default::default(), pending: vec![(top, 0)] } + Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 } } } @@ -690,8 +693,9 @@ impl Iterator for PageWalk<'_> { }; let node = kid.as_ref(); *next += 1; + self.examined += 1; let Some(node) = node else { continue }; - if self.seen.len() >= 1_000_000 { + if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 { self.pending.clear(); return None; } @@ -700,8 +704,15 @@ impl Iterator for PageWalk<'_> { } let obj = self.doc.get(node); let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids").map(|k| self.doc.resolve(k)) { - Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), + match d.get(b"Kids") { + // A `/Kids` array object shared by several nodes is read under the first: under + // the others it would only list nodes already seen. + Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => { + if self.seen.insert(*array) { + self.pending.push((self.doc.get(*array), 0)); + } + } + Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)), _ => return Some(node), } } @@ -717,7 +728,16 @@ fn page_ref(doc: &Document, page: usize) -> Result { fn annots(doc: &Document, page: ObjRef) -> Vec { let obj = doc.get(page); let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() }; - doc.resolve(a).as_array().cloned().unwrap_or_default() + let list = doc.resolve(a).as_array().cloned().unwrap_or_default(); + #[cfg(test)] + ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len())); + list +} + +#[cfg(test)] +thread_local! { + /// Entries copied out of pages' `/Annots` arrays. + static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Replace a page's `/Annots`, writing into the shared array object when it is indirect. diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 4d3eef632b..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), }); } @@ -232,6 +255,16 @@ fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> { /// Add a link over `rect` (user space). Returns its index in the page's `/Annots`. pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let p = page_ref(doc, page)?; + let obj = new_link(doc, p, rect, action, style)?; + let mut list = annots(doc, p); + list.push(Object::Ref(obj)); + let index = list.len() - 1; + set_annots(doc, p, list)?; + Ok(index) +} + +/// A link annotation object for page `p`, not yet on the page's `/Annots`. +fn new_link(doc: &mut Document, p: ObjRef, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let r = checked_rect(rect)?; if matches!(action, LinkAction::Other(_)) { return Err(AnnotError::Invalid("choose a page or a web address".into())); @@ -244,12 +277,7 @@ pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, d.set(b"F".to_vec(), Object::Int(4)); write_style(&mut d, style); write_action(doc, &mut d, action)?; - let obj = doc.add(Object::Dict(d)); - let mut list = annots(doc, p); - list.push(Object::Ref(obj)); - let index = list.len() - 1; - set_annots(doc, p, list)?; - Ok(index) + Ok(doc.add(Object::Dict(d))) } fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { @@ -414,12 +442,40 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { /// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per /// line rectangle). Returns how many links were added. pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result { + let pages = page_refs(doc)?; + // Check the whole batch first, so a bad entry fails it before any link object is created. + for (page, rects, uri) in links { + pages.get(*page).ok_or(AnnotError::NoSuchPage(*page))?; + if uri.trim().is_empty() { + return Err(AnnotError::Invalid("type the web address first".into())); + } + for r in rects { + checked_rect(*r)?; + } + } + // Each page's `/Annots` is extended once, with its new links in the order given. + let mut added: Vec<(ObjRef, Vec)> = Vec::new(); + let mut slot: std::collections::HashMap = std::collections::HashMap::new(); let mut n = 0; for (page, rects, uri) in links { + let p = pages.get(*page).copied().ok_or(AnnotError::NoSuchPage(*page))?; + let action = LinkAction::Uri(uri.clone()); for r in rects { - add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?; + let obj = new_link(doc, p, *r, &action, style)?; + let k = *slot.entry(p).or_insert_with(|| { + added.push((p, Vec::new())); + added.len() - 1 + }); + if let Some((_, list)) = added.get_mut(k) { + list.push(Object::Ref(obj)); + } n += 1; } } + for (p, new) in added { + let mut list = annots(doc, p); + list.extend(new); + set_annots(doc, p, list)?; + } Ok(n) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a659966812..b39e41c60a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1504,3 +1504,121 @@ fn typed_signature_uses_nonzero_winding_fill() { assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); } + +/// Many page tree nodes sharing one `/Kids` array read it once, not once per node. +#[test] +fn page_walk_reads_a_shared_kids_array_once() { + let mut doc = fixture(); + let root = ObjRef { num: 2, generation: 0 }; + let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 }); + let shared = doc.add(Object::Array([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(2_000).collect())); + let nodes: Vec = (0..1_000) + .map(|_| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Pages")); + d.set(b"Kids".to_vec(), Object::Ref(shared)); + Object::Ref(doc.add(d)) + }) + .collect(); + doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(nodes))).unwrap(); + let mut walk = PageWalk::new(&doc, root); + let pages: Vec = walk.by_ref().collect(); + assert_eq!(pages, [first, second]); + assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined); +} + +/// Adding many links to a page extends its `/Annots` once, not once per link. +#[test] +fn many_links_extend_a_page_annotation_list_once() { + let mut doc = fixture(); + let before = annots(&doc, page_refs(&doc).unwrap()[0]).len(); + let n = 2_000; + let rects: Vec<[f64; 4]> = (0..n).map(|i| [10.0, i as f64 * 0.1, 50.0, i as f64 * 0.1 + 5.0]).collect(); + ANNOTS_ENTRIES_COPIED.with(|c| c.set(0)); + let added = links::add_many(&mut doc, &[(0, rects, "https://example.com".into())], &links::LinkStyle::default()).unwrap(); + let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get); + assert_eq!(added, n); + assert_eq!(annots(&doc, page_refs(&doc).unwrap()[0]).len(), before + n); + assert!(copied <= 4 * n, "{copied} annotation entries copied for {n} links"); +} + +/// A batch with one bad entry adds nothing: no link objects are left behind unattached. +#[test] +fn a_link_batch_with_a_bad_entry_creates_no_objects() { + let mut doc = fixture(); + let page = page_refs(&doc).unwrap()[0]; + let before = (annots(&doc, page).len(), doc.scan_objects().count()); + let good = [10.0, 10.0, 50.0, 20.0]; + let style = links::LinkStyle::default(); + for bad in [ + vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good, [f64::NAN, 0.0, 10.0, 10.0]], "https://example.com".into())], + vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good], " ".into())], + vec![(0, vec![good], "https://example.com".to_string()), (99, vec![good], "https://example.com".into())], + ] { + assert!(links::add_many(&mut doc, &bad, &style).is_err()); + 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/README.md b/crates/audit/README.md index 37771a5350..cfa2979c21 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -33,7 +33,12 @@ Limits, by design and documented rather than hidden: less than 40% of its words (a sliver of a long value) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper -than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". +than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-word +comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no +findings". + +A document whose permissions withhold copying (Table 22, bit 5) still gets every finding, with +an empty `covered_text`: the audit hands out no text that copying would refuse. Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 3df666c190..041ba9ddd8 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> { @@ -144,10 +147,11 @@ impl<'a> Walker<'a> { match op.op.as_slice() { b"q" => stack.push(gs.clone()), b"Q" => { + // The current path is not graphics state (ISO 32000-2 §8.4.1): `re Q f` still + // fills the rectangle, whose corners are already in user space. if let Some(g) = stack.pop() { *gs = g; } - path = Path::Empty; } b"cm" => { let before = gs.ctm; @@ -258,7 +262,7 @@ impl<'a> Walker<'a> { let resolved = self.doc.resolve(entry); let Object::Stream(stream) = &*resolved else { return Ok(()) }; let dict = &stream.dict; - if dict.name(b"Subtype") != Some(b"Form") { + if crate::subtype(self.doc, dict)?.as_deref() != Some(b"Form") { return Ok(()); } let matrix = dict @@ -271,8 +275,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 +311,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 +322,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..f1f1350987 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -65,7 +65,7 @@ pub struct AuditFinding { /// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`. pub rect: [f64; 4], /// The extractable text the cover hides, lines joined with single spaces and trimmed. - /// Possibly empty when nothing decodable remains under the cover. + /// Empty when the document's permissions withhold copying (see [`audit_redactions`]). pub covered_text: String, } @@ -77,8 +77,14 @@ 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("page has more covers and text lines than the audit compares")] + TooManyComparisons, + #[error("covered text exceeds the audit's report budget")] + TooMuchOutput, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error(transparent)] @@ -95,47 +101,110 @@ const WORD_FRACTION: f64 = 0.5; const COVER_CONTRAST: f64 = 3.0; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any -/// content that cannot be decoded is returned as an error, never as an empty finding list. +/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is +/// returned as an error, never as an empty finding list. +/// +/// When the document's permissions withhold copying (ISO 32000-2 Table 22, bit 5), findings are +/// still reported but their `covered_text` is empty: the audit hands out no text that copying +/// would refuse. pub fn audit_redactions(doc: &Document) -> Result, AuditError> { + audit(doc, Limits::DEFAULT) +} + +/// Work and output bounds for one audit. +#[derive(Clone, Copy)] +struct Limits { + /// Cover-against-line comparisons per page, counting a line once per word box it has (at + /// least once), since [`covers`] walks them. + comparisons: usize, + /// Bytes of covered text the findings report. + covered_text: usize, +} + +impl Limits { + /// Every cover is compared with every line of its page, and each finding repeats the text + /// it covers, so both grow with the square of the content: a few megabytes of covers over + /// one long line would otherwise take hours and gigabytes. + const DEFAULT: Limits = Limits { comparisons: 50_000_000, covered_text: 64 << 20 }; +} + +/// The work and report the audit has spent against its [`Limits`]. +struct Budget { + limits: Limits, + comparisons: usize, + /// What comparing one cover with every line of the current page costs. + page_cost: usize, + covered_text: usize, +} + +impl Budget { + /// Resets the comparison count for a page with these `lines`. + fn start_page(&mut self, lines: &[TextLine]) { + self.comparisons = 0; + self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1))); + } + + /// The text of the `lines` that `hides`, joined with single spaces, or `None` when they show + /// no text. Without `copying` the text is empty and never assembled: one long line under many + /// covers would otherwise be copied once per cover, outside both bounds. + fn hidden_text(&mut self, lines: &[TextLine], copying: bool, hides: impl Fn(&TextLine) -> bool) -> Result, AuditError> { + self.comparisons = self.comparisons.saturating_add(self.page_cost); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + if !copying { + return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new)); + } + let mut covered = String::new(); + for line in lines.iter().filter(|l| hides(l)) { + if !covered.is_empty() { + covered.push(' '); + } + covered.push_str(line.text.trim()); + } + Ok((!covered.trim().is_empty()).then_some(covered)) + } + + /// The text a finding reports for `covered`: none when copying is withheld. Charges what it + /// reports against the output bound. + fn report_text(&mut self, covered: String, copying: bool) -> Result { + let reported = if copying { covered } else { String::new() }; + self.covered_text = self.covered_text.saturating_add(reported.len()); + if self.covered_text > self.limits.covered_text { + return Err(AuditError::TooMuchOutput); + } + Ok(reported) + } +} + +fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { + let copying = doc.permissions().is_none_or(|p| p.copy()); + let mut budget = Budget { limits, comparisons: 0, page_cost: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { - let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?; + // The glyphs themselves: `/ActualText` is the file's own claim about them, and an empty + // or substitute one must not hide text a cover sits on. + let lines = pdfcraft_edit::glyph_text_lines(doc, page).map_err(|e| AuditError::Text(n, e.to_string()))?; + budget.start_page(&lines); // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - if is_cover(&r, line) { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } - if !covered.trim().is_empty() { - out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered }); + if let Some(covered) = budget.hidden_text(&lines, copying, |line| is_cover(&r, line))? { + let covered_text = budget.report_text(covered, copying)?; + out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - let hidden = match &annot { - AnnotationCover::Redact(rect) => covers(rect, line), - AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, - }; - if hidden { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } - if !covered.trim().is_empty() { + if let Some(covered) = budget.hidden_text(&lines, copying, |line| match &annot { + AnnotationCover::Redact(rect) => covers(rect, line), + AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, + })? { let (kind, rect) = match annot { AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect), }; - out.push(AuditFinding { page: n, kind, rect, covered_text: covered }); + let covered_text = budget.report_text(covered, copying)?; + out.push(AuditFinding { page: n, kind, rect, covered_text }); } } } @@ -143,11 +212,13 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> } /// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly -/// after the line, overlaps it substantially, and is dark enough against the line's color to -/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the -/// contrast check and are not flagged. +/// after the start of the line, overlaps it substantially, and is dark enough against the line's +/// color to make the text unreadable. Light covers (table shading, highlights) over dark text fail +/// the contrast check and are not flagged. A line can join runs painted before and after the +/// cover; the later ones are drawn on it in the same low-contrast color, so they are unreadable +/// too. fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { - rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST + rect.pos > line.first_paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } /// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough: @@ -229,7 +300,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro continue; } let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; - match dict.name(b"Subtype") { + match subtype(doc, &dict)?.as_deref() { Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), Some(b"Square") => { let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); @@ -249,6 +320,16 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro Ok(out) } +/// The `/Subtype` name of `dict`, following an indirect reference. A reference that cannot be +/// loaded fails the audit: the subtype decides whether the object is checked at all. +fn subtype(doc: &Document, dict: &Dict) -> Result>, AuditError> { + let name = match dict.get(b"Subtype") { + Some(pdfcraft_cos::Object::Ref(r)) => doc.try_get(r.num)?.as_name().map(<[u8]>::to_vec), + other => other.and_then(pdfcraft_cos::Object::as_name).map(<[u8]>::to_vec), + }; + Ok(name) +} + fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> { let values: Vec = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::>()?; if !values.iter().all(|v| v.is_finite()) { diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 4ca3b0bf5b..3aca406e7c 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -592,3 +592,181 @@ fn reading_blocks_respect_stream_comments_and_split_operands() { assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); } } + +/// The current path is not graphics state: a rectangle built before `Q` and filled after it +/// paints the box, so it is a cover. +#[test] +fn rectangle_filled_after_restore_is_a_cover() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\nq 100 688 220 32 re Q f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} + +/// A `/Subtype` may be an indirect reference to the name, like any other value: a `/Redact` +/// annotation and a covering form XObject named that way are still checked. +#[test] +fn indirect_subtype_is_resolved() { + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n"); + b.add(FONT); + b.add("<< /Type /Annot /Subtype 7 0 R /Rect [95 685 305 725] >>"); + b.add("/Redact"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::UnappliedMark); + + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n"); + b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype 7 0 R /BBox [0 0 612 792]"); + b.add(FONT); + b.add("/Form"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); +} + +/// Text drawn after a cover on the same baseline joins the covered text in one line. The cover +/// still hides the words painted before it. +#[test] +fn later_text_on_the_line_does_not_clear_an_earlier_cover() { + let content = b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"; + let doc = open(one_page(content, "")); + let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); + assert_eq!(lines.len(), 1, "the runs join one line: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let findings = audit_redactions(&doc).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} + +/// Many covers over the same long line repeat its text in every finding: a small file must not +/// expand into an unbounded report. Past the budget the audit fails rather than truncate. +#[test] +fn covered_text_across_findings_is_bounded() { + let text = "X".repeat(60_000); + let mut content = format!("BT /F1 1 Tf 0 700 Td ({text}) Tj ET\n0 g\n").into_bytes(); + for i in 0..1_200 { + content.extend_from_slice(format!("-{i} 690 50000 20 re f\n").as_bytes()); + } + let result = audit_redactions(&open(one_page(&content, ""))); + assert!(matches!(result, Err(crate::AuditError::TooMuchOutput)), "{:?}", result.map(|f| f.len())); +} + +/// Covers compared against lines are bounded per page, so content that is quadratic in its +/// covers and lines fails the audit instead of running for hours. A line costs one comparison per +/// word: 20 covers against 20 two-word lines are 800. +#[test] +fn cover_and_line_comparisons_are_bounded() { + let mut content = Vec::new(); + for i in 0..20 { + content.extend_from_slice(format!("BT /F1 12 Tf 72 {} Td (Line {i}) Tj ET\n", 700 - 14 * i).as_bytes()); + } + for _ in 0..20 { + content.extend_from_slice(b"1 g 0 0 10 10 re f\n"); + } + let doc = open(one_page(&content, "")); + let limits = crate::Limits { comparisons: 799, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 800, covered_text: usize::MAX }; + assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); +} + +/// Covered text is document text: a document whose permissions withhold copying reports where +/// its covers are but not what they hide, unless it was opened with the owner password. +#[test] +fn copy_restricted_documents_withhold_covered_text() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n"; + let mut doc = open(one_page(content, "")); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .expect("encrypt"); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).expect("save")); + let user = Document::open_with_password(bytes.clone(), None).expect("open"); + let findings = audit_redactions(&user).expect("audit must run"); + assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); + assert_eq!(findings[0].covered_text, ""); + // The output bound counts the text reported, so withheld text costs nothing. + let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 }; + assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1); + let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); + let findings = audit_redactions(&owner).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); + let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() }; + assert!(crate::audit(&owner, exact).is_ok()); + let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; + assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); +} + +/// Withheld text is never assembled: covers over one long line would otherwise copy the line once +/// per cover, a volume neither the comparison nor the output bound charges. +#[test] +fn withheld_covered_text_is_not_assembled() { + let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", "")); + let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); + let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 }; + budget.start_page(&lines); + let withheld = budget.hidden_text(&lines, false, |_| true).expect("within the budget"); + assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it"); + assert_eq!(budget.hidden_text(&lines, false, |_| false).expect("within the budget"), None); + let copied = budget.hidden_text(&lines, true, |_| true).expect("within the budget"); + assert_eq!(copied.as_deref(), Some("SECRET DATA 123")); +} + +/// `/ActualText` is metadata the file's author controls: an empty or substitute replacement must +/// not hide the glyphs a cover sits on. +#[test] +fn a_cover_over_text_with_empty_or_substitute_actual_text_is_found() { + for actual in ["()", "(nothing here)"] { + let content = + format!("/Span << /ActualText {actual} >> BDC\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\nEMC\n0 g\n100 688 220 32 re f\n"); + let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{actual}: {findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{actual}: covered_text was {:?}", findings[0].covered_text); + } +} + +#[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/Cargo.toml b/crates/content/Cargo.toml index c553df0b8c..e6b4c28a42 100644 --- a/crates/content/Cargo.toml +++ b/crates/content/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +# The renderer's content tokenizer, already in the tree through pdfcraft-cos: inline images end +# where the renderer ends them. +hayro-syntax = { workspace = true } diff --git a/crates/content/README.md b/crates/content/README.md index 6ab984dd49..fedeadc56b 100644 --- a/crates/content/README.md +++ b/crates/content/README.md @@ -13,7 +13,10 @@ The parser is tolerant (unreadable bytes are skipped and counted, never fatal) a panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker and the `Patch` API of architecture §6 grow here. -Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their -sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes -that look like the terminator. Filtered images and resource-defined colour spaces retain -the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too. +An inline image ends where the renderer's tokenizer (`hayro-syntax`) ends it, so the parser +and the renderer read the same operators after the image, and redaction removes what the page +actually draws. Only where that tokenizer reads no image (its parameters don't read, or no `EI` +passes its checks) does the parser locate `EI` by its own rules: unfiltered images with built-in +or Indexed colour spaces, and image masks, by their sample layout (including per-row byte +padding), other images by the tolerant delimiter heuristic. Such an image is counted in +`Parsed::ambiguous_images`, because the operators after it may not be the ones the page draws. diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index eb126ffa21..bb478d18e9 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -60,6 +60,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it + /// accepts). Their end here is a guess, so the operators after them may not be the ones the + /// page draws. + pub ambiguous_images: usize, /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before /// and whether one runs out of it into the next. Only boundaries are recorded, so the size /// depends on the number of pieces, not of comments. @@ -74,95 +78,172 @@ 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, &[]) } +/// The operators of a (decoded) content stream, read one at a time, as [`parse`] reads them. A +/// caller that stops early or keeps only a summary never holds every operator of the stream. +pub fn ops(data: &[u8]) -> Ops<'_> { + Ops::new(data, &[]) +} + /// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment /// crosses. fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { - let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() }; - let mut boundary = 0; - let mut lx = Lexer::new(data, 0); - let mut operands: Vec = Vec::new(); - let mut start: Option = None; - loop { - let trivia = lx.pos; - lx.skip_ws(); - // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a - // string, a name, an array or dictionary operand or inline image data is not one. - if boundary < ends.len() { - comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges); + let mut reader = Ops::new(data, ends); + let ops = reader.by_ref().collect(); + Parsed { ops, skipped: reader.skipped, ambiguous_images: reader.ambiguous_images, comment_edges: reader.comment_edges } +} + +/// The iterator [`ops`] returns. +pub struct Ops<'a> { + data: &'a [u8], + lx: Lexer<'a>, + operands: Vec, + start: Option, + skipped: usize, + ambiguous_images: usize, + ends: &'a [usize], + boundary: usize, + comment_edges: Vec<(bool, bool)>, +} + +impl<'a> Ops<'a> { + fn new(data: &'a [u8], ends: &'a [usize]) -> Self { + Ops { + data, + lx: Lexer::new(data, 0), + operands: Vec::new(), + start: None, + skipped: 0, + ambiguous_images: 0, + ends, + boundary: 0, + comment_edges: vec![(false, false); ends.len()], } - let Some(&b) = data.get(lx.pos) else { break }; - let at = lx.pos; - let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); - if operand_start { - match lx.object() { - Ok(o) => { + } + + /// Bytes read so far that could not be read as operands or operators. + pub fn skipped(&self) -> usize { + self.skipped + } + + /// Inline images read so far whose end is this parser's guess (as in + /// [`Parsed::ambiguous_images`]). + pub fn ambiguous_images(&self) -> usize { + self.ambiguous_images + } +} + +impl Iterator for Ops<'_> { + type Item = Op; + + fn next(&mut self) -> Option { + let Ops { data, lx, operands, start, skipped, ambiguous_images, ends, boundary, comment_edges: edges } = self; + let (data, ends): (&[u8], &[usize]) = (data, ends); + loop { + let trivia = lx.pos; + lx.skip_ws(); + // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a + // string, a name, an array or dictionary operand or inline image data is not one. + if *boundary < ends.len() { + comment_edges(data, trivia..lx.pos, ends, boundary, edges); + } + let Some(&b) = data.get(lx.pos) else { + // Operands left without an operator. + *skipped += operands.len(); + operands.clear(); + return None; + }; + let at = lx.pos; + let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); + if operand_start { + match lx.object() { + Ok(o) => { + start.get_or_insert(at); + operands.push(o); + } + Err(_) if b == b'(' => { + // An unterminated string runs to the end of the stream. + *skipped += data.len() - at; + lx.pos = data.len(); + } + Err(_) => { + 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; + } + if is_delim(b) { + // A stray `)`, `>`, `]`, `{`, `}`. + lx.pos += 1; + *skipped += 1; + continue; + } + let kw = lx.token(); + if kw.is_empty() { + lx.pos = at + 1; + *skipped += 1; + continue; + } + match kw { + b"true" => { start.get_or_insert(at); - operands.push(o); + operands.push(Object::Bool(true)); } - Err(_) if b == b'(' => { - // An unterminated string runs to the end of the stream. - out.skipped += data.len() - at; - lx.pos = data.len(); + b"false" => { + start.get_or_insert(at); + operands.push(Object::Bool(false)); } - Err(_) => { - // Skip the offending byte and carry on. - lx.pos = at + 1; - out.skipped += 1; + b"null" => { + start.get_or_insert(at); + operands.push(Object::Null); } - } - continue; - } - if is_delim(b) { - // A stray `)`, `>`, `]`, `{`, `}`. - lx.pos += 1; - out.skipped += 1; - continue; - } - let kw = lx.token(); - if kw.is_empty() { - lx.pos = at + 1; - out.skipped += 1; - continue; - } - match kw { - b"true" => { - start.get_or_insert(at); - operands.push(Object::Bool(true)); - } - b"false" => { - start.get_or_insert(at); - operands.push(Object::Bool(false)); - } - b"null" => { - start.get_or_insert(at); - operands.push(Object::Null); - } - b"BI" => match inline_image(data, lx.pos) { - Ok((dict, img, end)) => { + b"BI" => { + let image = inline_image(data, at, lx.pos); + *ambiguous_images += usize::from(image.ambiguous); + let end = image.end; lx.pos = end; - out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); operands.clear(); + match image.image { + Some((dict, img)) => { + return Some(Op { + op: b"BI".to_vec(), + operands: Vec::new(), + inline: Some((dict, img)), + span: start.take().unwrap_or(at)..end, + }); + } + None => { + *skipped += end.saturating_sub(at); + *start = None; + } + } } - Err(end) => { - out.skipped += end.saturating_sub(at); - lx.pos = end; - start = None; - operands.clear(); + _ => { + let s = start.take().unwrap_or(at); + return Some(Op { op: kw.to_vec(), operands: std::mem::take(operands), inline: None, span: s..lx.pos }); } - }, - _ => { - let s = start.take().unwrap_or(at); - out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); } } } - out.skipped += operands.len(); - out } /// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and @@ -198,11 +279,29 @@ fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], bo } } -/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds -/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. -fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { +/// An inline image as [`inline_image`] reads it. +struct Inline { + /// Its parameters and data; `None` when the parameters can't be read. + image: Option<(Dict, Vec)>, + /// The position after its `EI` (or the end of the stream). + end: usize, + /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. + ambiguous: bool, +} + +/// Read the inline image whose `BI` keyword starts at `bi` (its parameters start at `pos`). +/// +/// Filtered image data has no length, and `EI` can occur inside it, so where it ends is a +/// heuristic choice among `EI` candidates. The renderer's choice decides which operators the +/// page draws after the image, and redaction must see the same ones, so the end is taken from +/// the renderer's tokenizer (hayro-syntax) rather than worked out here by another rule. Only +/// when that tokenizer reads no image does this parser fall back to its own rules, and the +/// image is marked ambiguous. +fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { let mut lx = Lexer::new(data, pos); let mut dict = Dict::new(); + let mut readable = true; + let mut stop = pos; loop { lx.skip_ws(); if lx.eat_keyword(b"ID") { @@ -216,22 +315,68 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize let Some((k, v)) = entry else { // A stream that ends inside the parameters is cut short, not unreadable: keep what was read. if at >= data.len() { - return Ok((dict, Vec::new(), data.len())); + return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false }; } - // Without an `EI` after it, the image runs to the end of the stream. - return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end)); + readable = false; + stop = at; + break; }; dict.set(k, v); } + if let Some((start, end_data, end)) = renderer_image_end(data, bi) { + // Where the renderer ends the data at an `EI` found by its heuristic, one whitespace byte + // before that `EI` separates it from the data. + let mut samples = data.get(start..end_data).unwrap_or_default(); + if data.get(end_data..).is_some_and(|rest| rest.starts_with(b"EI")) + && let Some((last, rest)) = samples.split_last() + && is_ws(*last) + { + samples = rest; + } + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + } + if !readable { + // Without an `EI` after it, the image runs to the end of the stream. + return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true }; + } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); let end = unfiltered_image_end(data, begin, &dict) .or_else(|| declared_image_end(data, begin, &dict)) .or_else(|| image_data_end(data, begin, filter_eod(&dict))); - Ok(match end { - Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), - None => (dict, data[begin..].to_vec(), data.len()), - }) + let (image, end) = match end { + Some((end_data, end)) => (data.get(begin..end_data).unwrap_or_default().to_vec(), end), + None => (data.get(begin..).unwrap_or_default().to_vec(), data.len()), + }; + Inline { image: Some((dict, image)), end, ambiguous: true } +} + +/// Where the renderer's tokenizer ends the inline image whose `BI` starts at `bi`: the start and +/// end of its data and the position after its `EI`. `None` when it reads no image there (its +/// parameters don't read, or no `EI` passes its checks). +fn renderer_image_end(data: &[u8], bi: usize) -> Option<(usize, usize, usize)> { + use hayro_syntax::content::TypedIter; + use hayro_syntax::content::ops::{InlineImage, TypedInstruction}; + let tail = data.get(bi..)?; + let mut iter = TypedIter::new(tail); + let (start, len) = match iter.next()? { + TypedInstruction::InlineImage(InlineImage(stream)) => match stream.raw_data() { + // The data is a slice of `tail`: its offset is where it starts. + std::borrow::Cow::Borrowed(samples) => ((samples.as_ptr() as usize).checked_sub(tail.as_ptr() as usize)?, samples.len()), + std::borrow::Cow::Owned(_) => return None, + }, + _ => return None, + }; + let end_data = start.checked_add(len)?; + // `committed` is past the whitespace after `EI`; the image ends at the `EI`. + let mut end = iter.committed(); + while end > end_data && tail.get(end - 1).is_some_and(|b| is_ws(*b)) { + end -= 1; + } + if end < end_data.checked_add(2)? || tail.get(end - 2..end) != Some(b"EI") { + return None; + } + Some((bi.checked_add(start)?, bi.checked_add(end_data)?, bi.checked_add(end)?)) } /// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any. @@ -491,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 094a0665df..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"; @@ -102,10 +148,11 @@ fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_compon let parsed = inline_pixels(parameters.as_bytes(), b" EI "); assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); assert_eq!(parsed.ops.len(), 3); - // Indexed samples permit at most 8 bits, even though device spaces permit 16. + // Indexed samples permit at most 8 bits, even though device spaces permit 16, so the + // length is unknown, and the renderer takes the second `EI`. let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16"); let parsed = inline_pixels(invalid.as_bytes(), b" EI "); - assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty()); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); } } @@ -127,7 +174,7 @@ fn unfiltered_inline_image_terminator_must_follow_its_samples() { } #[test] -fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { +fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() { for parameters in [ b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(), b"/W 4 /H 1 /BPC 8 /CS /ResourceColor", @@ -142,12 +189,23 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK", b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G", ] { + // What follows the first `EI` (`!!~>`) does not read as content, so the renderer takes + // the later one. let parsed = inline_pixels(parameters, b"! EI !!~>"); - assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters)); + let label = String::from_utf8_lossy(parameters); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"! EI !!~>", "{label}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"BI", b"Q"], "{label}"); + assert_eq!(parsed.ambiguous_images, 0, "{label}"); } - // A short payload still uses the tolerant recovery, including when there is no EI. - for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] { - assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc"); + // A short payload is read whole; without an `EI`, the renderer reads no image and this + // parser's end is a guess. + for (src, ambiguous) in [(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), 0), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc", 1)] { + let parsed = parse(src); + assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc"); + assert_eq!(parsed.ambiguous_images, ambiguous); + let mut reader = ops(src); + reader.by_ref().for_each(drop); + assert_eq!(reader.ambiguous_images(), ambiguous, "the streaming reader counts it too"); } } @@ -459,3 +517,38 @@ fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() { assert_eq!(img, b"!!!!>EI !!~>"); } } + +/// The operators the renderer's tokenizer reads from `data`. +fn renderer_operators(data: &[u8]) -> Vec> { + let mut iter = hayro_syntax::content::UntypedIter::new(data); + let mut out = Vec::new(); + while let Some(instruction) = iter.next() { + out.push(instruction.operator.as_ref().to_vec()); + } + out +} + +/// Inline images end where the renderer ends them, so both read the same operators after one: +/// an `EI` without whitespace before it in the data, upper-case text ending in "EI" after the +/// image, and an `EI` inside compressed bytes (#3579). +#[test] +fn inline_images_end_where_the_renderer_ends_them() { + let flate: Vec = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect(); + let mut stored = b"q BI /W 64 /H 1 /BPC 8 /CS /G /F /Fl ID ".to_vec(); + stored.extend_from_slice(&flate); + stored.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET"); + let fixtures: [&[u8]; 6] = [ + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (ZWEI) Tj (EIN FREI BEI) Tj ET", + b"q BI /W 2 /H 1 /CS /G /BPC 8 /F /AHx ID 7F7F> EI Q BT (DREI) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI", + b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID ! EI !!~>\nEI Q", + b"q BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q", + &stored, + ]; + for data in fixtures { + let parsed = parse(data); + let ours: Vec> = parsed.ops.iter().map(|op| op.op.clone()).collect(); + assert_eq!(ours, renderer_operators(data), "{}", String::from_utf8_lossy(data)); + assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data)); + } +} 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 768da4d1f1..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); @@ -383,17 +386,26 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { Some(b"CalGray") => Ok(Space::Gray), Some(b"CalRGB") => Ok(Space::Rgb), Some(b"Indexed" | b"I") => { - let base = space(doc, a.get(1))?; - if matches!(base, Space::Indexed(..)) { + // Checked before recursing: Indexed is the only space that recurses, so refusing + // an Indexed base here also ends a cyclic base reference. + let nested = match a.get(1).map(|b| doc.resolve(b)).as_deref() { + Some(Object::Array(b)) => matches!(b.first().and_then(Object::as_name), Some(b"Indexed" | b"I")), + Some(Object::Name(n)) => matches!(n.as_slice(), b"Indexed" | b"I"), + _ => false, + }; + if nested { return Err("nested indexed colour spaces aren't valid".into()); } + let base = space(doc, a.get(1))?; let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() { Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8, _ => return Err("an indexed colour space with an invalid hival".into()), }; 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/lib.rs b/crates/create/src/lib.rs index 5717977b1e..7d1fdfc366 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -393,17 +393,38 @@ fn rgba_image(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64)) -> Embedded { /// BMP and GIF (the first frame), through the `image` decoders. Their resolution is not read: /// 72 dpi, as for images without one. fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result { + // A GIF's first frame is painted on a canvas the size of its logical screen, however small + // the frame: a few bytes can ask for hundreds of MB. + if format == image::ImageFormat::Gif + && let Some(&[w0, w1, h0, h1]) = bytes.get(6..10) + && u64::from(u16::from_le_bytes([w0, w1])) * u64::from(u16::from_le_bytes([h0, h1])) > MAX_GIF_PIXELS + { + return Err(CreateError::Image(name.into(), "the GIF's screen is larger than 4096×4096 pixels".into())); + } let img = image::load_from_memory_with_format(bytes, format).map_err(|e| CreateError::Image(name.into(), e.to_string()))?; - let rgba = img.to_rgba8(); + let rgba = img.into_rgba8(); Ok(rgba_image(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0))) } +/// Largest GIF logical screen, in pixels (4096×4096). +const MAX_GIF_PIXELS: u64 = 1 << 24; + /// Most decoded bytes one TIFF page or PNG may take: room for the 768 MiB CMYK poster in #665, /// twice over. const MAX_DECODED_BYTES: usize = 2 << 30; -/// Every page of a TIFF (multi-page scans become multi-page PDFs). -fn tiff_pages(name: &str, bytes: &[u8]) -> Result, CreateError> { +/// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until +/// they are embedded, and many small directories can share one compressed strip. Saturates to the +/// whole address space on 32-bit targets (wasm32), where 4 GiB doesn't fit in `usize`. +const MAX_DECODED_TOTAL: usize = MAX_DECODED_BYTES.saturating_mul(2); + +/// The first `wanted` pages of a TIFF (multi-page scans become multi-page PDFs). +fn tiff_pages(name: &str, bytes: &[u8], wanted: usize) -> Result, CreateError> { + tiff_pages_within(name, bytes, wanted, MAX_DECODED_TOTAL) +} + +/// [`tiff_pages`], refused once the pages kept take more than `budget` bytes. +fn tiff_pages_within(name: &str, bytes: &[u8], wanted: usize, budget: usize) -> Result, CreateError> { use tiff::ColorType as C; use tiff::decoder::{Decoder, DecodingResult, Limits}; use tiff::tags::Tag; @@ -415,6 +436,7 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result, CreateError> { limits.decoding_buffer_size = MAX_DECODED_BYTES; let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| bad(e.to_string()))?.with_limits(limits); let mut out = Vec::new(); + let mut kept = 0usize; loop { let (w, h) = dec.dimensions().map_err(|e| bad(e.to_string()))?; let ct = dec.colortype().map_err(|e| bad(e.to_string()))?; @@ -469,8 +491,12 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result, CreateError> { } other => return Err(bad(format!("{other:?} TIFF images aren't supported yet"))), }; + kept = kept.saturating_add(page.data.len()).saturating_add(page.smask.as_ref().map_or(0, |(_, alpha)| alpha.len())); + if kept > budget { + return Err(bad(format!("the TIFF's pages take more than {} MiB together", budget >> 20))); + } out.push(page); - if !dec.more_images() { + if out.len() >= wanted || !dec.more_images() { break; } dec.next_image().map_err(|e| bad(e.to_string()))?; @@ -526,14 +552,15 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { (lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text) } -/// Detect the image format from its bytes; a TIFF may hold several pages. -fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { +/// Detect the image format from its bytes; a TIFF may hold several pages, of which the first +/// `wanted` are read. +fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { Ok(vec![jpeg(name, &upright_image::upright(bytes))?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) } else if is_tiff(bytes) { - tiff_pages(name, bytes) + tiff_pages(name, bytes, wanted) } else if bytes.starts_with(b"BM") { Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?]) } else if is_gif(bytes) { @@ -548,7 +575,7 @@ fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { /// Embed an image file (its first page, for TIFFs) as an image XObject in `doc`. Returns the /// object and the image's natural size in points (from its resolution). pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(ObjRef, (f64, f64)), CreateError> { - let img = embed(name, bytes)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?; + let img = embed(name, bytes, 1)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?; let size = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1); Ok((add_image(doc, img, &mut Vec::new()), size)) } @@ -580,7 +607,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec)], resolution: Ima } let mut doc = Document::new_empty(); let mut profiles = Vec::new(); - for img in images.iter().map(|(name, bytes)| embed(name, bytes)).collect::, _>>()?.into_iter().flatten() { + for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::, _>>()?.into_iter().flatten() { let dpi = match resolution { ImageResolution::Embedded => img.dpi, ImageResolution::Dpi(dpi) => (dpi, dpi), diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 6356105699..b6c7969c24 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(); @@ -1181,3 +1190,63 @@ fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { let img = image_of(&doc, 1); assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); } + +#[test] +fn a_cyclic_indexed_colour_space_is_refused() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + // 5 0 obj [/Indexed 5 0 R 255 <00>]: the base is the indexed space itself. + let cs = doc.add(Object::Null); + doc.set(cs, Object::Array(vec![Object::name("Indexed"), Object::Ref(cs), Object::Int(255), Object::String(PdfString::literal(vec![0]))])); + with_image(&mut doc, |_| { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"ColorSpace".to_vec(), Object::Ref(cs)); + Stream::from_raw(dict, vec![0]) + }); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped); +} + +/// A TIFF of `pages` 64×64 gray pages, then (with `unreadable`) one whose samples Create can't read. +fn gray_tiff(pages: usize, unreadable: bool) -> Vec { + let mut tif = std::io::Cursor::new(Vec::new()); + let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap(); + for _ in 0..pages { + enc.write_image::(64, 64, &[128; 64 * 64]).unwrap(); + } + if unreadable { + enc.write_image::(1, 1, &[0.5; 3]).unwrap(); + } + tif.into_inner() +} + +/// Many small IFDs can each decode to a large page: the pages a TIFF keeps in memory together +/// are bounded, and embedding one image reads only its first page. +#[test] +fn tiff_pages_are_decoded_within_a_total_budget() { + let tif = gray_tiff(3, false); + assert!(matches!(tiff_pages_within("t.tif", &tif, usize::MAX, 10_000), Err(CreateError::Image(..))), "12 KiB of pages past a 10 KB budget"); + assert_eq!(tiff_pages_within("t.tif", &tif, usize::MAX, 20_000).unwrap().len(), 3); + let tif = gray_tiff(1, true); + let mut doc = Document::new_empty(); + assert!(image_xobject(&mut doc, "t.tif", &tif).is_ok(), "the unreadable last page is never decoded"); +} + +/// A one-pixel GIF on a `w`×`h` logical screen: the decoder paints it on a canvas that size. +fn gif_on_screen(w: u16, h: u16) -> Vec { + let mut gif = b"GIF89a".to_vec(); + gif.extend_from_slice(&w.to_le_bytes()); + gif.extend_from_slice(&h.to_le_bytes()); + gif.extend_from_slice(&[0x80, 0, 0, 0xFF, 0xFF, 0xFF, 0, 0, 0]); + gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 1, 0, 0x3B]); + gif +} + +/// The decoder's own limit lets a 74-byte GIF ask for a canvas of hundreds of MB, which the +/// conversion then copies; a screen past [`MAX_GIF_PIXELS`] is refused before decoding. +#[test] +fn a_tiny_gif_on_a_huge_screen_is_refused() { + assert!(matches!(from_images(&[("big.gif".into(), gif_on_screen(4200, 4100))]), Err(CreateError::Image(..)))); + let doc = reopen(&from_images(&[("small.gif".into(), gif_on_screen(40, 30))]).unwrap()); + assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]); +} diff --git a/crates/create/src/upright_image.rs b/crates/create/src/upright_image.rs index 9816dfc4b2..b89c517190 100644 --- a/crates/create/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -14,6 +14,12 @@ const QUALITY: u8 = 92; /// Larger photos are embedded as they are: turning them would take hundreds of MB. const MAX_PIXELS: u64 = 64 << 20; +#[cfg(test)] +thread_local! { + /// How many JPEGs [`turned`] handed to the decoder on this thread. + static DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded); /// everything else, and any JPEG this can't read, is returned unchanged. pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { @@ -24,9 +30,11 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { } fn turned(bytes: &[u8]) -> Option> { - if !bytes.starts_with(&[0xFF, 0xD8]) { + if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS { return None; } + #[cfg(test)] + DECODES.with(|c| c.set(c.get().saturating_add(1))); let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?; let orientation = decoder.orientation().ok()?; let (w, h) = decoder.dimensions(); @@ -53,6 +61,41 @@ fn turned(bytes: &[u8]) -> Option> { Some(out) } +/// Most Extended XMP chunks a JPEG handed to the decoder may carry (64 MB of metadata). The +/// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number. +const MAX_EXTENDED_XMP_CHUNKS: usize = 1024; + +/// How many Extended XMP chunks (APP1 segments) come before the image data. +fn extended_xmp_chunks(bytes: &[u8]) -> usize { + const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; + let (mut i, mut chunks) = (2usize, 0usize); + while let Some(&byte) = bytes.get(i) { + // Stray bytes between segments, which the decoder skips too. + if byte != 0xFF { + i += 1; + continue; + } + let Some(&marker) = bytes.get(i.saturating_add(1)) else { break }; + match marker { + // Fill bytes before a marker. + 0xFF => i += 1, + // Markers without a segment. + 0x00 | 0x01 | 0xD0..=0xD7 => i += 2, + // The image data starts (or ends): no headers after it. + 0xDA | 0xD9 => break, + _ => { + let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break }; + let len = usize::from(u16::from_be_bytes([hi, lo])); + if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) { + chunks += 1; + } + i = i.saturating_add(2).saturating_add(len.max(2)); + } + } + } + chunks +} + /// The resolution in a JFIF header, if the file has one in inches or centimetres. fn jfif_density(bytes: &[u8]) -> Option { let mut i = 2usize; @@ -94,4 +137,26 @@ mod tests { assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } + + #[test] + fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() { + // Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every + // chunk so far after each marker, quadratic in their number. + let chunk = |n: usize| { + let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); + seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID + seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length + seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset + let mut out = vec![0xFF, 0xE1]; + out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); + out.extend_from_slice(&seg); + out.repeat(n) + }; + let photo = phone_jpeg(6, 144); + let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat(); + DECODES.with(|c| c.set(0)); + assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo"); + assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_))); + assert_eq!(DECODES.with(std::cell::Cell::get), 1, "only the ordinary photo reached the decoder"); + } } 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/added.rs b/crates/edit/src/added.rs index d246df725b..d39bb0fa91 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -235,9 +235,17 @@ pub fn lines(t: &AddedText) -> Vec { wrapped(t).into_iter().map(|(line, _)| line).collect() } +#[cfg(test)] +thread_local! { + /// How often [`wrapped`] laid a text out on this thread, for the test that keeps it out of per-line loops. + pub(crate) static WRAP_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// [`lines`], each with whether its paragraph runs right to left (its first strong character /// does). fn wrapped(t: &AddedText) -> Vec<(String, bool)> { + #[cfg(test)] + WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1))); let width = (t.rect[2] - t.rect[0]).max(t.size); let mut out = Vec::new(); for para in t.text.split('\n') { @@ -780,7 +788,8 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: let r = text_rect(t); let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes()); - for (i, line) in lines(t).iter().enumerate() { + let all = lines(t); + for (i, line) in all.iter().enumerate() { let w = t.family.width(line, t.size, t.bold, t.italic); let x = match t.align { Align::Left | Align::Justify => r[0], @@ -790,7 +799,6 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: // Baseline: 0.8 em below the line top. let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; // Justified: word spacing makes every line but the last fill the box. - let all = lines(t); let spaces = line.matches(' ').count(); let tw = if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; @@ -1042,6 +1050,9 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option) -> R } let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let (content, res) = draw(doc, c, view, &taken, obj.is_some())?; + if obj.is_none() { + crate::check_on_top(doc, p)?; + } for (k, v) in res.iter() { let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default(); for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) { 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/flatten.rs b/crates/edit/src/flatten.rs index 3ac5468057..8aa1b0e320 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -9,7 +9,7 @@ //! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays //! as it is, so no comment or field value is lost with nothing in its place. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -129,7 +129,16 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi None } +/// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on +/// success, so a page refused part way leaves the pages before it as they were. fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { + let mut work = doc.clone(); + let drawn = flatten_in(&mut work, pages, which)?; + *doc = work; + Ok(drawn) +} + +fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result { let all = page_list(doc); check(pages, all.len())?; // `?` in an appearance can still be fixed (a field's value, a comment's text are stored as @@ -295,6 +304,12 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = const { std::cell::Cell::new(0) }; +} + /// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With /// `on_page`, also the widgets (fields without kids) that are on no page. fn prune_fields(doc: &mut Document, widgets: &HashSet, on_page: Option<&HashSet>) -> Result<(), EditError> { @@ -303,23 +318,59 @@ fn prune_fields(doc: &mut Document, widgets: &HashSet, on_page: Option<& let af_ref = af.as_ref(); let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default(); let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - // Returns whether the node survives. - fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, on_page: Option<&HashSet>, depth: usize) -> bool { - if widgets.contains(&r) || depth > 64 { - return depth > 64; + // Returns whether the node survives, and whether that answer depends on the depth cutoff. + // `known` holds each node's answer once it is visited, so a node listed twice is pruned once, + // and one still being visited (a cycle) survives. An answer the cutoff decided records the depth + // it was reached at, and a shallower path visits the node again: at most once per depth. + fn keep( + doc: &mut Document, + r: ObjRef, + widgets: &HashSet, + on_page: Option<&HashSet>, + known: &mut HashMap)>, + depth: usize, + ) -> (bool, bool) { + #[cfg(test)] + KEEP_CALLS.with(|c| c.set(c.get().saturating_add(1))); + if widgets.contains(&r) { + return (false, false); } - let Some(d) = doc.get(r).as_dict().cloned() else { return true }; + if depth > 64 { + return (true, true); + } + match known.get(&r) { + Some(&(survives, None)) => return (survives, false), + Some(&(survives, Some(at))) if depth >= at => return (survives, true), + _ => {} + } + known.insert(r, (true, None)); + let Some(d) = doc.get(r).as_dict().cloned() else { return (true, false) }; let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { - return on_page.is_none_or(|p| p.contains(&r)); + let survives = on_page.is_none_or(|p| p.contains(&r)); + known.insert(r, (survives, None)); + return (survives, false); }; - let survivors: Vec = - kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect(); + let mut cut = false; + let survivors: Vec = kids + .iter() + .filter(|k| { + k.as_ref().is_none_or(|kr| { + let (survives, kid_cut) = keep(doc, kr, widgets, on_page, known, depth + 1); + cut |= kid_cut; + survives + }) + }) + .cloned() + .collect(); if survivors.len() != kids.len() { let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone()))); } - !survivors.is_empty() + known.insert(r, (!survivors.is_empty(), cut.then_some(depth))); + (!survivors.is_empty(), cut) } - let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect(); + let mut known = HashMap::new(); + let survivors: Vec = + fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, &mut known, 0).0)).cloned().collect(); if survivors.len() == fields.len() && widgets.is_empty() { return Ok(()); // only looked for fields on no page, and there were none } 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/lib.rs b/crates/edit/src/lib.rs index d3db00fe30..630f12550b 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -17,7 +17,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; #[derive(Debug, thiserror::Error, PartialEq)] @@ -492,6 +492,9 @@ enum Isolation { /// the count of `q` left open at the end, when known. `marked` counts known unfinished /// marked-content sequences independently of graphics malformation, or zero when unknown. Unknown { unclosed: usize, marked: usize }, + /// Marked content nests past [`ISOLATION_MAX_DEPTH`]: how many sequences a mark on top would + /// have to close (some may be hidden optional content) is unknown. + TooDeep, } const ISOLATION_MAX_STREAMS: usize = 100_000; @@ -591,7 +594,10 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation { _ => {} } } - if malformed || text_open || path_open || !stack.is_empty() || !marked_known { + if !marked_known { + return Isolation::TooDeep; + } + if malformed || text_open || path_open || !stack.is_empty() { return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked }; } let comment_open = edges.last().is_some_and(|edge| edge.1); @@ -601,15 +607,16 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation { /// Put a mark's content on a page: behind (with the behind-marks at the start) or on top /// (appended, after wrapping the original content in q/Q). fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { - place_tagged(doc, page, kind.tag(), content, behind) + place_tagged(doc, page, kind.tag(), content, behind).map(drop) } /// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the /// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including /// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of /// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content -/// sequences are closed at the end too, so hidden optional content cannot hide the new mark. -fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { +/// sequences are closed at the end too, so hidden optional content cannot hide the new mark; when +/// their number is unknown, nothing is placed on top. Returns the new content stream. +fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result { let mut list = contents(doc, page)?; // The behind-marks at the start: backgrounds in the order added, then watermarks. let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; @@ -623,9 +630,12 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont // behind-marks at the start. A `q` it leaves open is closed at its end, where // closing it changes nothing already drawn. Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)), + // Content left inside an optional-content sequence it cannot close could be hidden. + Isolation::TooDeep => return Err(too_deep_on_top()), } }; - let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); + let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content))); + let mark = Object::Ref(mark_ref); if behind { // Right after the backgrounds already there, so a new background is painted over the // older ones (#3591) and a watermark over all of them. Not after the watermarks: on a @@ -646,7 +656,27 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont list.push(mark); } doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; - Ok(()) + Ok(mark_ref) +} + +fn too_deep_on_top() -> EditError { + EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into()) +} + +/// The refusal [`place_tagged`] gives for a mark on top of `page`, checked before an edit changes +/// anything (removing old marks, adding resources) so a refused edit leaves the document as it was. +fn check_on_top(doc: &Document, page: &pdfcraft_model::Page) -> Result<(), EditError> { + let obj = doc.get(page.obj); + let list = match obj.as_dict().and_then(|d| d.get(b"Contents")) { + None => return Ok(()), + Some(Object::Ref(r)) => doc.get(*r).as_array().cloned().unwrap_or_else(|| vec![Object::Ref(*r)]), + Some(Object::Array(a)) => a.clone(), + Some(other) => vec![other.clone()], + }; + match isolation(doc, &list) { + Isolation::TooDeep => Err(too_deep_on_top()), + _ => Ok(()), + } } /// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original @@ -664,9 +694,11 @@ fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &s /// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica /// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes /// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes. -pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result<(), EditError> { +/// Returns the new content stream. +pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result { let all = page_list(doc); check(&[page], all.len())?; + check_on_top(doc, &all[page])?; add_resources(doc, &all[page], None, Some(&content))?; let p = page_list(doc).swap_remove(page); place_tagged(doc, &p, tag, content, false) @@ -746,6 +778,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, .iter() .map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose()) .collect::, _>>()?; + for &i in pages { + check_on_top(doc, &all[i])?; + } if replace { remove_marks(doc, pages, MarkKind::HeaderFooter)?; } @@ -834,6 +869,11 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac { return Err(EditError::Invalid("invalid watermark settings".into())); } + if !wm.behind { + for &i in pages { + check_on_top(doc, &all[i])?; + } + } if replace { remove_marks(doc, pages, MarkKind::Watermark)?; } @@ -938,7 +978,7 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu if before != kept.len() { let wrappers = match isolation(doc, &kept) { Isolation::Clean { wrappers, .. } => wrappers, - Isolation::Unknown { .. } => { + Isolation::Unknown { .. } | Isolation::TooDeep => { let mut opens = Vec::new(); let mut pairs = Vec::new(); for (i, o) in kept.iter().enumerate() { @@ -994,7 +1034,7 @@ mod linebreak; pub mod text; pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, - clear_block, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, + clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; pub mod added; pub use added::{ diff --git a/crates/edit/src/marked_isolation_tests.rs b/crates/edit/src/marked_isolation_tests.rs index 6da8322296..227371bcac 100644 --- a/crates/edit/src/marked_isolation_tests.rs +++ b/crates/edit/src/marked_isolation_tests.rs @@ -96,7 +96,7 @@ fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() { } #[test] -fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() { +fn depth_budget_bounds_emitted_closures_and_overflow_is_refused_on_top() { let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH); let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH); expected.extend_from_slice(WRAP_CLOSE); @@ -114,9 +114,46 @@ fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() { place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap(); assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers"); + // Past the budget the depth is unknown, so nothing can close the page's (possibly hidden) + // optional content before a mark on top: placing one there is refused, and the page is left + // as it was. Behind the content stays possible. for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] { let mut over = maximum.clone(); over.extend_from_slice(suffix); - assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC"); + let mut doc = fixture(&[&over]); + let page = page_list(&doc).remove(0); + let original = contents(&mut doc, &page).unwrap(); + assert!( + matches!(stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()), Err(EditError::Invalid(_))), + "overflow cannot resume speculative depth tracking after EMC" + ); + assert_eq!(contents(&mut doc, &page).unwrap(), original); + place_tagged(&mut doc, &page, MarkKind::Background.tag(), b"q Q\n".to_vec(), true).unwrap(); } } + +/// Marks refused on top of content nested past the depth budget leave the page as it was: old +/// marks are not removed and no resources are added before the refusal. +#[test] +fn a_mark_refused_on_top_changes_nothing() { + let mut over = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH); + over.extend_from_slice(b"/Artifact BMC\n"); + let mut doc = fixture(&[&over]); + let behind = Watermark { text: "OLD".into(), behind: true, ..Watermark::default() }; + add_watermark(&mut doc, &[0], &behind, false).unwrap(); + let page = page_list(&doc).remove(0).obj; + let before = (*doc.get(page)).clone(); + let on_top = Watermark { text: "NEW".into(), ..Watermark::default() }; + assert!(matches!(add_watermark(&mut doc, &[0], &on_top, true), Err(EditError::Invalid(_)))); + assert_eq!((*doc.get(page)).clone(), before, "the old watermark stays"); + let hf = HeaderFooter { + text: [String::new(), "Top".into(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Times, + ..HeaderFooter::default() + }; + assert!(matches!(add_header_footer(&mut doc, &[0], &hf, true, &Context::default()), Err(EditError::Invalid(_)))); + assert_eq!((*doc.get(page)).clone(), before, "no fonts are added"); + let mut stamped = doc.clone(); + assert!(matches!(stamp(&mut stamped, 0, "Probe", b"/PCTimes 9 Tf\n".to_vec()), Err(EditError::Invalid(_)))); + assert_eq!((*stamped.get(page)).clone(), before, "no fonts are added"); +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index cf3860237b..aa785024e4 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6355,3 +6355,237 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ] ); } + +/// Text set larger than the widest page PDF allows (14 400 pt) panicked when its paragraph was given +/// a width: the width is clamped between the font size and 14 400 pt, and `f64::clamp` panics when +/// its minimum is the larger. The width is now capped at 14 400 pt whatever the font size: from the +/// file (`Tf`, or a small `Tf` scaled up by `Tm`) or asked for (the automation tools pass it through). +#[test] +fn width_on_a_paragraph_set_larger_than_a_page_is_capped_not_a_panic() { + let wide = text::BlockStyle { width: Some(200.0), ..Default::default() }; + let huge = text::BlockStyle { size: Some(20_000.0), width: Some(200.0), ..Default::default() }; + for (content, style) in [ + ("BT /F2 20000 Tf 72 700 Td (ab) Tj ET", &wide), + ("BT /F2 1 Tf 20000 0 0 20000 72 700 Tm (ab) Tj ET", &wide), + ("BT /F2 12 Tf 72 700 Td (ab) Tj ET", &huge), + ] { + let mut doc = text_page(content); + text::rewrite_block(&mut doc, 0, 0, None, style).unwrap(); + } +} + +#[test] +fn added_text_is_wrapped_once_per_write() { + let mut doc = fixture(); + let lines: Vec = (0..40).map(|i| format!("line {i}")).collect(); + let text = AddedText { rect: [72.0, 100.0, 300.0, 700.0], text: lines.join("\n"), size: 10.0, align: Align::Justify, ..AddedText::default() }; + added::WRAP_CALLS.with(|c| c.set(0)); + add_content(&mut doc, 0, &Content::Text(text)).unwrap(); + let calls = added::WRAP_CALLS.with(std::cell::Cell::get); + assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines"); +} + +/// A field tree whose every node lists its child twice is pruned in work linear in its nodes, not +/// in its paths, and a node listing itself is visited once. +#[test] +fn flattening_prunes_a_field_tree_with_repeated_kids_once_per_node() { + let levels = 16; + let page = stream("", " "); + // 5: the flattened widget; 6…: the chain; then the hidden widget at its end, on the page. + let leaf = 6 + levels; + let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R {} 0 R] >> >>", leaf + 2); + let annots = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R {leaf} 0 R] >>"); + let mut objs: Vec = vec![ + fields, + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + annots, + page, + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N {} 0 R >> >>", leaf + 1), + ]; + for i in 0..levels { + let next = 7 + i; + objs.push(format!("<< /T (n{i}) /Kids [{next} 0 R {next} 0 R] >>")); + } + objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 >>".into()); + objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into()); + objs.push(format!("<< /T (loop) /Kids [{} 0 R] >>", leaf + 2)); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let mut doc = build(&objs); + flatten::KEEP_CALLS.with(|c| c.set(0)); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1); + let calls = flatten::KEEP_CALLS.with(std::cell::Cell::get); + assert!(calls <= 2 * (levels + 4), "{calls} field-tree visits"); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + let left = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().len(); + assert_eq!(left, 2, "the chain to the hidden widget and the self-listing node stay"); +} + +/// A field reached first at the end of a long chain has its kids cut off by the depth limit, so +/// they survive there. A shorter path to the same field still looks at its kids: an off-page widget +/// under it is taken out when every field is flattened. +#[test] +fn a_field_reached_again_by_a_shorter_path_is_pruned_there() { + let chain = 64; + let page = stream("", " "); + // 7…: the chain; then the shared field, its off-page widget, and the short path to it. + let shared = 7 + chain; + let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 7 0 R {} 0 R] >> >>", shared + 2); + let mut objs: Vec = vec![ + fields, + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(), + page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into(), + ]; + for i in 0..chain { + objs.push(format!("<< /T (c{i}) /Kids [{} 0 R] >>", 8 + i)); + } + objs.push(format!("<< /T (shared) /Kids [{} 0 R] >>", shared + 1)); + objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 >>".into()); + objs.push(format!("<< /T (short) /Kids [{shared} 0 R] >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1); + let shared_ref = pdfcraft_cos::ObjRef::new(u32::try_from(shared).unwrap(), 0); + let kids = doc.resolve(doc.get(shared_ref).as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().len(); + assert_eq!(kids, 0, "the off-page widget is pruned along the short path"); +} + +/// A ToUnicode destination is repeated for every glyph that shows its code: a page whose text +/// would run past the extraction budget is refused, not built in memory. +#[test] +fn page_text_past_the_budget_is_refused() { + let cmap = stream("", &format!("1 beginbfchar <41> <{}> endbfchar", "0041".repeat(256))); + let page = |shown: &str| { + let content = stream("", &format!("BT /F1 12 Tf 10 10 Td ({shown}) Tj ET")); + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + &content, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>", + &cmap, + ]) + }; + let doc = page(&"A".repeat(100_000)); + assert!(matches!(text_lines(&doc, 0), Err(EditError::Invalid(_))), "25 MB of text from a 100 kB page"); + let doc = page("AA"); + assert_eq!(text_lines(&doc, 0).unwrap()[0].text.len(), 512, "a page within the budget still reads"); +} + +/// 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); +} + +/// A page that can't take flattened content on top (marked content nested past the depth budget) +/// refuses the whole flatten: the pages before it keep their comments and content. +#[test] +fn a_flatten_refused_on_a_later_page_changes_nothing() { + let deep = stream("", &"/Artifact BMC\n".repeat(10_001)); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>", + &stream("", " "), + "<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 7 0 R /Annots [8 0 R] >>", + &deep, + "<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 0 >>\nstream\n\nendstream", + ]); + let first = pdfcraft_cos::ObjRef::new(3, 0); + let before = (*doc.get(first)).clone(); + assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_)))); + assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99e..d003935314 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; } @@ -528,12 +530,13 @@ impl TextLine { self.origin.baseline } - /// Paint position of the line's last text-showing operator: `(content-stream index, - /// operator index in the page's joined stream)`. Lines paint in order of this tuple. + /// Paint position of the line's first operator: `(content-stream index, operator index in + /// the page's joined stream)`. A line joins runs painted at different times, so anything + /// painted after this position may lie on top of part of it. /// (Lines from `text_lines` never come from form XObjects, so `stream` is always a /// page content-stream index and `ops` are indices into the joined operator list.) - pub fn paint_pos(&self) -> (usize, usize) { - (self.stream, self.ops.iter().max().copied().unwrap_or(0)) + pub fn first_paint_pos(&self) -> (usize, usize) { + (self.stream, self.ops.iter().min().copied().unwrap_or(0)) } fn rotation(&self) -> f64 { @@ -615,7 +618,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 +635,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 @@ -887,8 +903,15 @@ struct Carry { marked: Vec>, /// For each open marked-content sequence, its optional content group when it is `/OC`. layers: Vec>>, + /// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]). + text_left: usize, } +/// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is +/// copied for every glyph showing its code, so a small page can otherwise expand into gigabytes; +/// real pages hold far less (a whole novel is about 1 MB). +const MAX_PAGE_TEXT: usize = 16 << 20; + impl Carry { fn new() -> Self { let ts = Ts { @@ -907,7 +930,7 @@ impl Carry { vertical: None, unreadable: None, }; - Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() } + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT } } } @@ -1119,7 +1142,13 @@ fn interpret( let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); match shown { - Some(t) => text.push_str(&t), + Some(t) => { + carry.text_left = carry + .text_left + .checked_sub(t.len()) + .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; + text.push_str(&t); + } None => decodable = false, } let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; @@ -1277,17 +1306,29 @@ fn interpret( /// The lines of text on a page (0-based), in content order. pub fn text_lines(doc: &Document, page: usize) -> Result, EditError> { - let p = page_dict(doc, page)?; + text_lines_in(doc, &page_dict(doc, page)?, true) +} + +/// The lines `page`'s glyphs show, in content order, without `/ActualText` replacements: what the +/// page draws, not what its marked-content metadata says the glyphs mean. For checks that must +/// not trust that metadata (the redaction audit). Takes the page itself, so a caller going +/// through every page doesn't walk the page tree again for each. +pub fn glyph_text_lines(doc: &Document, page: &pdfcraft_model::Page) -> Result, EditError> { + text_lines_in(doc, page, false) +} + +/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans. +fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> Result, EditError> { let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut lines: Vec = Vec::new(); 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))?; + let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, actual_text.then_some(&properties))?; let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended for mut s in shown { // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a @@ -1490,15 +1531,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 +1568,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 +1595,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 @@ -1578,7 +1630,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E let mut forms = invoked.into_iter().peekable(); for s in shown { while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) { - enter(w, resources, &name, ts)?; + enter(w, resources, &name, ts, &mut carry.text_left)?; // Text after a form starts a new line. last = None; } @@ -1590,21 +1642,23 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E add_shown(&mut w.lines, &mut last, s, si); } for (_, name, ts) in forms { - enter(w, resources, &name, ts)?; + enter(w, resources, &name, ts, &mut carry.text_left)?; } Ok(()) } - fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) -> Result<(), EditError> { + /// `text_left`: the page's text budget, shared with the forms the page draws. + fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts, text_left: &mut usize) -> Result<(), EditError> { let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) }; let ops = parse(&form.data).ops; // The form starts with the graphics state at its `Do`, its own matrix applied. - let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() }; + let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left }; let stream = w.next_stream; w.next_stream = w.next_stream.saturating_add(1); if let Some(r) = form.obj { w.path.push(r); } walk(w, &ops, &form.resources, &mut carry, &|_| stream)?; + *text_left = carry.text_left; if form.obj.is_some() { w.path.pop(); } @@ -1612,8 +1666,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 +2290,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 +2495,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 +2541,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 +2719,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. @@ -2722,8 +2780,9 @@ fn rewrite_block_from( // A paragraph keeps its width (or takes the one asked for); a single line grows to the right // (to the left when right-to-left), up to the page's margin. let width = match style.width { - // Capped at the largest page PDF allows (14 400 pt). - Some(w) if w.is_finite() => w.clamp(size * k, 14_400.0), + // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that + // (`clamp` panics when its minimum is the larger, or NaN). + Some(w) if w.is_finite() => w.clamp((size * k).min(14_400.0), 14_400.0), Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), // Turned text: the page's margins are along another axis. A single line grows (below). None if !upright => (frame[2] - frame[0]).max(size * k), @@ -3044,7 +3103,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 +3192,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/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c19e1e42c8..cfee2e3baa 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -596,6 +596,19 @@ mod tests { assert!(menus >= 3); } + // Regression test for #2028: Edit text & images sat under "Add content", though it changes + // what is already on the page; it belongs first under "Modify page". + #[test] + fn edit_text_and_images_is_the_first_item_under_modify_page() { + let edit = group("edit").expect("the Edit a PDF tool"); + let section_of = |command: &str| edit.sections.iter().find(|s| s.items.iter().any(|i| i.command == command)).map(|s| s.title); + assert_eq!(section_of("edit.edit_text"), Some("Modify page"), "Edit text & images is a Modify page tool"); + let modify = edit.sections.iter().find(|s| s.title == "Modify page").expect("a Modify page section"); + assert_eq!(modify.items.first().map(|i| i.command), Some("edit.edit_text"), "and the first one, so it is not below the fold"); + let add = edit.sections.iter().find(|s| s.title == "Add content").expect("an Add content section"); + assert!(add.items.iter().all(|i| i.command != "edit.edit_text"), "it adds nothing, so it is not listed under Add content"); + } + #[test] fn every_update_and_remove_command_is_reachable_from_a_tool_entry() { let reachable: HashSet<&str> = diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index c23db91f1f..1326ccb5e8 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -1,8 +1,11 @@ //! Compare files: the text differences between two open documents, a report, and the //! differences marked as comments in the newer one. +use std::collections::HashMap; use std::sync::Arc; +use pdfcraft_cos::ObjRef; + pub use pdfcraft_compare::{Change, Comparison, Kind, Side}; // How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages // that draw more. @@ -10,6 +13,10 @@ pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape}; +/// Most bytes of encoded pictures one Office export prepares, counting each image object once +/// (the Word and PowerPoint writers keep at most 512 MiB of them as well). +pub const MAX_EXPORT_IMAGE_BYTES: usize = 512 << 20; + /// Acrobat's compare colours: replaced blue, inserted green, deleted red. pub fn colour(kind: Kind) -> crate::Rgb { match kind { @@ -163,23 +170,32 @@ pub struct OfficeExport { /// The exported file. pub bytes: Vec, /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them - /// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so - /// the text and images of those forms are missing from `bytes`. + /// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops + /// reading there to stay bounded on hostile files, so the text and images of those forms, or + /// those pictures, are missing from `bytes`. pub truncated_pages: Vec, } +/// An image object prepared for Office export: its file extension and encoded bytes, or why it is +/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded). +type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>; + impl crate::Document { /// The pages as paragraphs and images (for Word, HTML and RTF export), including what /// form XObjects draw. pub fn export_pages(&self) -> Vec { - self.export_pages_checked().0 + self.export_pages_checked(true, MAX_EXPORT_IMAGE_BYTES).0 } - /// [`Self::export_pages`], and the pages whose forms were left partly unread - /// ([`OfficeExport::truncated_pages`]). - fn export_pages_checked(&self) -> (Vec, Vec) { + /// [`Self::export_pages`], and the pages whose forms were left partly unread or whose + /// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without + /// `images` no picture is prepared. + pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (Vec, Vec) { let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; let mut truncated = Vec::new(); + // Each image object is encoded once and shared by every place that draws it. + let mut encoded: HashMap = HashMap::new(); + let mut image_bytes = image_budget; let pages = self .info .pages @@ -197,9 +213,7 @@ impl crate::Document { let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height }); let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }; let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default(); - if reading.truncated { - truncated.push(i); - } + let forms_unread = reading.truncated; let blocks = reading .blocks .into_iter() @@ -217,13 +231,21 @@ impl crate::Document { } }) .collect(); - let images = pdfcraft_edit::reading_images(cos, i) - .unwrap_or_default() + let placed = if images { pdfcraft_edit::reading_images(cos, i).unwrap_or_default() } else { Vec::new() }; + let mut over_budget = false; + let images = placed .iter() .filter_map(|im| { - let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?; + let object = im.object?; + let prepared = encoded.entry(object).or_insert_with(|| { + let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?; + image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?; + Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes))) + }); + over_budget |= matches!(prepared, Err(true)); + let (ext, bytes) = prepared.clone().ok()?; Some(pdfcraft_export::Image { - ext: if ext == "jpg" { "jpg" } else { "png" }, + ext, bytes, rect: visible_rect(im.rect), size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit], @@ -231,6 +253,9 @@ impl crate::Document { }) }) .collect(); + if forms_unread || over_budget { + truncated.push(i); + } // The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one. let background = pdfcraft_edit::page_background(cos, i).ok().flatten(); pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background } @@ -247,7 +272,8 @@ impl crate::Document { /// [`Self::export_office`], and the pages whose text it had to leave out /// ([`OfficeExport::truncated_pages`]). pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { - let (pages, truncated_pages) = self.export_pages_checked(); + // RTF leaves pictures out. + let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf, MAX_EXPORT_IMAGE_BYTES); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a2c444bd62..a59743dbbb 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -129,8 +129,16 @@ pub struct JsRunner { } impl JsRunner { + /// A runner for `cos`: what scripts read about the document (pages, info, whether it is + /// an XFA form) and its document-level scripts, read once. pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { - let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) }; + let doc = pdfcraft_js::DocInfo { + file_name: file_name.to_string(), + num_pages: pdfcraft_model::pages(cos).len(), + page: 0, + info: info(cos), + xfa: pdfcraft_xfa::xfa_shape(cos).is_some(), + }; JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), @@ -289,6 +297,12 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende .collect() } +#[cfg(test)] +thread_local! { + /// Characters read to tell underscore runs from other words. + static UNDERSCORE_CHARS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A page's words in reading order, their boxes mapped from view space by `map` (and ordered /// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them). pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> { @@ -296,6 +310,8 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 let mut last_line = u32::MAX; // A space glyph ends the word before it. let mut gap = false; + // Whether the last word is an underscore run (a form's blank), which other glyphs don't join. + let mut last_under = false; for (i, g) in text.glyphs.iter().enumerate() { let line = text.line_of.get(i).copied().unwrap_or(0); if g.text.trim().is_empty() { @@ -303,10 +319,9 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 continue; } let under = g.text.chars().all(|c| c == '_'); - let breaks = match words.last() { - None => true, - Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under, - }; + #[cfg(test)] + UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len())); + let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under; gap = false; last_line = line; match words.last_mut() { @@ -317,7 +332,10 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 r[2] = r[2].max(g.rect[2]); r[3] = r[3].max(g.rect[3]); } - _ => words.push((g.text.clone(), g.rect)), + _ => { + words.push((g.text.clone(), g.rect)); + last_under = under; + } } } words @@ -409,6 +427,27 @@ impl Session { mod review_tests { use super::*; + /// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with + /// the word so far without reading the word again. + #[test] + fn a_long_underscore_run_is_read_in_one_pass() { + let n = 10_000; + let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph { + text: t.into(), + rect: [i as f32, 0.0, i as f32 + 1.0, 10.0], + quad: [[0.0; 2]; 4], + direction: [1.0, 0.0], + }; + let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect(); + glyphs.push(glyph(n, "x")); + let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs }; + UNDERSCORE_CHARS_READ.with(|c| c.set(0)); + let words = words_with(&text, |x, y| [x, y]); + assert_eq!(words.iter().map(|(w, _)| w.len()).collect::>(), [n, 1]); + let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get); + assert!(read <= 4 * n, "{read} characters read for {n} glyphs"); + } + #[test] fn duplicate_field_names_diff_against_first_original() { let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 817eb79a96..f257865d3a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -985,16 +985,17 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and /// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`, +/// /Widths, whose advances the interpreter approximates; a CMap /// that it reads differently), a mark drawn over the text as displayed lies over glyphs the /// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is /// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its /// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps /// the document as it was. fn shown_under_redactions( after: &pdfcraft_cos::Document, marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], pages: &[pdfcraft_render::PageInfo], password: Option<&str>, ) -> Result<(), EditError> { @@ -1011,8 +1012,7 @@ fn shown_under_redactions( for &pi in &marked { let Some(page) = model.get(pi) else { continue }; let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = - list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect(); + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; } let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; @@ -2356,6 +2356,9 @@ struct EditCtx { /// Fields the edit renamed to keep imported names separate (old → new), for the repair /// log and the undo label. renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + redaction_overlays: Vec, } impl EditCtx { @@ -2382,6 +2385,7 @@ impl EditCtx { seed, count: 0, renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), } } @@ -2736,7 +2740,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; } Edit::ApplyRedactions { pages } => { - pdfcraft_redact::apply(doc, pages.as_deref())?; + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; } Edit::ClearRedactions => { pdfcraft_redact::clear_marks(doc, None)?; @@ -3798,6 +3802,7 @@ impl Session { cx.js = Some(js::JsRunner::new(&next, &name)); } cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); // `next` is a copy: if the edit fails or crashes, the document is unchanged. guard(|| run_edit(&mut next, &edit, &mut cx)) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; @@ -3806,7 +3811,7 @@ impl Session { if let Edit::ApplyRedactions { pages } = &edit { let marks: Vec = pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref())) + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; } // Renamed fields are said, not slipped in: a repair-log note (with the caveat that diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3d347e6492..c8f0eb8861 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2253,12 +2253,17 @@ fn setting_the_calculation_order_recalculates_and_undoes() { /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { + one_line_page_with(resources, "", body) +} + +/// [`one_line_page`] with `stream_entries` added to the content stream's dictionary. +fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), - format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -2289,31 +2294,76 @@ fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) { s.apply(id, mark).unwrap(); } -/// Redaction removes text by the positions its own interpreter works out. Here the font and size -/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does -/// not model, so it places no glyph under the mark the user drew over the word as displayed: it -/// used to report success and leave the word in the file. The renderer is asked now, and the edit -/// is refused. +/// Redaction removes text by the positions its own interpreter works out, and the renderer has +/// the last word: where the two disagree, a page that still shows a glyph mostly under a mark +/// fails the edit. The check is run here on a page nothing was removed from, as if the +/// interpreter had placed the word elsewhere. Only the overlay streams an apply added are left +/// out: a content stream of the input labelled like one (`/PCMark /Redaction`) is checked too. #[test] -fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { +fn the_renderer_check_refuses_text_still_shown_under_a_mark() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page_with("/Font << /F1 4 0 R >>", "/PCMark /Redaction", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET"); + let id = s.open("shown.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let doc = s.get(id).unwrap(); + let mut cos = doc.editor.as_ref().unwrap().cos.clone(); + let marks = pdfcraft_redact::marks(&cos); + let refused = |cos: &pdfcraft_cos::Document| { + let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None); + matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0) + }; + assert!(refused(&cos)); + // The page's contents as an array, as they are once an apply has added its overlay. + let page = pdfcraft_model::pages(&cos).swap_remove(0); + let contents = page.dict.get(b"Contents").cloned().unwrap(); + cos.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(vec![contents]))).unwrap(); + assert!(refused(&cos), "the labelled input stream was not checked"); +} + +/// Inline image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` the renderer ends the image at, so it +/// runs the `cm` and shows SECRET 200 pt to the right. Redaction reads the image the same way, so +/// a mark drawn over the word as shown takes it out of the file. +#[test] +fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page( + "/Font << /F1 4 0 R >>", + "BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + ); + let id = s.open("inline.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + assert!(!page_texts(&s, id).concat().contains("SECRET")); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(6).any(|w| w == b"SECRET"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); +} + +/// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer +/// draws it, so a mark drawn over it as displayed takes it out of the file. +#[test] +fn a_word_in_a_font_set_by_an_extgstate_is_redacted() { let line = "Public words then SECRET42 and more"; let mut s = Session::new().with_clock(|| 1_700_000_000); let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET")); let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap(); mark_shown_text(&mut s, id, "SECRET42"); - let refused = s.apply(id, Edit::ApplyRedactions { pages: None }); - assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}"); - // Nothing was changed: the mark is still pending and the page still shows the line. - assert_eq!(s.get(id).unwrap().redaction_marks(), 1); - assert_eq!(page_texts(&s, id), [line]); - - // The same line with its font set by `Tf`: the mark is applied and the word is gone. - let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET")); - let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap(); - mark_shown_text(&mut s, id, "SECRET42"); s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); let shown = page_texts(&s, id).swap_remove(0); assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); } #[test] @@ -4026,6 +4076,20 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +/// XFA forms check `xfa_installed` and `xfa_version` in a document script that runs before +/// each of their scripts, and alert when either is missing. +#[test] +fn scripts_in_an_xfa_form_see_the_xfa_plugin_installed() { + let check = "typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let xfa = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))), None).unwrap(); + let o = s.run_javascript(xfa, check, None).unwrap(); + assert_eq!((o.error, o.result.as_deref()), (None, Some("boolean number 3.5"))); + let plain = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let o = s.run_javascript(plain, check, None).unwrap(); + assert_eq!((o.error, o.result.as_deref()), (None, Some("undefined number 3.5"))); +} + #[test] fn edit_history_does_not_replay_field_script_side_effects() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -6645,7 +6709,7 @@ fn exporting_a_jpeg_logo_to_word_keeps_the_image() { assert!(jpeg.starts_with(&[0xFF, 0xD8])); let docx = d.export_office(compare::OfficeFormat::Docx); assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}"); - assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})"); + assert!(docx.windows(jpeg.len()).any(|w| w == &jpeg[..]), "the JPEG is stored unchanged (overlap {overlaps})"); } } @@ -7233,3 +7297,49 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { let positions: Vec<_> = entries.iter().map(|entry| html.find(&format!("

{entry}

")).expect("one paragraph per entry")).collect(); assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); } + +/// A page whose forms go past the reading limit and whose picture goes past the image budget is +/// listed once among the truncated pages. +#[test] +fn a_page_truncated_for_forms_and_pictures_is_listed_once() { + let depth = compare::MAX_FORM_DEPTH + 1; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + String::new(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\x20\x40\x7f\nendstream".into(), + "<< /Length 34 >>\nstream\nq 10 0 0 10 0 0 cm /Im Do Q /D1 Do\nendstream".into(), + ]; + // Forms D1..D{depth}, objects 6 onwards, each drawing the next. + let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", 5 + i)).collect(); + let resources = format!("<< /XObject << /Im 4 0 R {xobjects}>> >>"); + for i in 1..=depth { + let body = if i == depth { "q Q".to_string() } else { format!("/D{} Do", i + 1) }; + objs.push(format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream", + body.len() + )); + } + objs[2] = format!("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources {resources} >>"); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("both.pdf", None, Arc::new(bytes), None).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.export_pages_checked(false, 0).1, [0], "the forms alone truncate the page"); + let (exported, _) = doc.export_pages_checked(true, 1 << 20); + assert_eq!(exported[0].images.len(), 1, "the picture exports within a sufficient budget"); + let (pages, truncated) = doc.export_pages_checked(true, 0); + assert!(pages[0].images.is_empty(), "the picture is past the budget"); + assert_eq!(truncated, [0]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 15e1a8548b..8d9026600d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -8,5 +8,6 @@ mod document_fonts; mod form_rotation; mod marked_content_isolation; mod ocr_edit; +mod office_export_images; mod private_image_resize; mod private_text_glyphs; diff --git a/crates/engine/tests/it/office_export_images.rs b/crates/engine/tests/it/office_export_images.rs new file mode 100644 index 0000000000..4f2ff618fb --- /dev/null +++ b/crates/engine/tests/it/office_export_images.rs @@ -0,0 +1,43 @@ +//! Office export of pages that draw one image many times. + +use std::sync::Arc; + +use pdfcraft_engine::Session; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.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", objects.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", objects.len() + 1).as_bytes()); + out +} + +/// One image drawn many times is prepared once: every placement shares its encoded bytes. +#[test] +fn an_image_drawn_many_times_is_encoded_once() { + let placements = 500; + let content = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(placements); + let bytes = 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 >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{40}\u{80}\u{ff}\nendstream".into(), + ]); + let mut s = Session::new(); + let id = s.open("many.pdf", None, Arc::new(bytes), None).unwrap(); + let pages = s.get(id).unwrap().export_pages(); + let images = &pages[0].images; + assert_eq!(images.len(), placements); + let first = images[0].bytes.as_ptr(); + assert!(!images[0].bytes.is_empty()); + assert!(images.iter().all(|im| im.bytes.as_ptr() == first), "each placement holds its own copy of the image"); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index f3030ad58d..8be472e98a 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -8,6 +8,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::HashMap; + mod pptx; mod zip; @@ -35,7 +37,8 @@ pub struct Block { pub struct Image { /// "png" or "jpg". pub ext: &'static str, - pub bytes: Vec, + /// Shared: a picture drawn in many places is held once. + pub bytes: std::sync::Arc<[u8]>, pub rect: [f64; 4], /// Width and height before rotation, in points. pub size: [f64; 2], @@ -85,14 +88,33 @@ enum Item<'a> { PageBreak, } +#[cfg(test)] +thread_local! { + /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. + static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` entries read by a lookup (tests only). +#[inline] +fn count_lookup_steps(_n: usize) { + #[cfg(test)] + LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); +} + /// The body text size: the size most of the document's characters are set in. fn body_size(pages: &[Page]) -> f64 { + // Sizes in first-seen order (which breaks ties), and where each one is in it. let mut by: Vec<(i64, usize)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); for b in pages.iter().flat_map(|p| &p.blocks) { let k = (b.size * 2.0).round() as i64; - match by.iter_mut().find(|e| e.0 == k) { - Some(e) => e.1 += b.text.len(), - None => by.push((k, b.text.len())), + count_lookup_steps(1); + let i = *slot.entry(k).or_insert_with(|| { + by.push((k, 0)); + by.len() - 1 + }); + if let Some(e) = by.get_mut(i) { + e.1 = e.1.saturating_add(b.text.len()); } } by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) @@ -831,9 +853,12 @@ pub fn rtf(pages: &[Page]) -> String { let items = items(pages); // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); let mut note = |c: [f64; 3]| { let c = rgb8(c); - if c != [0, 0, 0] && !colors.contains(&c) { + count_lookup_steps(1); + if c != [0, 0, 0] && !slot.contains_key(&c) { + slot.insert(c, colors.len()); colors.push(c); } }; @@ -847,7 +872,8 @@ pub fn rtf(pages: &[Page]) -> String { // `\cfN` for a colour (nothing for black, which keeps the default). let cf = |c: [f64; 3]| { let c = rgb8(c); - colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + count_lookup_steps(1); + slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) }; let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); if !colors.is_empty() { @@ -911,6 +937,46 @@ pub fn rtf(pages: &[Page]) -> String { mod tests { use super::*; + /// A page of `n` one-word blocks, each in its own size and colour. + fn varied_page(n: usize) -> Page { + let blocks = (0..n) + .map(|i| Block { + text: "word".into(), + rect: [72.0, i as f64, 100.0, i as f64 + 1.0], + size: 4.0 + i as f64 * 0.5, + bold: false, + italic: false, + color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], + rotation: 0.0, + }) + .collect(); + Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } + } + + fn lookup_steps(f: impl FnOnce()) -> usize { + LOOKUP_STEPS.with(|c| c.set(0)); + f(); + LOOKUP_STEPS.with(|c| c.get()) + } + + #[test] + fn many_distinct_sizes_are_counted_in_constant_steps_each() { + let pages = [varied_page(3000)]; + let steps = lookup_steps(|| { + body_size(&pages); + }); + assert!(steps <= 2 * 3000, "{steps} steps"); + } + + #[test] + fn many_distinct_colours_take_constant_steps_each() { + let pages = [varied_page(3000)]; + let mut out = String::new(); + let steps = lookup_steps(|| out = rtf(&pages)); + assert!(steps <= 4 * 3000, "{steps} steps"); + assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot"); + } + fn page() -> Page { let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), @@ -932,7 +998,7 @@ mod tests { ], images: vec![Image { ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), rect: [72.0, 450.0, 272.0, 600.0], size: [200.0, 150.0], rotation: 0.0, @@ -1374,7 +1440,7 @@ mod tests { /// file in the package, with a content type; JPEG pictures too, inline or placed. #[test] fn word_pictures_resolve_to_media_in_the_package() { - let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() }; + let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() }; for placed in [false, true] { let mut p = page(); p.images = vec![jpeg.clone()]; @@ -1401,7 +1467,7 @@ mod tests { fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() { let mut p = page(); let one = p.images[0].clone(); - p.images = vec![Image { bytes: Vec::new(), ..one.clone() }]; + p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }]; p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10)); let zip = docx(&[p], "T"); let rels = part(&zip, "word/_rels/document.xml.rels"); diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 1a7250d9e0..886a4bdbde 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -391,7 +391,7 @@ mod tests { ], images: vec![Image { ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), rect: [72.0, 150.0, 300.0, 300.0], size: [228.0, 150.0], rotation: 0.0, @@ -619,7 +619,13 @@ mod tests { blocks.push(huge("inf", f64::INFINITY, 200.0 - k as f64)); } let images = (0..40) - .map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 }) + .map(|k| Image { + ext: "png", + bytes: [0; 4].as_slice().into(), + rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], + size: [1.0, 1.0], + rotation: 0.0, + }) .collect(); let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None }; let bytes = pptx(&[p], "Hostile"); 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/lib.rs b/crates/fonts/src/lib.rs index ec103ed5f0..37a2ea74d8 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -88,17 +88,28 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { } /// [`wrap`], measuring with `measure` (for example [`cjk_width`]). +/// +/// A line measures as the sum of its words, the spaces between them and the characters of a broken +/// word, each measured once, so the work stays linear in `text` however much fits on a line. pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); + let space = measure(" ", size); + let mut buf = [0u8; 4]; // A CRLF pair is one break, a lone CR or LF is one break each. for para in text.replace("\r\n", "\n").split(['\n', '\r']) { let mut line = String::new(); + let mut line_width = 0.0; // No word is on the line yet; an empty word (a leading space) still starts it. let mut fresh = true; for word in para.split(' ') { - let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { - line = candidate; + let word_width = measure(word, size); + let candidate = if fresh { word_width } else { line_width + space + word_width }; + if candidate <= width { + if !fresh { + line.push(' '); + } + line.push_str(word); + line_width = candidate; fresh = false; continue; } @@ -108,11 +119,15 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } else { lines.push(std::mem::take(&mut line)); } + line_width = 0.0; for ch in word.chars() { - if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width { + let ch_width = measure(ch.encode_utf8(&mut buf), size); + if !line.is_empty() && line_width + ch_width > width { lines.push(std::mem::take(&mut line)); + line_width = 0.0; } line.push(ch); + line_width += ch_width; } fresh = line.is_empty(); } @@ -442,4 +457,20 @@ mod tests { let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width); assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}"); } + + /// Wrapping measures each word and character a bounded number of times, however many fit on a line. + #[test] + fn wrapping_measures_each_word_a_few_times() { + let measured = std::cell::Cell::new(0usize); + let measure = |s: &str, size: f64| { + measured.set(measured.get() + s.len()); + helvetica_width(s, size) + }; + let text = format!("{} {}", "word ".repeat(5_000), "x".repeat(5_000)); + let lines = wrap_with(&text, 1.0, 13_000.0, measure); + assert_eq!(lines.len(), 2, "the words fit on one line, the long word on another"); + assert!(measured.get() <= 4 * text.len(), "{} bytes measured", measured.get()); + let narrow = wrap_with(&text, 10.0, 100.0, helvetica_width); + assert!(narrow.iter().all(|l| helvetica_width(l, 10.0) <= 100.0), "{narrow:?}"); + } } diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0aaed6939e..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)] @@ -33,9 +33,9 @@ pub struct Metrics { widths: Vec, /// Composite fonts: CID → width, and CID ranges with one width. cid_widths: HashMap, - cid_ranges: Vec<(u32, u32, f64)>, + cid_ranges: Spans, /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). - cid_map: Vec<(u32, u32, u32)>, + cid_map: Spans, default: f64, /// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that /// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph. @@ -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. @@ -598,8 +610,8 @@ impl Metrics { first: 0, widths: Vec::new(), cid_widths: HashMap::new(), - cid_ranges: Vec::new(), - cid_map: Vec::new(), + cid_ranges: Spans::default(), + cid_map: Spans::default(), default: 500.0, explicit_default: false, std14: Some(crate::Std14::Helvetica), @@ -679,13 +691,13 @@ 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() { m.codes = Codes::Ranges(spaces); } - m.cid_map = cids; + m.cid_map = Spans::new(cids); } let desc = font .get(b"DescendantFonts") @@ -698,6 +710,7 @@ impl Metrics { m.explicit_default = true; } if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) { + let mut ranges = Vec::new(); let mut i = 0; while i < w.len() { let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break }; @@ -713,14 +726,17 @@ impl Metrics { i += 2; } Some(c1) => { - if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { - m.cid_ranges.push((c0, c1 as u32, v)); + if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) + && ranges.len() < MAX_CMAP_ENTRIES + { + ranges.push((c0, c1 as u32, v)); } i += 3; } None => break, } } + m.cid_ranges = Spans::new(ranges); } descriptor = dict(doc, desc.get(b"FontDescriptor")); } else { @@ -791,14 +807,14 @@ impl Metrics { if self.cid_map.is_empty() { return code; } - self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo)) + self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo))) } /// The advance of `code` in text space per unit of font size. pub fn width(&self, code: u32) -> f64 { if self.composite { let cid = self.cid(code); - let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2)); + let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2)); return w.unwrap_or(self.default) * self.scale; } if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { @@ -858,7 +874,7 @@ impl Metrics { // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) - || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) + || self.cid_ranges.find(cid).is_some() || !self.subset || self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1) // No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW @@ -905,9 +921,13 @@ impl Metrics { } } -/// The UTF-16 code units of CMap hex bytes. +/// Most UTF-16 units kept from one ToUnicode destination: the CMap format allows destinations of up +/// to 512 bytes, and every glyph a sequential range covers builds its text from the whole of one. +const MAX_DESTINATION_UNITS: usize = 256; + +/// The UTF-16 code units of CMap hex bytes, at most [`MAX_DESTINATION_UNITS`] of them. fn code_units(b: &[u8]) -> Vec { - b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() + b.chunks(2).take(MAX_DESTINATION_UNITS).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() } /// UTF-16BE (with surrogates) from CMap hex bytes. @@ -954,16 +974,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi: true } +#[cfg(test)] +thread_local! { + /// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic. + static SPAN_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts one span record read (tests only). +#[inline] +fn count_span_reads() { + #[cfg(test)] + SPAN_READS.with(|c| c.set(c.get().saturating_add(1))); +} + +/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code +/// wins, indexed so that a lookup is one binary search. +#[derive(Clone, Debug, Default, PartialEq)] +struct Spans { + spans: Vec<(u32, u32, T)>, + /// The span covering each stretch of codes; see [`stretches`]. + cover: Vec<(u64, Option)>, +} + +impl Spans { + fn new(spans: Vec<(u32, u32, T)>) -> Self { + let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect(); + Spans { cover: stretches(&bounds, false), spans } + } + + fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + /// The first-declared span covering `code`. + fn find(&self, code: u32) -> Option<&(u32, u32, T)> { + let step = self.cover.partition_point(|&(start, _)| { + count_span_reads(); + start <= u64::from(code) + }); + let &(_, record) = self.cover.get(step.checked_sub(1)?)?; + self.spans.get(record?) + } +} + /// For each stretch of codes, from its first code on, the latest-declared range covering it (`None` /// where no range does), sorted by code; a lookup is one binary search over it. fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { + let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect(); + stretches(&spans, true) +} + +/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where +/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared. +/// At most `u32::MAX` spans are indexed. +fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option)> { + // Heap keys, largest for the winning record; the mapping is its own inverse. + let key = |record: u32| if later_wins { record } else { !record }; // Openings (first code, record) and closings (one past the last code), each in code order. The cover // can only change at one of their codes, so the sweep visits those codes in order. - let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect(); + let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect(); opens.sort_unstable(); - let mut closes: Vec = ranges.iter().map(|r| u64::from(r.hi) + 1).collect(); + let mut closes: Vec = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect(); closes.sort_unstable(); - // Records that have opened and not yet closed; the top is the latest declared one. + // Keys of the records that have opened and not yet closed; the top is the winning one. let mut open: BinaryHeap = BinaryHeap::new(); let mut cover: Vec<(u64, Option)> = Vec::new(); let (mut next_open, mut next_close) = (0, 0); @@ -977,16 +1050,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { while let Some(&(lo, record)) = opens.get(next_open) && u64::from(lo) <= p { - open.push(record); + open.push(key(record)); next_open += 1; } while closes.get(next_close).is_some_and(|&end| end <= p) { next_close += 1; } - while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) { + while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) { open.pop(); } - let winner = open.peek().map(|&top| top as usize); + let winner = open.peek().map(|&top| key(top) as usize); if cover.last().map(|&(_, w)| w) != Some(winner) { cover.push((p, winner)); } @@ -1220,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); } } @@ -1262,6 +1335,88 @@ mod tests { use super::*; + /// Span records read while `f` runs. + fn span_reads(f: impl FnOnce()) -> usize { + SPAN_READS.with(|c| c.set(0)); + f(); + SPAN_READS.with(|c| c.get()) + } + + /// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. + fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"W".to_vec(), Object::Array(w)); + let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); + let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); + font( + doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ) + } + + /// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. + #[test] + fn widths_of_a_code_no_range_covers_take_few_steps() { + let mut doc = Document::new_empty(); + let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); + let f = composite(&mut doc, "", w, ""); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.width(4), 0.5); + let reads = span_reads(|| { + for _ in 0..1000 { + assert_eq!(m.width(3), 1.0); + } + }); + assert!(reads <= 1000 * 64, "{reads} span reads"); + } + + /// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. + #[test] + fn overlapping_spans_resolve_to_the_first_declared() { + let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); + let found = |code| spans.find(code).map(|s| s.2); + let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; + for (code, value) in want { + assert_eq!(found(code), value, "code {code}"); + } + } + + /// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. + #[test] + fn many_unicode_entries_look_up_their_cids_in_few_steps() { + let n = 2000; + let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); + let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); + let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); + let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); + let mut doc = Document::new_empty(); + let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); + let reads = span_reads(|| { + Metrics::from_dict(&doc, &f); + }); + assert!(reads <= n * 64, "{reads} span reads"); + } + + /// Every glyph of a sequential range builds its text from the destination, so a long one is cut + /// to the CMap limit once, when it is read. + #[test] + fn unicode_destinations_are_cut_to_the_cmap_limit() { + let long = "0041".repeat(4096); + let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode(cmap.as_bytes(), &mut u); + for code in [0x10, 0x20, 0x30, 0x40] { + let text = u.text(code).unwrap_or_default(); + assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); + } + } + /// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. #[test] fn low_code_tables_end_with_the_last_covered_code() { @@ -1496,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(); @@ -1718,7 +1905,7 @@ mod tests { let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); let mut m = Metrics::fallback(); - (_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange"); + m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); assert_eq!(m.cid(5), u32::MAX); let mut doc = Document::new_empty(); let mut enc = Dict::new(); @@ -2114,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/author.rs b/crates/forms/src/author.rs index f5178a6879..544d0a7f8b 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1280,6 +1280,31 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { } } } + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or +/// by reference. +fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { + let Some(root) = doc.root() else { return Ok(()) }; + let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; + let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; + let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; + let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); + let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); + if kept.len() == co.len() { + return Ok(()); + } + match (co_obj.as_ref(), af_obj.as_ref()) { + (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, + (_, None) => { + af.set(b"CO".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } Ok(()) } diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index e75a05d475..428c1724d0 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -122,6 +122,12 @@ pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)> (run, extra) } +#[cfg(test)] +thread_local! { + /// Read names compared while merging a field's reads. + pub(crate) static NAMES_COMPARED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's /// reads and issues, and Fix order's order. pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { @@ -132,8 +138,11 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { .map(|(f, _)| { let mut names = f.actions.calculate.reads(); if let Some(js) = &f.actions.scripts.calculate { + let mut known: HashSet = names.iter().cloned().collect(); for name in crate::af::calculation_reads(js) { - if !names.contains(&name) { + #[cfg(test)] + NAMES_COMPARED.with(|n| n.set(n.get() + 1)); + if known.insert(name.clone()) { names.push(name); } } diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 85284b8cc5..82c1c8e74e 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(), widgets: 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) || seen.widgets.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); } } @@ -754,6 +764,15 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re Ok(()) } +/// What one reading of the form's fields has used: the field and widget dictionaries read, and +/// the script budget their actions share. +struct Seen { + fields: std::collections::HashSet, + /// A widget belongs to the first field that lists it, however often it is listed. + widgets: std::collections::HashSet, + scripts: DecodeBudget, +} + /// 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. fn walk( @@ -761,11 +780,11 @@ fn walk( 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); @@ -807,10 +826,8 @@ 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(); + // A field of no kind this reads is skipped before it claims widgets or scripts, which a later + // field listing them then reads. let ff = inh.ff.unwrap_or(0); let kind = match inh.ft.as_deref() { Some(b"Tx") => FieldKind::Text, @@ -822,6 +839,12 @@ fn walk( Some(b"Sig") => FieldKind::Signature, _ => return, }; + let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); + 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 widgets = widget_refs .into_iter() .filter_map(|w| { 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 b2e4994ecf..05b14d9338 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,95 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// A Calculate script naming thousands of fields is merged with the field's other reads in one +/// pass, not by searching the names so far for each one. +#[test] +fn a_script_reading_many_fields_merges_its_reads_in_one_pass() { + use crate::calc_order::NAMES_COMPARED; + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("total")).unwrap(); + let n = 2_000; + let script: String = (0..n).map(|i| format!("getField(\"f{i}\").value + ")).collect::() + "0"; + set_field_script(&mut doc, "total", "calculate", Some(&format!("event.value = {script};"))).unwrap(); + NAMES_COMPARED.with(|c| c.set(0)); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields[0].reads.len(), n); + 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"); +} + +/// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share, +/// is read once: the first field to list it owns it. +#[test] +fn a_widget_listed_repeatedly_is_read_once() { + let kids = "7 0 R ".repeat(500); + let doc = document(&[ + "<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(), + "<< /FT /Tx /T (a) /Kids 6 0 R >>".into(), + "<< /FT /Tx /T (b) /Kids 6 0 R >>".into(), + "<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [7 0 R] >>".into(), + format!("[{kids}]"), + "<< /Type /Annot /Subtype /Widget /Parent 2 0 R /Rect [0 0 10 10] >>".into(), + ]); + let all = fields(&doc); + let widgets: Vec = all.iter().map(|f| f.widgets.len()).collect(); + assert_eq!(widgets, [1, 0], "{:?}", all.iter().map(|f| &f.name).collect::>()); +} + +/// A field of a kind that isn't read doesn't claim the widget it shares with a field that is. +#[test] +fn a_skipped_field_leaves_its_widget_to_the_next() { + let doc = document(&[ + "<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(), + "<< /FT /Zz /T (odd) /Kids [6 0 R] >>".into(), + "<< /FT /Tx /T (b) /Kids [6 0 R] >>".into(), + "<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 3 0 R /Rect [0 0 10 10] >>".into(), + ]); + let all = fields(&doc); + let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); + assert_eq!(widgets, [("b", 1)]); +} diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 40b86c6328..26b7ba8d41 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. @@ -716,6 +716,8 @@ fn num_text(n: f64) -> String { fn fixed(v: f64, decimals: usize) -> String { let k = 10f64.powi(decimals.min(20) as i32); let scaled = (v * k).round() / k; + // A small negative value rounds to -0.0, which would print as "-0". + let scaled = if scaled == 0.0 { 0.0 } else { scaled }; format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) } @@ -1466,8 +1468,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 +1619,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 +1641,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 +1651,7 @@ impl Interp<'_> { } Val::List(items) => { for it in items { + self.charge(self.cost(&v))?; self.assign_to(it, prop, v.clone())?; } Ok(()) @@ -2524,10 +2522,10 @@ impl Interp<'_> { let v = parse_num(&s).unwrap_or(0.0); let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { - "mm" => 25.4, - "cm" => 2.54, - "pt" => 72.0, - "pc" => 6.0, + "mm" | "millimeter" | "millimeters" => 25.4, + "cm" | "centimeter" | "centimeters" => 2.54, + "pt" | "point" | "points" => 72.0, + "pc" | "pica" => 6.0, _ => 1.0, }; let target = if to.trim().is_empty() { unit.clone() } else { to }; @@ -2631,6 +2629,14 @@ pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: X mod tests { use super::*; + #[test] + fn fixed_does_not_print_negative_zero() { + assert_eq!(fixed(-0.4, 0), "0"); + assert_eq!(fixed(-0.004, 2), "0.00"); + assert_eq!(fixed(-0.0, 0), "0"); + assert_eq!(fixed(-1.5, 0), "-2"); + } + #[test] fn dates_round_trip_through_formcalc_day_numbers() { assert_eq!(days_from_civil(1900, 1, 1), 1); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 2a91075d68..f06e5c483b 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -144,6 +144,8 @@ pub struct DocInfo { pub page: usize, /// Document information (`this.info.title` etc.): lower-case key, value. pub info: Vec<(String, String)>, + /// The document has an XFA form, so scripts see `xfa_installed`. + pub xfa: bool, } /// Something a script asked the viewer to do. @@ -203,6 +205,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 +285,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 +515,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 +569,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 +587,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 +609,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 +650,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 +827,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}")); @@ -1212,9 +1291,13 @@ fn color_object(ctx: &mut Context) -> JsObject { o.build() } +/// The XFA version scripts see as `xfa_version`. +const XFA_VERSION: f64 = 3.5; + /// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, /// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, -/// `documentFileName`, `info`), and the `app`, `util`, `console`, `display` and `color` objects. +/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and +/// `color` objects. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), @@ -1235,9 +1318,9 @@ fn install(ctx: &mut Context) -> JsResult<()> { let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); - let (file, pages, info) = { + let (file, pages, info, xfa) = { let h = host(ctx)?.borrow(); - (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone()) + (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa) }; let mut io = ObjectInitializer::new(ctx); for (k, v) in &info { @@ -1250,6 +1333,12 @@ fn install(ctx: &mut Context) -> JsResult<()> { ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; ctx.register_global_property(js_string!("info"), info, rw)?; + // XFA forms check both before they run: every document sees the XFA version, and only an + // XFA form sees `xfa_installed`. + ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?; + if xfa { + ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; + } let app = ObjectInitializer::new(ctx) .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) @@ -1333,6 +1422,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`. @@ -1501,6 +1594,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { @@ -1531,6 +1626,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 680f82faa6..f27b994b59 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -16,7 +16,7 @@ fn fields() -> Vec { } fn doc() -> DocInfo { - DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())] } + DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], xfa: false } } fn go(script: &str, event: &Event) -> Outcome { @@ -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] @@ -813,6 +990,9 @@ mod formcalc { assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); assert_eq!(calc(r#"UnitValue("2in", "mm")"#), "50.8"); assert_eq!(calc(r#"UnitType("2.5cm")"#), "cm"); + assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); + assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); + assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); @@ -1471,3 +1651,29 @@ mod scand { } } } + +/// The check XFA forms run on opening: without both globals, or with a version below 2.8, it +/// alerts that the viewer can't show the form. +const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \ + event.value = typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version;"; + +/// An XFA form sees `xfa_installed` (true) and `xfa_version` (3.5), so the check passes. +#[test] +fn an_xfa_form_sees_the_xfa_plugin_installed() { + let xfa = DocInfo { xfa: true, ..doc() }; + let o = run(XFA_CHECK, &Event::doc("Open"), &xfa, &fields(), &[], Limits::default()); + assert_eq!(o.error, None); + assert_eq!(o.value, "boolean number 3.5"); + assert!(o.alerts.is_empty(), "{:?}", o.alerts); + let o = run("event.value = String(xfa_installed);", &Event::doc("Open"), &xfa, &fields(), &[], Limits::default()); + assert_eq!(o.value, "true"); +} + +/// A form without XFA sees `xfa_version` but no `xfa_installed`, so the check alerts. +#[test] +fn a_plain_form_sees_the_xfa_version_but_not_installed() { + let o = go(XFA_CHECK, &Event::doc("Open")); + assert_eq!(o.error, None); + assert_eq!(o.value, "undefined number 3.5"); + assert_eq!(o.alerts, ["old viewer"]); +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 03668c8708..62b229eacc 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 72b6c4b07b..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,13 +508,29 @@ 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() }), } } } out } +/// Most viewports [`set_scale`] gives a page, and most [`scale_at`] reads. +const MAX_VIEWPORTS: usize = 1024; + +#[cfg(test)] +thread_local! { + /// Numbers read from viewport `/BBox` arrays. + pub(crate) static VIEWPORT_NUMBERS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9). /// Outside every viewport the page measures true size in inches. pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result { @@ -510,12 +544,17 @@ pub fn scale_at_or(doc: &Document, page_index: usize, at: Point, per_point: f64, if let Some(o) = p.dict.get(b"VP") { let o = doc.resolve(o); let arr = o.as_array().ok_or_else(|| invalid("invalid page viewports"))?; - for viewport in arr.iter().rev() { + // The last viewports win; a page holds at most MAX_VIEWPORTS of its own. + for viewport in arr.iter().rev().take(MAX_VIEWPORTS) { let o = doc.resolve(viewport); let Some(d) = o.as_dict() else { continue }; if let Some(bbox) = d.get(b"BBox") { let o = doc.resolve(bbox); - let b: Option> = o.as_array().and_then(|a| a.iter().map(|v| doc.resolve(v).as_f64()).collect()); + let b: Option> = o.as_array().filter(|a| a.len() == 4).and_then(|a| { + #[cfg(test)] + VIEWPORT_NUMBERS_READ.with(|n| n.set(n.get() + a.len())); + a.iter().map(|v| doc.resolve(v).as_f64()).collect() + }); if let Some(b) = b.filter(|b| b.len() == 4 && b.iter().all(|v| v.is_finite())) && at[0] >= b[0].min(b[2]) && at[0] <= b[0].max(b[2]) @@ -541,7 +580,7 @@ pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &s Some(o) => doc.resolve(o).as_array().cloned().ok_or_else(|| invalid("invalid page viewports"))?, None => Vec::new(), }; - if list.len() >= 1024 { + if list.len() >= MAX_VIEWPORTS { return Err(invalid("page has too many measurement viewports")); } let mut d = Dict::new(); diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 3cf6bbd17e..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); @@ -250,6 +256,8 @@ impl Walker<'_> { let mut current = None; let mut closures = Vec::new(); let mut path = Geometry::default(); + // Read on the first `Do` and kept: resolving a direct dictionary copies it. + let mut xobjects = None; let point = |m: Matrix, x: f64, y: f64| { let (x, y) = m.apply(x, y); [x, y] @@ -384,7 +392,11 @@ impl Walker<'_> { } "Do" => { if let Some(name) = op.name(0) { - let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)); + let xobjects = xobjects.get_or_insert_with(|| { + #[cfg(test)] + XOBJECT_DICT_READS.with(|n| n.set(n.get() + 1)); + resources.get(b"XObject").map(|o| self.doc.resolve(o)) + }); if let Some(o) = xobjects.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(name)) { let r = o.as_ref(); if r.is_some_and(|r| self.seen.contains(&r)) { @@ -420,6 +432,12 @@ impl Walker<'_> { Ok(()) } } +#[cfg(test)] +thread_local! { + /// Times a content stream's `/XObject` resources were read. + pub(crate) static XOBJECT_DICT_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + pub fn geometry(doc: &Document, page_index: usize) -> Result { let p = crate::page(doc, page_index)?; let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o)); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 303f9b4386..8d69a05cf0 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -403,3 +403,61 @@ fn a_scale_ratio_converts_its_page_side_to_points() { assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}"); } } +/// A content stream reads its `/XObject` resources once, not again (as a copy of a direct +/// dictionary) for every `Do`. +#[test] +fn xobject_resources_are_read_once_per_stream() { + let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]); + snap::XOBJECT_DICT_READS.with(|c| c.set(0)); + let paths = snap::geometry(&d, 0).unwrap(); + assert!(paths.segments.is_empty()); + let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get); + assert!(reads <= 1, "{reads} reads of the page's XObject resources"); +} +/// Finding the scale under a point reads only well-formed viewport boxes, and only as many +/// viewports as a page can be given, however many a file lists or however long their boxes. +#[test] +fn scale_lookup_reads_viewports_within_bounds() { + let vp = vec!["5 0 R"; 5_000].join(" "); + let long_box = vec!["0"; 2_000].join(" "); + let d = fixture("", &format!("/VP [{vp}]"), &["<< /Type /Viewport /BBox 6 0 R >>", &format!("[{long_box}]")]); + VIEWPORT_NUMBERS_READ.with(|c| c.set(0)); + let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap(); + assert_eq!(scale.unit, "in"); + 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 b5967eee09..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}; @@ -29,6 +29,19 @@ pub const MIN_FRAGMENTS: usize = 4; /// Most pixels a merged image may have (memory). const MAX_PIXELS: u64 = 80_000_000; +/// Fragment groups a page's search for runs may read per content operator, and at least. A run +/// that is not one picture is searched again from each of its groups, so without a limit images +/// drawn over one another take time quadratic in their number; past it, the rest of the page is +/// left as it is. +const SCAN_PER_OP: usize = 16; +const MIN_SCAN: usize = 1 << 16; + +#[cfg(test)] +thread_local! { + /// Fragment groups read, for the test that keeps a page's search linear in its content. + static GROUP_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// One fragment: its image, its box in the coordinates the run is drawn in, and its size. struct Fragment { image: ObjRef, @@ -40,6 +53,8 @@ struct Fragment { /// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix /// and the name drawn. fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec)> { + #[cfg(test)] + GROUP_READS.with(|c| c.set(c.get().saturating_add(1))); if !ops.get(i)?.is("q") { return None; } @@ -143,12 +158,16 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 // Pixel rows per unit of height: the finest fragment's. let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); let height = ((top - bottom) * density).round(); - if !(height.is_finite() && height >= 1.0) || (width as u64) * (height as u64) > MAX_PIXELS { + if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) { return None; } + // In range and whole, so the cast is exact. let height = height as u32; - let row = width as usize * n; - let mut out = vec![255u8; row * height as usize]; + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return None; + } + let row = (width as usize).checked_mul(n)?; + let mut out = vec![255u8; row.checked_mul(height as usize)?]; for f in run { let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); @@ -213,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); } @@ -222,7 +243,13 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & let ops = parsed.ops; let mut out: Vec = Vec::with_capacity(ops.len()); let (mut i, mut merged, mut k) = (0, 0, 0); + let budget = ops.len().saturating_mul(SCAN_PER_OP).max(MIN_SCAN); + let mut scanned = 0usize; while i < ops.len() { + if scanned > budget { + out.extend(ops.get(i..).unwrap_or_default().iter().cloned()); + break; + } // The longest run of fragment groups from here with one colour space. let mut run = Vec::new(); let mut kind: Option<(Object, usize)> = None; @@ -241,10 +268,12 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height }); j = next; } + scanned = scanned.saturating_add(run.len()).saturating_add(1); // Not one picture as a whole: the rows drawn one under the other from the start may be. if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { run = drawn_in_sequence(&ops, i, &xobjects, doc); j = run_end(&ops, i, run.len()); + scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2); } let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { // Not a run: keep this op and look again from the next one. @@ -297,3 +326,53 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro } Ok(()) } + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + + use super::*; + + #[test] + fn a_merged_height_beyond_u32_is_refused_before_allocating() { + // Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be + // 2^16 × 2^48 pixels, whose product wraps to 0 in u64. + let image = ObjRef { num: 1, generation: 0 }; + let edge = 1.0 - (-48f64).exp2(); + let run = [ + Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, + Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, + ]; + assert!(merge(&Document::new_empty(), &run, 3).is_none()); + } + + /// Many images drawn on top of each other are not one picture; looking for runs among them reads + /// a bounded number of groups per operator, not the rest of the page from every group. + #[test] + fn rejected_runs_read_a_bounded_number_of_groups() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0]))); + let groups = 4000; + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(groups).as_bytes()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + let page = doc.add(Object::Dict(page)); + GROUP_READS.with(|c| c.set(0)); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true); + assert_eq!(merged.ok(), Some(0)); + let reads = GROUP_READS.with(|c| c.get()); + assert!(reads <= 40 * 4 * groups, "{reads} group reads"); + } +} diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 6182b08922..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); @@ -227,6 +242,19 @@ fn is_dct(s: &Stream) -> Option { } } +#[cfg(test)] +thread_local! { + /// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding. + static DECODED_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` decoded pixels (tests only). +#[inline] +fn count_decoded(_n: u64) { + #[cfg(test)] + DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n))); +} + /// 8-bit samples of an image (`n` components), decoded. pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); @@ -235,7 +263,15 @@ pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { } let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?; if is_dct(s)? { - let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?; + use image::ImageDecoder as _; + // The JPEG's own size is what gets decoded: one other than the dictionary's is refused from + // its header, before any pixels are. + let decoder = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&s.raw[..])).ok()?; + if decoder.dimensions() != (w, h) { + return None; + } + let img = image::DynamicImage::from_decoder(decoder).ok()?; + count_decoded(u64::from(img.width()) * u64::from(img.height())); // Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them). let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() }; (v.len() == bytes).then_some(v) @@ -432,4 +468,35 @@ mod dimension_tests { let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); } + + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. + #[test] + fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { + let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default(); + let stream = |w: i64, h: i64| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(w)); + dict.set(b"Height".to_vec(), Object::Int(h)); + dict.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Stream::from_raw(dict, data.clone()) + }; + DECODED_PIXELS.with(|c| c.set(0)); + assert!(pixels(&stream(10, 10), 1).is_none()); + assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); + 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/src/links.rs b/crates/optimize/src/links.rs index 744881df74..082d56e7df 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -150,11 +150,14 @@ fn referenced_names(doc: &Document) -> HashSet> { while let Some(o) = stack.pop() { match o { Object::Dict(d) => { - if let Some(n) = d.get(b"Dest").and_then(dest_name) { + // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name + // string, and an unresolved ref has no name, so the destination it uses would + // look unreferenced and be removed. + if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); } if matches!(d.name(b"S"), Some(b"GoTo" | b"GoToR" | b"GoToE")) - && let Some(n) = d.get(b"D").and_then(dest_name) + && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index fd1ef3cd94..6d9a9bb44e 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"] { @@ -1084,3 +1097,45 @@ fn a_hostile_soft_mask_leaves_its_image_unchanged() { assert_eq!(stream(&doc, photo), before); assert_eq!(stream(&doc, mask), hostile); } + +#[test] +fn indirectly_referenced_destination_is_kept() { + // A link's /Dest can be an indirect reference to the name string, not the string itself. Reduce + // File Size read the name only from a direct string, so it deleted a destination the link still + // used, silently breaking the link. + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + // A link whose /Dest is `ref -> "target"`, and an unused destination for contrast. + let name_ref = Object::Ref(doc.add(s("target"))); + let mut link = Dict::new(); + link.set(b"Type".to_vec(), Object::name("Annot")); + link.set(b"Subtype".to_vec(), Object::name("Link")); + link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + link.set(b"Dest".to_vec(), name_ref); + let link = Object::Ref(doc.add(Object::Dict(link))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![link]))).unwrap(); + let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("target"), fit.clone(), s("unused"), fit])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + // Only "unused" is removed; the indirectly-referenced "target" is kept. + assert_eq!(report.unreferenced_dests, 1); + let left = named_dest_keys(&doc); + assert!(left.contains(&b"target".to_vec()), "indirectly referenced destination was kept: {left:?}"); + assert!(!left.contains(&b"unused".to_vec())); +} + +/// The keys in the catalog's /Names /Dests name tree (one level, as the optimizer writes it). +fn named_dest_keys(doc: &Document) -> Vec> { + let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let names = doc.resolve(cat.get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = doc.resolve(names.get(b"Dests").unwrap()).as_dict().cloned().unwrap(); + let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap(); + arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect() +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 80f90fb2a4..1162a810cd 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -154,8 +154,8 @@ impl<'a> Copier<'a> { } /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. - fn fix_annotations(&self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { - for &a in &self.annots { + fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { + for &a in &std::mem::take(&mut self.annots) { let src_dict = dst.get(a).as_dict().cloned(); let Some(d) = src_dict else { continue }; let mut d = d; @@ -188,10 +188,13 @@ impl<'a> Copier<'a> { } } } else { - // Other actions (URI, Launch, JavaScript…) carry no page references. - let mut c = Copier::new(self.src, self.map.clone(), HashMap::new()); - let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None)?; - d.set(b"A".to_vec(), copied); + // Other actions (URI, Launch, JavaScript…) carry no page references. The + // copier lends its map out and takes it back with the new copies in it, + // so actions sharing an object share its one copy. + let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); + let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); + self.map = c.map; + d.set(b"A".to_vec(), copied?); } } } @@ -541,13 +544,16 @@ fn named_dest(src: &Document, name: &[u8]) -> Option { } let names = src.resolve(catalog.get(b"Names")?); let tree = names.as_dict()?.get(b"Dests")?.clone(); - name_tree_lookup(src, &tree, name, 0) + name_tree_lookup(src, &tree, name, 0, &mut HashSet::new()) } -fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Option { - if depth > 32 { +/// Name trees are read once per node: a node listed twice (or a cycle) is skipped. +fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8, seen: &mut HashSet) -> Option { + if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) { return None; } + #[cfg(test)] + crate::work(1); let node = src.resolve(node); let d = node.as_dict()?; if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) { @@ -560,14 +566,16 @@ fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Opt } } let kids = d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1)) + kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1, seen)) } -/// All (key, value) pairs of a name tree, in order. -fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec, Object)>) { - if depth > 32 { +/// All (key, value) pairs of a name tree, in order, each node read once. +fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: &mut HashSet, out: &mut Vec<(Vec, Object)>) { + if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) { return; } + #[cfg(test)] + crate::work(1); let node = src.resolve(node); let Some(d) = node.as_dict() else { return }; if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) { @@ -580,7 +588,7 @@ fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Ve } } for k in d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default() { - name_tree_entries(src, &k, depth + 1, out); + name_tree_entries(src, &k, depth + 1, seen, out); } } @@ -680,24 +688,32 @@ pub fn combine_field_conflicts(sources: &[&Document]) -> Vec) -> Vec<(ObjRef, String, String, String)> { let mut planned = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut walk = RenameWalk::default(); for &root in roots { - plan_renames_under(doc, root, "", taken, &mut seen, 0, &mut planned); + plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); } planned } +/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name +/// the suffix to try next. +#[derive(Default)] +struct RenameWalk { + seen: HashSet, + next: HashMap, +} + /// One node of [`plan_renames`]. fn plan_renames_under( doc: &Document, node: ObjRef, prefix: &str, taken: &mut std::collections::HashSet, - seen: &mut std::collections::HashSet, + walk: &mut RenameWalk, depth: u8, planned: &mut Vec<(ObjRef, String, String, String)>, ) { - if depth > 32 || !seen.insert(node) { + if depth > 32 || !walk.seen.insert(node) { return; } let Some(d) = doc.get(node).as_dict().cloned() else { return }; @@ -705,14 +721,19 @@ fn plan_renames_under( if !name.is_empty() { let fqn = qualified(prefix, &name); if taken.contains(&fqn) { - let mut n = 1u32; + // `taken` only grows, so every suffix below the last one handed out for this name is + // still taken: resume there instead of at 1. + let mut n = walk.next.get(&fqn).copied().unwrap_or(1); let mut variant; while { + #[cfg(test)] + crate::work(1); variant = format!("{name}-{n}"); taken.contains(&qualified(prefix, &variant)) } { - n += 1; + n = n.saturating_add(1); } + walk.next.insert(fqn.clone(), n.saturating_add(1)); let new_fqn = qualified(prefix, &variant); planned.push((node, variant.clone(), fqn, new_fqn.clone())); taken.insert(new_fqn); @@ -724,7 +745,7 @@ fn plan_renames_under( let prefix = qualified_optional(prefix, &name); let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); for kid in kids.iter().filter_map(|k| k.as_ref()) { - plan_renames_under(doc, kid, &prefix, taken, seen, depth + 1, planned); + plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned); } } @@ -826,8 +847,11 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize _ => (None, Dict::new()), }; let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut listed: HashSet = list.iter().filter_map(Object::as_ref).collect(); for f in fields { - if !list.contains(&Object::Ref(*f)) { + #[cfg(test)] + crate::work(1); + if listed.insert(*f) { list.push(Object::Ref(*f)); } } @@ -1144,7 +1168,7 @@ fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec 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/lib.rs b/crates/organize/src/lib.rs index 6a696dd4db..d0e088fd71 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -739,5 +739,24 @@ pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) } } +#[cfg(test)] +thread_local! { + /// Steps taken on this thread by loops whose cost follows the input's shape, so a test can + /// bound the work a hostile document causes without timing it. + static WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Count `n` steps of input-shaped work (see [`WORK`]). +#[cfg(test)] +fn work(n: usize) { + WORK.with(|w| w.set(w.get().saturating_add(n))); +} + +/// The steps counted on this thread since the last call. +#[cfg(test)] +fn take_work() -> usize { + WORK.with(|w| w.replace(0)) +} + #[cfg(test)] mod tests; diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index b5f5e65ffe..d8db2f7ac4 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; @@ -29,7 +29,7 @@ enum XKey { } /// Who owns the resources currently being walked, for the key drawn names are recorded under. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] enum Owner { /// An indirect resources object (a page's, or a form XObject's own). Resources(ObjRef), @@ -50,6 +50,9 @@ struct Pruner<'a> { glyph_seen: HashSet, /// An indirect font's explicit resources have one owner regardless of which page uses it. glyph_fonts: HashSet, + /// Forms already analysed, with the owner of the resources they drew against: drawing one + /// again records nothing new, and re-reading it on every `Do` is exponential in the nesting. + forms_seen: HashSet<(ObjRef, Owner)>, } impl Pruner<'_> { @@ -211,6 +214,11 @@ impl Pruner<'_> { // A form without its own resources draws against the ones it was invoked with. None => (resources.clone(), owner), }; + if !self.forms_seen.insert((r, inner_owner)) { + continue; + } + #[cfg(test)] + crate::work(1); match s.decoded() { Ok(data) => self.content(&data, &inner, inner_owner, depth + 1), // What the form draws is unknown: keep everything it could reach. @@ -223,23 +231,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. @@ -314,6 +315,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result 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..9ce1284322 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3132,3 +3132,157 @@ 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"]); } + +/// A one-page document whose page carries `annots` (object bodies 5…), with `extra` bodies after them. +fn page_with_annots(annots: &[String], extra: &[String], catalog_extra: &str) -> Document { + let n = annots.len(); + let refs: String = (0..n).map(|i| format!("{} 0 R ", i + 5)).collect(); + let mut b: Vec = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Annots [{refs}] >>"), + body("P1"), + ]; + b.extend(annots.iter().cloned()); + b.extend(extra.iter().cloned()); + open(build(&b, "/Root 1 0 R")) +} + +#[test] +fn registering_many_imported_fields_is_linear() { + let n = 2_000; + let widgets: Vec = (0..n).map(|i| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [0 0 10 10] >>")).collect(); + let src = page_with_annots(&widgets, &[], ""); + take_work(); + let out = extract_pages(&src, &[0]).unwrap(); + let work = take_work(); + assert!(work <= 4 * n, "{work} steps registering {n} fields"); + let form = out.resolve(catalog(&out).get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(form.get(b"Fields").and_then(|f| f.as_array()).map(Vec::len), Some(n)); +} + +#[test] +fn renaming_many_same_named_fields_resumes_the_suffix_search() { + let n = 2_000; + let widgets: Vec = (0..n).map(|_| "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [0 0 10 10] >>".to_string()).collect(); + let src = page_with_annots(&widgets, &[], ""); + take_work(); + let renames = combine_field_conflicts(&[&src]).remove(0); + let work = take_work(); + assert!(work <= 4 * n, "{work} suffix attempts for {n} fields"); + // Still the first free variant, in order. + assert_eq!(renames.len(), n - 1); + assert_eq!(renames.first(), Some(&("x".to_string(), "x-1".to_string()))); + assert_eq!(renames.last(), Some(&("x".to_string(), format!("x-{}", n - 1)))); +} + +#[test] +fn annotation_actions_share_the_objects_they_copy() { + // Fifty links whose URI actions all point at one string object. + let n = 50; + let links: Vec = + (0..n).map(|_| format!("<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI {} 0 R >> >>", n + 5)).collect(); + let src = page_with_annots(&links, &["(https://example.com/shared)".into()], ""); + let out = extract_pages(&src, &[0]).unwrap(); + let copies = out + .object_numbers() + .into_iter() + .filter(|&o| out.get(ObjRef::new(o, out.generation(o))).as_string().is_some_and(|s| s.bytes == b"https://example.com/shared")) + .count(); + assert_eq!(copies, 1, "the shared URI is copied once, not once per action"); +} + +#[test] +fn name_trees_with_shared_kids_are_walked_once_per_node() { + // Each level lists the next one twice: 2^depth paths through `depth` nodes. + let depth = 10; + let first = 6; // after the catalog, pages, page, content and the link + let mut extra = Vec::new(); + for tree in 0..2 { + let base = first + tree * (depth + 1); + for level in 0..depth { + let next = base + level + 1; + extra.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>")); + } + extra.push(if tree == 0 { + "<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".into() + } else { + "<< /Names [(other) [3 0 R /Fit]] >>".into() + }); + } + let link = "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest (missing) >>".to_string(); + let catalog_extra = format!("/Names << /EmbeddedFiles {first} 0 R /Dests {} 0 R >>", first + depth + 1); + let src = page_with_annots(&[link], &extra, &catalog_extra); + take_work(); + let out = combine(&[("a", &src)]).unwrap(); + let work = take_work(); + assert!(work <= 8 * (depth + 1), "{work} name-tree nodes visited"); + let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(tree.get(b"Names").and_then(|n| n.as_array()).map(Vec::len), Some(2), "one attachment, listed once"); +} + +#[test] +fn a_form_drawing_itself_repeatedly_is_analysed_once() { + let form = "/F Do /F Do /F Do /F Do"; + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /F 5 0 R /Unused 6 0 R >> >> >>".into(), + "<< /Length 5 >>\nstream\n/F Do\nendstream".into(), + format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /XObject << /F 5 0 R >> >> /Length {} >>\nstream\n{form}\nendstream", + form.len() + ), + "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 0 >>\nstream\n\nendstream".into(), + ]; + let src = open(build(&b, "/Root 1 0 R")); + take_work(); + let out = extract_pages(&src, &[0]).unwrap(); + let work = take_work(); + assert!(work <= 4, "{work} form analyses"); + let page = out.get(pages(&out).unwrap()[0].obj).as_dict().cloned().unwrap(); + let res = out.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let x = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + assert!(x.contains(b"F") && !x.contains(b"Unused"), "pruning still keeps what is drawn and drops the rest"); +} + +/// 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/organize/src/xmp.rs b/crates/organize/src/xmp.rs index bbcbbcf83f..cc3a78ccf4 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -172,11 +172,24 @@ fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> { /// start and end of its value, and its quote character. Only matches inside a start tag. fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> { let mut from = 0; + // The last `<` and `>` before the match, found by one pass over the packet rather than by + // searching back from every match. + let (mut scanned, mut last_open, mut last_close) = (0, None, None); while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) { from = i + name.len(); let before = xmp.get(..i)?; + let gap = xmp.get(scanned..i)?; + #[cfg(test)] + crate::work(gap.len()); + if let Some(at) = gap.rfind('<') { + last_open = Some(scanned + at); + } + if let Some(at) = gap.rfind('>') { + last_close = Some(scanned + at); + } + scanned = i; // Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`). - if !before.ends_with(char::is_whitespace) || before.rfind('<') < before.rfind('>') || before.rfind('<').is_none() { + if !before.ends_with(char::is_whitespace) || last_open < last_close || last_open.is_none() { continue; } let rest = xmp.get(from..)?; @@ -269,4 +282,18 @@ mod tests { } } } + + #[test] + fn attribute_search_scans_the_packet_once() { + // Thousands of attribute-like words in one text node: none is inside a tag. + let packet = format!("{}", " rdf:about".repeat(5_000)); + crate::take_work(); + assert_eq!(attribute_span(&packet, "rdf:about"), None); + let work = crate::take_work(); + assert!(work <= 2 * packet.len(), "{work} bytes scanned in a {}-byte packet", packet.len()); + // A real attribute after them is still found. + let packet = packet.replace("", ""); + let (_, s, e, q) = attribute_span(&packet, "rdf:about").unwrap(); + assert_eq!((packet.get(s..e), q), (Some("u"), '\'')); + } } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 2cda29fea1..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), @@ -339,8 +342,17 @@ fn is_content(d: &Dict) -> bool { /// The device colour a content stream sets. It is read operator by operator, so the bytes of a /// string, a comment or an inline image are never taken for an operator, and an operator right /// before a delimiter (`1 k/GS0 gs`) is still found. -fn content_colour(data: &[u8], used: &mut DeviceColour) { - for op in pdfcraft_content::parse(data).ops { +/// +/// Operators are read one at a time and dropped, so a stream of millions of tiny operators never +/// becomes millions of parsed ones in memory, and reading stops once all three spaces are set. +/// Returns how many operators it read. +fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize { + let mut read = 0; + for op in pdfcraft_content::ops(data) { + if used.gray && used.rgb && used.cmyk { + break; + } + read += 1; match op.op.as_slice() { b"g" | b"G" => used.gray = true, b"rg" | b"RG" => used.rgb = true, @@ -366,6 +378,7 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) { _ => {} } } + read } /// Whether any content or object uses a device colour space. @@ -400,12 +413,18 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) && (referenced.contains(r) || is_content(&s.dict)) && let Ok(data) = s.decoded() { - content_colour(&data, &mut used); + let _ = content_colour(&data, &mut used); } } used } +#[cfg(test)] +thread_local! { + /// Page `/Annots` entries read while matching annotations to their pages. + static ANNOT_ENTRIES_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Check `doc` against `level`. pub fn verify(doc: &Document, level: Level) -> Vec { let mut out = Vec::new(); @@ -476,17 +495,20 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } // Pages: annotations and page actions. let pages = pdfcraft_model::pages(doc); - let page_of = |r: ObjRef| { - pages.iter().position(|p| { - p.dict - .get(b"Annots") - .map(|a| doc.resolve(a)) - .and_then(|a| a.as_array().cloned()) - .unwrap_or_default() - .iter() - .any(|x| x.as_ref() == Some(r)) - }) - }; + // Each annotation's page: the first page that lists it. + let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + #[cfg(test)] + ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); + for r in a.iter().filter_map(Object::as_ref) { + annot_pages.entry(r).or_insert(i); + } + } + } + let page_of = |r: ObjRef| annot_pages.get(&r).copied(); for (i, p) in pages.iter().enumerate() { if p.dict.get(b"AA").is_some() { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); @@ -496,12 +518,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec { let Some(d) = dict_of(o) else { continue }; let ty = d.name(b"Type"); let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role). - if let Some(s) = d.name(b"S") - && FORBIDDEN_ACTIONS.contains(&s) - && is_action(d) - { - issue("6.5.1", "A {} action isn't allowed", Some(String::from_utf8_lossy(s).into_owned()), None, true); + // Actions anywhere (structure elements also have /S: their role), with the actions they + // chain to and those written directly in an /A or /OpenAction. + let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); + let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); + for a in [own].into_iter().chain(direct).flatten() { + if let Some(s) = chain_forbidden(doc, a) { + issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); + } } // Annotations. if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { @@ -633,10 +657,62 @@ fn is_action(d: &Dict) -> bool { } } -/// Whether `o` is (or refers to) a forbidden action, or to nothing. +/// How many actions one action and the `/Next` actions written inside it may hold before the +/// whole chain counts as forbidden, since it can't be checked. +const MAX_ACTION_CHAIN: usize = 1024; + +/// The first forbidden action type in the action `a` or in the actions written directly in its +/// `/Next` (ISO 32000-2 12.6.2: a dictionary or an array of them, which may be an object of its +/// own). An action `/Next` refers to is an object of its own, checked and removed as one. +fn chain_forbidden(doc: &Document, a: &Dict) -> Option { + let mut read = 0; + let mut seen = std::collections::HashSet::new(); + // Actions written in an indirect `/Next` array, copied out of it. + let mut held: Vec = Vec::new(); + if let Some(s) = walk_chain(doc, a, &mut read, &mut seen, &mut held) { + return Some(s); + } + while let Some(d) = held.pop() { + if let Some(s) = walk_chain(doc, &d, &mut read, &mut seen, &mut held) { + return Some(s); + } + } + None +} + +/// [`chain_forbidden`] for the actions reachable from `a` through direct `/Next` entries. The +/// actions of each indirect `/Next` array (each read once) go to `held`. +fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collections::HashSet, held: &mut Vec) -> Option { + let mut stack = vec![a]; + while let Some(d) = stack.pop() { + *read += 1; + if *read > MAX_ACTION_CHAIN { + return Some("too deeply chained".into()); + } + if let Some(s) = d.name(b"S") + && FORBIDDEN_ACTIONS.contains(&s) + { + return Some(String::from_utf8_lossy(s).into_owned()); + } + match d.get(b"Next") { + Some(Object::Dict(next)) => stack.push(next), + Some(Object::Array(list)) => stack.extend(list.iter().filter_map(Object::as_dict)), + Some(Object::Ref(r)) if seen.insert(*r) => { + if let Object::Array(list) = &*doc.get(*r) { + // More than the chain limit can't be read anyway. + held.extend(list.iter().filter_map(Object::as_dict).take(MAX_ACTION_CHAIN).cloned()); + } + } + _ => {} + } + } + None +} + +/// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing. fn forbidden(doc: &Document, o: &Object) -> bool { match &*doc.resolve(o) { - Object::Dict(a) => a.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s)), + Object::Dict(a) => chain_forbidden(doc, a).is_some(), Object::Null => true, _ => false, } @@ -782,14 +858,20 @@ pub fn convert(doc: &mut Document, level: Level) -> Result>"); + let open_action = format!("<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /GoTo /D [3 0 R /Fit] /Next {js} >> >>"); + let goto = format!("<< /Type /Action /S /GoTo /D [3 0 R /Fit] /Next [{js}] >>"); + let cases = [ + build(&[&open_action, pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] >>")], ""), + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("5 0 R"), &goto], ""), + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link(&format!("<< /S /GoTo /D [3 0 R /Fit] /Next {js} >>"))], ""), + // `/Next` refers to an array that holds the action directly. + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] /Next 5 0 R >>"), &format!("[{js}]")], ""), + ]; + for (i, mut doc) in cases.into_iter().enumerate() { + let found = messages(&verify(&doc, Level::A2b)).contains(&"A JavaScript action isn't allowed"); + assert!(found, "case {i}: {:#?}", verify(&doc, Level::A2b)); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(!r.remaining.iter().any(|x| x.clause == "6.5.1"), "case {i}: {:#?}", r.remaining); + let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + assert!(!bytes.windows(11).any(|w| w == b"/JavaScript"), "case {i}: {}", String::from_utf8_lossy(&bytes)); + } +} + +/// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching +/// every page's list again for each annotation. +#[test] +fn annotation_pages_are_found_in_one_pass() { + let n = 2_000; + let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" ")); + let mut objs = + vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), page]; + objs.extend((0..n).map(|_| "<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R >>".to_string())); + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let doc = build(&refs, ""); + ANNOT_ENTRIES_READ.with(|c| c.set(0)); + let issues = verify(&doc, Level::A2b); + assert!(issues.iter().any(|i| i.clause == "6.3.2" && i.page == Some(0)), "{issues:#?}"); + 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..db65492c19 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() { + continue; + } + 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..fabe19a130 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,55 @@ 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}"); +} + +/// A form refused by the spent budget is left out; the text drawn after it still counts. +#[test] +fn text_direction_reads_on_past_a_refused_form() { + let mut doc = Document::new_empty(); + let form = doc.add(Stream::from_raw([(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(), Vec::new())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let mut score = 0; + let content = b"/X Do BT 0 1 -1 0 0 0 Tm (a) Tj ET"; + text_direction(&doc, content, &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::new(1 << 20, 0)); + assert_eq!(score, 1); +} 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/README.md b/crates/redact/README.md index 9c2793e248..cca3d51fdc 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -20,7 +20,8 @@ clear_marks(&mut doc, None)?; // remove marks without app - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed. Images with separate `/SMask` or `/Mask` data are removed whole; + area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes + to more than twice their declared raster (plus a byte per row and 4 KiB), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; @@ -31,8 +32,13 @@ clear_marks(&mut doc, None)?; // remove marks without app page as a `/PCMark /Redaction` stream. A verification pass re-reads each redacted page and fails the operation if any glyph or inline -image is still under an area. Content that can't be decoded makes the operation fail rather than -leave it unredacted. A Redact annotation with neither a usable `/QuadPoints` array nor a usable +image is still under an area. It checks every content stream the page had, whatever its +dictionary calls it; only the overlay streams this `apply` added (`Report::overlays`) are skipped. +Text is placed with the font and size set by `Tf` or by an ExtGState's `/Font` through `gs`, and a +form XObject starts from the text state of the content that draws it. Content that can't be +decoded makes the operation fail rather than leave it unredacted, and so does an inline image the +renderer's tokenizer reads no image for (`RedactError::Unreadable`): where the page's operators +resume after it would be a guess. A Redact annotation with neither a usable `/QuadPoints` array nor a usable `/Rect` makes it fail with `RedactError::UnreadableMark` before anything changes, instead of skipping that mark. The count covers the marks on the pages being applied. @@ -50,15 +56,19 @@ as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed c A tree where that can't be done (an element written in place, or one too large to walk) makes `apply` fail with `RedactError::Tags` rather than leave the annotation reachable. -Replacement descriptions are removed only from marked-content spans changed by cleanup. -Unchanged spans retain their descriptions; unused property entries are retired during redaction +Replacement descriptions (`/Alt`, `/ActualText`, `/E`) are removed from marked-content spans +changed by cleanup, from every structure element above them (found through both `/P` and `/K`), +and from elements that lose an annotation reference, which also lose `/T`. An element written in +place above one whose descriptions go makes `apply` fail with `RedactError::Tags`. Unchanged +spans retain their descriptions; unused property entries are retired during redaction and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still used by surviving pages or forms are kept. A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed -XObject reachable. Pages that aren't redacted are left untouched. +XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own +content doesn't draw, for the same reason. Pages that aren't redacted are left untouched. ## Remove hidden information / Sanitize @@ -71,7 +81,11 @@ sanitize::sanitize(&mut doc)?; // all of them Categories: metadata (`/Info`, XMP), file attachments, comments, form fields (flattened so the values stay visible), hidden text (render modes 3 and 7, or wholly off the page), hidden layers (content of off optional-content groups, then the groups), bookmarks, links/actions/JavaScript, -and `/PieceInfo` private data. Both make the next save a full rewrite. +and `/PieceInfo` private data. XMP metadata, private data, associated files (`/AF`) and +additional actions (`/AA`) are removed from every object reachable from the catalog that owns +them (images, fonts, Forms, annotations and fields as well as the catalog and pages), never from +name maps such as resource dictionaries. Content is read strictly: a page whose content can't be +read in full is refused, not cleaned in part. Both make the next save a full rewrite. `patterns` finds phone numbers, e-mail addresses, credit card numbers (Luhn-checked), US Social Security numbers and dates for Search & Redact. diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 9a19ba2f0b..601700f257 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -77,7 +77,11 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let bytes = row.checked_mul(h)?; let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; - let mut data = s.decoded().ok()?; + // Decode no further than the raster can need: a filter stage may legitimately produce up to + // twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that + // can only be a decompression bomb; the image is then removed whole. + let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?; + let mut data = s.decoded_within(allowance).ok()?; if data.len() < bytes { return None; } diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c9a404b935..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; @@ -74,6 +74,10 @@ pub(crate) struct Output { /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was /// in force: the operation must fail closed rather than leave that text under the mark. pub vertical: bool, + /// The content holds an inline image the renderer reads no image for + /// ([`pdfcraft_content::Parsed::ambiguous_images`]): what this scope placed after it may not + /// be what the page shows, so a redaction must not trust it. + pub ambiguous: bool, pub tag_changes: Vec<(ObjRef, ObjRef, HashSet, HashSet)>, pub error: Option, } @@ -102,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, @@ -120,6 +130,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, + budget, } } @@ -529,8 +540,9 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope } } // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions remain reachable too, even after every page holder is cleaned. - unlink_form_properties(doc, &holder_nodes, retired_properties)?; + // unused descriptions and doomed XObjects remain reachable too, even after every page + // holder is cleaned. + unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; Ok(()) } @@ -567,8 +579,11 @@ fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], c Ok(pruned) } -fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> { - if retired.is_empty() { +/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from +/// the page-tree `holders`, each unless the form's own content (or a form inheriting its +/// resources) still uses the name. Fails closed when that cannot be determined. +fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() && doomed.is_empty() { return Ok(()); } let mut pending: Vec = holders @@ -593,7 +608,27 @@ fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Obj let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; let names: Vec> = res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); - if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? { + let mut xo = res_dict(doc, &res, b"XObject"); + let doomed_names: Vec> = + xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && doomed_names.is_empty() { + pending.push(res); + continue; + } + let data = form.decoded()?; + let mut pruned = false; + for name in doomed_names { + match content_draws(doc, std::slice::from_ref(&data), &xo, &name, 0, &mut HashSet::new()) { + Some(true) => {} + Some(false) => pruned |= xo.remove(&name).is_some(), + None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), + } + } + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; + if pruned { doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; } pending.push(res); @@ -690,6 +725,34 @@ fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Ok(()) } +/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` +/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the +/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). +fn extgstate_font( + doc: &Document, + scope: &mut Scope<'_>, + ext_gstates: &Dict, + name: &[u8], + fallback: &Rc, +) -> Option<(Rc, bool, f64)> { + let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; + let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; + let [font, size] = entry.as_slice() else { return None }; + let size = doc.resolve(size).as_f64()?; + let font_dict = doc.resolve(font).as_dict().cloned(); + let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + let key = [b"\0".as_slice(), name].concat(); + let metrics = match scope.fonts.get(&key) { + Some(m) => m.clone(), + None => { + let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); + scope.fonts.insert(key, m.clone()); + m + } + }; + Some((metrics, vertical, size)) +} + /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -700,11 +763,9 @@ struct Glyph { /// Process a scope's content streams (state flows from one stream to the next). pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, ctm: Matrix) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut gs = Gs { + let gs = Gs { ctm, - font: fallback.clone(), + font: Rc::new(Metrics::fallback()), vertical: false, size: 0.0, char_spacing: 0.0, @@ -714,9 +775,19 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< leading: 0.0, rise: 0.0, }; + process_from(doc, scope, streams, resources, gs) +} + +/// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state +/// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed +/// with the caller's font, size and spacing, as the renderer places it. +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { + let mut out = Output::default(); + let fallback = Rc::new(Metrics::fallback()); let mut stack: Vec = Vec::new(); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let fonts_res = res_dict(doc, resources, b"Font"); + let ext_gstates = res_dict(doc, resources, b"ExtGState"); let mut xobjects = res_dict(doc, resources, b"XObject"); // The current path: its operators, its bounding box in user space, and whether it clips. let mut path: Vec = Vec::new(); @@ -729,6 +800,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); + out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); @@ -865,6 +937,11 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< }; } } + b"gs" => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { + (gs.font, gs.vertical, gs.size) = (font, vertical, size); + } + } b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, @@ -1201,8 +1278,26 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + // Strict, like page content: a Form read only in part counts as undecodable. + 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); @@ -1222,11 +1317,12 @@ fn xobject( let res = own.clone().unwrap_or_else(|| resources.clone()); let saved_fonts = std::mem::take(&mut scope.fonts); scope.depth += 1; - let inner = process(doc, scope, std::slice::from_ref(&data), &res, fctm); + let inner = process_from(doc, scope, std::slice::from_ref(&data), &res, Gs { ctm: fctm, ..gs.clone() }); scope.depth -= 1; scope.fonts = saved_fonts; out.residue += inner.residue; out.vertical |= inner.vertical; + out.ambiguous |= inner.ambiguous; out.doomed.extend_from_slice(&inner.doomed); out.retired_properties.extend(inner.retired_properties); out.tag_changes.extend(inner.tag_changes); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6df5102afa..00fbbbd171 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -64,6 +64,10 @@ pub enum RedactError { "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" )] Tags, + /// Objects written in place nest deeper than hidden information is followed, so its removal + /// could not be shown complete. + #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] + TooDeep, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -139,6 +143,10 @@ pub struct Report { /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field /// values, and from which a relayout could rebuild them) were removed. pub xfa_removed: bool, + /// The content streams this apply added to draw the marks' boxes and labels. They are the + /// only streams a check for text left under the marks may skip: an input stream labelled + /// like an overlay (`/PCMark /Redaction`) is checked like any other. + pub overlays: Vec, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -219,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) { @@ -231,7 +251,9 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< let mut data = Vec::new(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + // 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(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } @@ -335,10 +357,17 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc /// /// # Errors /// -/// This operation is not transactional: an error may leave `doc` partially modified, with -/// sensitive content still present. Call this on a working clone and replace the original only -/// on success. On any error, discard the working clone; do not save or reuse it. +/// On any error `doc` is left as it was: the redaction runs on a copy (cheap: unchanged objects +/// are shared) that replaces `doc` only on success. pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { + let mut work = doc.clone(); + let report = apply_to(&mut work, pages)?; + *doc = work; + Ok(report) +} + +/// [`apply`] without the copy: an error can leave `doc` part redacted. +fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result { let (all_marks, unreadable) = marks_checked(doc); // A mark with no readable area is not applied, and applying the others would look complete: // refuse before anything changes. Only the pages being applied count. @@ -384,10 +413,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 @@ -401,6 +431,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - let (list, data) = page_streams(doc, &page.dict, pi)?; - // The overlay stream (last) draws text of its own: the overlay label is allowed. - let original: Vec> = list - .iter() - .zip(data) - .filter(|(o, _)| doc.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")) - .map(|(_, d)| d) - .collect(); + 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 83db13d728..0b6c04fdf0 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -8,14 +8,14 @@ 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)] pub enum Hidden { /// Document information (`/Info`) and XMP metadata streams. Metadata, - /// Embedded files and file attachment annotations. + /// Embedded files, associated files (`/AF`) and file attachment annotations. Attachments, /// Comments and markup annotations (with their pop-ups). Comments, @@ -87,14 +87,46 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Option { d.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()) } -/// Entries of a name tree (`/Names` leaves, `/Kids` recursion), bounded against cycles. -fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize { - if depth > 32 { +/// The most name-tree nodes one count reads. +const NAME_TREE_NODE_BUDGET: usize = 100_000; + +/// Entries of a name tree (`/Names` leaves, `/Kids` recursion). Each indirect node is read once, +/// however many `/Kids` arrays list it, so shared or cyclic kids cost one step per node rather +/// than one per path. Every `/Kids` entry the walk does not descend into (a repeated reference or +/// a non-dictionary) costs a step as well, so a long `/Kids` array of repeats cannot outrun the +/// budget. Depth and a total step budget bound the rest. +fn name_tree_len(doc: &Document, node: &Dict) -> usize { + let (mut seen, mut visited) = (std::collections::HashSet::new(), 0); + name_tree_len_seen(doc, node, 0, &mut seen, &mut visited) +} + +fn name_tree_len_seen(doc: &Document, node: &Dict, depth: usize, seen: &mut std::collections::HashSet, visited: &mut usize) -> usize { + *visited = visited.saturating_add(1); + if depth > 32 || *visited > NAME_TREE_NODE_BUDGET { return 0; } let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); - let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); - leaves + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).map(|k| name_tree_len(doc, &k, depth + 1)).sum::() + let mut n = leaves; + let kids = node.get(b"Kids").map(|k| doc.resolve(k)); + for k in kids.as_deref().and_then(Object::as_array).map_or(&[][..], Vec::as_slice) { + if *visited > NAME_TREE_NODE_BUDGET { + break; + } + let held; + let kid = match k { + Object::Ref(r) if !seen.insert(*r) => None, + Object::Ref(r) => { + held = doc.get(*r); + held.as_dict() + } + other => other.as_dict(), + }; + match kid { + Some(kid) => n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)), + None => *visited = visited.saturating_add(1), + } + } + n } /// The most distinct outline items one count reads, over all levels together. @@ -141,6 +173,151 @@ fn outline_len_seen( n } +/// Entries that hold a category's data wherever they appear, not only on the catalog and pages: +/// XMP on images, fonts and Forms, private data on Forms, associated files on pages and +/// annotations, additional actions on fields. +const OWNED: [(Hidden, &[u8]); 4] = + [(Hidden::Metadata, b"Metadata"), (Hidden::PrivateData, b"PieceInfo"), (Hidden::Attachments, b"AF"), (Hidden::LinksActionsScripts, b"AA")]; + +/// Keys whose dictionary values map names a producer chooses to objects (resources, glyph +/// procedures, named destinations, role and class maps, private data by application). A name +/// there may be spelt like an owned entry (a font called `/AF`), so these maps are walked but +/// never stripped. +const NAME_MAPS: [&[u8]; 14] = [ + b"Font", + b"XObject", + b"ExtGState", + b"ColorSpace", + b"Pattern", + b"Shading", + b"Properties", + b"CharProcs", + b"Dests", + b"RoleMap", + b"ClassMap", + b"Colorants", + b"Schema", + b"PieceInfo", +]; + +/// How deep the owner sweep follows dictionaries and arrays written in place. +const MAX_DIRECT_DEPTH: usize = 64; + +/// What a dictionary is to the owner sweep. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Held { + /// An object that may own an entry of [`OWNED`]. + Owner, + /// A [`NAME_MAPS`] map: its keys are names, its values owners. + Map, + /// An appearance dictionary (`/AP`): its dictionary values map appearance states. + Appearances, +} + +impl Held { + fn child(self, key: &[u8]) -> Held { + match self { + Held::Owner if NAME_MAPS.contains(&key) => Held::Map, + Held::Owner if key == b"AP" => Held::Appearances, + Held::Appearances => Held::Map, + _ => Held::Owner, + } + } +} + +/// Indirect objects that hold an owned entry, each with the role it was reached in. +type Holders = Vec<(ObjRef, Held)>; + +/// The objects reachable from the catalog that hold one of `keys` outside a name map, and how +/// many dictionaries hold each key. Each object is read at most once per [`Held`] role, so a +/// shared or cyclic graph costs one step per object. +fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Holders), RedactError> { + let mut held = vec![0; keys.len()]; + let mut holders = Vec::new(); + let Some(root) = doc.root() else { return Ok((held, holders)) }; + let mut next = vec![(root, Held::Owner)]; + let mut seen = std::collections::HashSet::new(); + while let Some((r, role)) = next.pop() { + if !seen.insert((r, role)) { + continue; + } + if find_owned(&doc.get(r), role, keys, &mut held, &mut next, 0)? { + holders.push((r, role)); + } + } + Ok((held, holders)) +} + +/// Counts the dictionaries in `o` (and what it holds in place) that hold one of `keys`, and +/// queues the indirect objects it names. Returns whether any key was found. +fn find_owned(o: &Object, role: Held, keys: &[&[u8]], held: &mut [usize], next: &mut Vec<(ObjRef, Held)>, depth: usize) -> Result { + if depth > MAX_DIRECT_DEPTH { + return Err(RedactError::TooDeep); + } + let (d, role) = match o { + Object::Ref(r) => { + next.push((*r, role)); + return Ok(false); + } + Object::Array(a) => { + let mut found = false; + for v in a { + found |= find_owned(v, Held::Owner, keys, held, next, depth + 1)?; + } + return Ok(found); + } + Object::Dict(d) => (d, role), + // A stream's dictionary is never a name map. + Object::Stream(s) => (&s.dict, Held::Owner), + _ => return Ok(false), + }; + let mut found = false; + if role != Held::Map { + for (i, k) in keys.iter().enumerate() { + if d.contains(k) + && let Some(n) = held.get_mut(i) + { + *n = n.saturating_add(1); + found = true; + } + } + } + for (k, v) in d.iter() { + found |= find_owned(v, role.child(k), keys, held, next, depth + 1)?; + } + Ok(found) +} + +/// Removes `keys` from `o` and from what it holds in place, outside name maps. [`owners`] has +/// already refused anything nested past the depth limit. +fn strip_owned(o: &mut Object, role: Held, keys: &[&[u8]], depth: usize) { + if depth > MAX_DIRECT_DEPTH { + return; + } + let (d, role) = match o { + Object::Array(a) => { + for v in a { + strip_owned(v, Held::Owner, keys, depth + 1); + } + return; + } + Object::Dict(d) => (d, role), + Object::Stream(s) => (&mut s.dict, Held::Owner), + _ => return, + }; + if role != Held::Map { + for k in keys { + d.remove(k); + } + } + let names: Vec> = d.iter().filter(|(_, v)| matches!(v, Object::Array(_) | Object::Dict(_))).map(|(k, _)| k.clone()).collect(); + for k in names { + if let Some(v) = d.get_mut(&k) { + strip_owned(v, role.child(&k), keys, depth + 1); + } + } +} + /// A comment as the comments list sees it, less file attachments (removed with Attachments) and /// redaction marks. fn is_comment(subtype: &[u8]) -> bool { @@ -179,16 +356,20 @@ 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)); } + // Content that can't be read may hide anything: count it so the removal runs and + // refuses the page, instead of a zero count skipping it. + total += 1; continue; }; 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])]); @@ -229,7 +410,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut cleaned_page = page.dict.clone(); cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; - let mut res = resources; + let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no @@ -245,6 +426,17 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + // Tags must not describe or point at what the page no longer shows. + let (changed_ids, empty_ids) = crate::tags::touched( + doc, + &resources, + pdfcraft_content::Pieces::join(&data).data(), + pdfcraft_content::Pieces::join(&page_content).data(), + ); + crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + for (old, new, changed, empty) in out.tag_changes { + crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; } } // What a page no longer draws must not stay reachable through an inherited or shared @@ -260,6 +452,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); let names = sub(doc, &cat, b"Names").unwrap_or_default(); + let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); + // A graph nested too deeply to walk counts as holding everything, so the removal runs and + // refuses it. + let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); + let owned = |h: Hidden| OWNED.iter().position(|o| o.0 == h).and_then(|i| held.get(i)).copied().unwrap_or(0); let mut doc2 = doc.clone(); HIDDEN .into_iter() @@ -267,10 +464,13 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let n = match h { Hidden::Metadata => { let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().map(|d| d.len())).unwrap_or(0); - let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); - info + xmp + info + owned(Hidden::Metadata) + } + Hidden::Attachments => { + sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + + count_sub(b"FileAttachment") + + owned(Hidden::Attachments) } - Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), @@ -282,12 +482,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { Hidden::LinksActionsScripts => { count_sub(b"Link") + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) - + usize::from(cat.contains(b"AA")) - + pages.iter().filter(|p| p.dict.contains(b"AA")).count() - + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() - + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t, 0)) + + owned(Hidden::LinksActionsScripts) + + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } - Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), + Hidden::PrivateData => owned(Hidden::PrivateData), }; (h, n) }) @@ -316,7 +515,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0 { + let fields = which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0; + if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { @@ -329,10 +529,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result bool { let s = a.name(b"Subtype").unwrap_or(b""); (which.contains(&Hidden::Comments) && is_comment(s)) + || (fields && s == b"Widget") || (which.contains(&Hidden::Attachments) && s == b"FileAttachment") || (which.contains(&Hidden::LinksActionsScripts) && s == b"Link") }; @@ -349,7 +551,9 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Result Result { - cat.remove(b"Metadata"); doc.trailer_mut().remove(b"Info"); } Hidden::Attachments => { if let Some(n) = names.as_mut() { n.remove(b"EmbeddedFiles"); } - cat.remove(b"AF"); } Hidden::Bookmarks => { cat.remove(b"Outlines"); @@ -416,14 +606,10 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { - cat.remove(b"PieceInfo"); - } Hidden::HiddenLayers if !off.is_empty() => { // The off layers leave the configuration (their content is gone already). if let Some(mut oc) = sub(doc, &cat, b"OCProperties") { @@ -453,6 +639,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = OWNED.iter().filter(|o| which.contains(&o.0)).map(|o| o.1).collect(); + if !keys.is_empty() { + for (r, role) in owners(doc, &keys)?.1 { + let mut o = (*doc.get(r)).clone(); + strip_owned(&mut o, role, &keys, 0); + doc.set(r, o); + } + } if done.is_empty() { return Err(RedactError::NothingToApply); } diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index ab5735e065..60c158b180 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -6,15 +6,49 @@ //! references to marked content that is now empty are dropped from the element. use std::collections::{HashMap, HashSet}; +use std::ops::Range; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::RedactError; -/// Each MCID's operators (serialized), and whether anything is still drawn inside it. -fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap, bool)> { - let mut out: HashMap, bool)> = HashMap::new(); - let mut stack: Vec> = Vec::new(); +/// The marked content of one content stream. Every operator inside some MCID is serialized once +/// into `ops`, and each MCID holds the ranges of `ops` its sequences span (marked-content +/// operators are not written, so a sequence is one range however deeply it nests), with whether +/// anything is drawn inside it. Copying the operators into every enclosing MCID instead would +/// hold `depth` copies of nested content. +#[derive(Default)] +struct Marked { + ops: Vec, + ids: HashMap>, bool)>, +} + +impl Marked { + /// The bytes of `ranges`, which are ranges of `ops`. + fn bytes<'a>(&'a self, ranges: &'a [Range]) -> impl Iterator + 'a { + ranges.iter().flat_map(|r| self.ops.get(r.clone()).unwrap_or_default()) + } +} + +/// Most bytes [`touched`] compares for one stream. Nested sequences share their operators, so the +/// comparisons can add up to the content's size times its nesting depth; past this, the MCIDs +/// left are taken as changed (their descriptions go) rather than compared. +const MAX_COMPARED: usize = 1 << 28; + +fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> Marked { + let mut out = Marked::default(); + // Open sequences: the MCID with where its content starts in `ops` and the drawing operators + // written before it, or `None` for a sequence without one. + let mut stack: Vec> = Vec::new(); + let (mut open_ids, mut drawn) = (0usize, 0usize); + let close = |out: &mut Marked, open: Option<(i64, usize, usize)>, drawn: usize| { + if let Some((id, start, drawn_before)) = open { + let end = out.ops.len(); + let e = out.ids.entry(id).or_default(); + e.0.push(start..end); + e.1 |= drawn > drawn_before; + } + }; for op in pdfcraft_content::parse(data).ops { match op.op.as_slice() { b"BDC" => { @@ -28,9 +62,10 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap None, }; if let Some(id) = id { - out.entry(id).or_default(); + out.ids.entry(id).or_default(); + open_ids += 1; } - stack.push(id); + stack.push(id.map(|id| (id, out.ops.len(), drawn))); continue; } b"BMC" => { @@ -38,11 +73,17 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap { - stack.pop(); + if let Some(open) = stack.pop() { + open_ids = open_ids.saturating_sub(usize::from(open.is_some())); + close(&mut out, open, drawn); + } continue; } _ => {} } + if open_ids == 0 { + continue; + } let draws = match op.op.as_slice() { b"Tj" | b"'" | b"\"" => op.operands.last().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()), b"TJ" => { @@ -51,13 +92,12 @@ fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap true, _ => false, }; - let mut bytes = Vec::new(); - pdfcraft_content::write_op(&op, &mut bytes); - for id in stack.iter().flatten() { - let e = out.entry(*id).or_default(); - e.0.extend_from_slice(&bytes); - e.1 |= draws; - } + pdfcraft_content::write_op(&op, &mut out.ops); + drawn += usize::from(draws); + } + // Sequences the stream leaves open run to its end. + while let Some(open) = stack.pop() { + close(&mut out, open, drawn); } out } @@ -67,10 +107,17 @@ pub(crate) fn touched(doc: &Document, resources: &Dict, before: &[u8], after: &[ let (b, a) = (by_mcid(doc, resources, before), by_mcid(doc, resources, after)); let mut changed = HashSet::new(); let mut empty = HashSet::new(); - for (id, (ops, _)) in &b { - match a.get(id) { + let mut budget = MAX_COMPARED; + let len = |ranges: &[Range]| ranges.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); + for (id, (old, _)) in &b.ids { + match a.ids.get(id) { Some((new, draws)) => { - if new != ops { + let n = len(old); + let same = n == len(new) && n <= budget && { + budget -= n; + b.bytes(old).eq(a.bytes(new)) + }; + if !same { changed.insert(*id); } if !draws { @@ -136,20 +183,21 @@ pub(crate) fn clean_within( // also from the parent tree and the ID tree: one listed only there still owns marked content // (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits // from its ancestors before a tree reaches it without one. - let mut todo: Vec<(Object, Option)> = - [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect(); + let mut todo: Vec<(Object, Option, Above)> = + [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None, Above::default())).collect(); let mut seen = HashSet::new(); let mut pages = HashMap::new(); + let mut parents = Parents::new(); let mut hits: Vec<(ObjRef, Dict, Option, bool)> = Vec::new(); // Whether marked content `x` (a kid on page `pg`) is one this cleanup must change. let touches = |doc: &Document, x: &Object, pg: Option| { mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m)) }; - while let Some((k, pg)) = todo.pop() { + while let Some((k, pg, above)) = todo.pop() { match k { - Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))), + Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg, above))), // A number or name tree node written in place. - Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))), + Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))), // An element written in place: its kids are followed, but if it owns marked content // this cleanup changes, only rewriting what holds it could change it. Object::Dict(d) => { @@ -161,9 +209,11 @@ pub(crate) fn clean_within( if kids.iter().any(|x| touches(doc, x, pg)) { return Err(RedactError::Tags); } - todo.extend(kids.into_iter().map(|x| (x, pg))); + let above = above.through(&d); + todo.extend(kids.into_iter().map(|x| (x, pg, above))); } Object::Ref(r) => { + parents.record(r, above); if !seen.insert(r) { continue; } @@ -172,13 +222,13 @@ pub(crate) fn clean_within( } let object = doc.get(r); if let Object::Array(a) = &*object { - todo.extend(a.iter().cloned().map(|x| (x, pg))); + todo.extend(a.iter().cloned().map(|x| (x, pg, above))); continue; } let Some(d) = object.as_dict().cloned() else { continue }; if !d.contains(b"S") { // A parent tree or ID tree node: its values may be elements. - todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))); + todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg, above))); continue; } let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) { @@ -191,11 +241,12 @@ pub(crate) fn clean_within( if sensitive || remap { hits.push((r, d.clone(), pg, sensitive)); } - todo.extend(kids.into_iter().map(|x| (x, pg))); + todo.extend(kids.into_iter().map(|x| (x, pg, Above::element(r)))); } _ => {} } } + let sensitive: Vec = hits.iter().filter(|h| h.3).map(|h| h.0).collect(); let mut n = 0; for (r, d, pg, sensitive) in hits { let kids = d.get(b"K").map(|k| doc.resolve(k)); @@ -244,9 +295,88 @@ pub(crate) fn clean_within( })?; n += usize::from(sensitive && doc.get(r).as_dict() != Some(&d)); } + // An element above may describe the changed content too (`/ActualText` on a span's parent + // covers the span, §14.9.4). + n += clear_ancestors(doc, &sensitive, &parents, limit)?; Ok(n) } +/// Where the walk reached an object from: the nearest element above it that is an object of its +/// own, and whether an element written in place between them carries a description (which only +/// rewriting the object holding it could remove). +#[derive(Clone, Copy, Default)] +struct Above { + element: Option, + described_in_place: bool, +} + +impl Above { + fn element(r: ObjRef) -> Self { + Self { element: Some(r), described_in_place: false } + } + + /// Below an element written in place. + fn through(self, d: &Dict) -> Self { + Self { described_in_place: self.described_in_place || DESCRIPTIONS.iter().any(|k| d.contains(k)), ..self } + } +} + +/// The keys of an element that describe its content. +const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"]; + +/// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with. +struct Parents(HashMap>); + +impl Parents { + fn new() -> Self { + Self(HashMap::new()) + } + + fn record(&mut self, r: ObjRef, above: Above) { + if above.element.is_some() || above.described_in_place { + self.0.entry(r).or_default().push(above); + } + } +} + +/// Remove the descriptions of every element above `elements`, through both `/P` and the walk's +/// `parents`; returns how many elements changed. [`RedactError::Tags`] when an element written in +/// place above them carries one, or when there are more than `limit` ancestors. +fn clear_ancestors(doc: &mut Document, elements: &[ObjRef], parents: &Parents, limit: usize) -> Result { + let mut todo: Vec = elements.to_vec(); + let mut seen: HashSet = elements.iter().copied().collect(); + let mut cleared = 0; + while let Some(r) = todo.pop() { + let mut up: Vec = doc.get(r).as_dict().and_then(|d| d.get(b"P")).and_then(Object::as_ref).into_iter().collect(); + for above in parents.0.get(&r).into_iter().flatten() { + if above.described_in_place { + return Err(RedactError::Tags); + } + up.extend(above.element); + } + for a in up { + if !seen.insert(a) { + continue; + } + if seen.len() > limit { + return Err(RedactError::Tags); + } + // The structure tree root (no /S) ends the climb. + let Some(d) = doc.get(a).as_dict().filter(|d| d.contains(b"S")).cloned() else { continue }; + if DESCRIPTIONS.iter().any(|k| d.contains(k)) { + doc.update_dict(a, |e| { + for key in DESCRIPTIONS { + e.remove(key); + } + })?; + cleared += 1; + } + todo.push(a); + } + } + Ok(cleared) +} + /// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following /// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than /// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps @@ -337,12 +467,14 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet }; // Elements are reached from the root's /K, and also from the parent tree and the ID tree: // one listed only there (left behind by an editor, say) still points at its annotation. - let mut todo: Vec = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect(); + let mut todo: Vec<(Object, Above)> = + [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, Above::default())).collect(); let mut seen = HashSet::new(); + let mut parents = Parents::new(); let mut hits: Vec = Vec::new(); - while let Some(k) = todo.pop() { + while let Some((k, above)) = todo.pop() { match k { - Object::Array(a) => todo.extend(a), + Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, above))), // A structure element written in place: its kids are followed; one that tags a // removed annotation can't be changed here (see above). Object::Dict(d) if d.contains(b"S") => { @@ -350,11 +482,13 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet if kids.iter().any(|x| refers_to(doc, x, gone)) { return Err(RedactError::Tags); } - todo.extend(kids); + let above = above.through(&d); + todo.extend(kids.into_iter().map(|x| (x, above))); } // A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids. - Object::Dict(d) => todo.extend(tree_node_entries(&d)), + Object::Dict(d) => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, above))), Object::Ref(r) => { + parents.record(r, above); if !seen.insert(r) { continue; } @@ -363,26 +497,26 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } let object = doc.get(r); if let Object::Array(a) = &*object { - todo.extend(a.iter().cloned()); + todo.extend(a.iter().cloned().map(|x| (x, above))); continue; } let Some(d) = object.as_dict() else { continue }; if !d.contains(b"S") { // A parent tree or ID tree node: its values may be elements. - todo.extend(tree_node_entries(d)); + todo.extend(tree_node_entries(d).into_iter().map(|x| (x, above))); continue; } let kids = kids_of(doc, d); if kids.iter().any(|x| refers_to(doc, x, gone)) { hits.push(r); } - todo.extend(kids); + todo.extend(kids.into_iter().map(|x| (x, Above::element(r)))); } _ => {} } } let mut n = 0; - for r in hits { + for &r in &hits { let Some(d) = doc.get(r).as_dict().cloned() else { continue }; let kids = d.get(b"K").map(|k| doc.resolve(k)); let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); @@ -402,14 +536,21 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet } else { e.remove(b"K"); } - // An element left with nothing to tag keeps no text describing what it tagged: - // a link's alternate text often repeats the URL, an annotation's its comment. - if keep.is_empty() { - for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { - e.remove(key); - } + // An element that lost an annotation keeps no text describing it, even with other + // kids left: a link's alternate text often repeats the URL, an annotation's its + // comment. Elements above it may describe it too. + for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + e.remove(key); } })?; } + clear_ancestors(doc, &hits, &parents, limit)?; Ok(n) } + +/// Bytes held for comparing marked content, for tests that bound it. +#[cfg(test)] +pub(crate) fn retained_for_comparison(doc: &Document, resources: &Dict, data: &[u8]) -> usize { + let marked = by_mcid(doc, resources, data); + marked.ops.len() + marked.ids.values().map(|(ranges, _)| ranges.len() * std::mem::size_of::>()).sum::() +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 286667937d..e29cd99795 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 } @@ -1689,8 +1690,8 @@ fn bluefin_survives(doc: &Document) -> bool { /// #3285 review (the maintainer's probes): an element written in place in the parent tree that /// owns the redacted text can't be changed, so applying is refused; the same element as an object -/// of its own is cleaned, and nothing of the word is left in a full rewrite. -/// Refusal does not promise rollback: direct callers discard the failed working copy. +/// of its own is cleaned, and nothing of the word is left in a full rewrite. A refusal leaves the +/// document as it was. #[test] fn an_element_written_in_place_owning_redacted_text_is_refused() { let objs = |element: &str| -> Vec> { @@ -1710,17 +1711,13 @@ fn an_element_written_in_place_owning_redacted_text_is_refused() { let mut doc = pdf(objs("<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>")); mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); let before = write_full(&doc, &SaveOptions::default()).unwrap(); - let mut working = doc.clone(); - let error = apply(&mut working, None).unwrap_err(); + let error = apply(&mut doc, None).unwrap_err(); assert!(matches!(error, RedactError::Tags)); assert_eq!( error.to_string(), "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" ); - assert!(!content(&working, 0).contains("Bluefin"), "the failed working copy was partially changed"); - assert!(bluefin_survives(&working), "the failed working copy still holds the word in its tags"); - drop(working); - assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "the refused document is unchanged"); let mut doc = pdf(objs("7 0 R")); mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); let r = apply(&mut doc, None).unwrap(); @@ -2461,7 +2458,12 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } } +mod descriptions; +mod inline_images; +mod limits; mod property_retirement; +mod surviving_references; +mod text_state; /// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still /// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not @@ -2472,3 +2474,291 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); 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] +fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() { + use crate::sanitize::{Hidden, scan}; + let levels = 12; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + ]; + for k in 0..levels { + let next = 6 + k; + objs.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>").into_bytes()); + } + objs.push(b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec()); + let doc = pdf(objs); + let counts: std::collections::HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths"); +} + +/// Page content that stops decoding at its start (`z` inside an ASCII85 group) while the rest of +/// the stream draws hidden text. A tolerant decode reads nothing, counts nothing and keeps the +/// stream; sanitizing must refuse instead. +#[test] +fn hidden_text_in_content_that_stops_decoding_is_refused() { + use crate::sanitize::{Hidden, remove_hidden, sanitize}; + let encoded: &[u8] = br"!!z6<#'\7PQ#?0Ha>,+>Y->EZd(j+>PW)+B2ko-rWQ%;FOP6+B3(u78s~>"; + let fresh = || { + let mut doc = one_page(b"", "", vec![stream("/Filter /ASCII85Decode", encoded)]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + doc + }; + let mut doc = fresh(); + let out = remove_hidden(&mut doc, &[Hidden::HiddenText]); + assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); + let mut doc = fresh(); + let out = sanitize(&mut doc); + assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}"); +} + +/// An array of references to objects `nums` (generation 0). +fn ref_array(nums: &[u32]) -> Object { + Object::Array(nums.iter().map(|&n| Object::Ref(ObjRef::new(n, 0))).collect()) +} + +/// An interactive form dictionary listing fields `nums`. +fn fields_dict(nums: &[u32]) -> Object { + let mut d = Dict::new(); + d.set(b"Fields".to_vec(), ref_array(nums)); + Object::Dict(d) +} + +/// Flattening keeps hidden widgets (they draw nothing); removing form fields must still take them +/// and their values, and their parent field's value, out of the file. +#[test] +fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", + "", + vec![ + // 7 a hidden terminal field merged with its widget, 8 a parent field, 9 its hidden widget + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (own) /V (LEAK_WIDGET_VALUE) /F 2 /Rect [10 200 150 220] /AP << /N 10 0 R >> >>".to_vec(), + b"<< /FT /Tx /T (parent) /V (LEAK_PARENT_VALUE) /Kids [9 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 8 0 R /F 32 /Rect [10 230 150 250] /AP << /N 10 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 140 20]", b"0 g"), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[7, 8]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7, 9]))).unwrap(); + remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); +} + +/// Removing hidden text cleans the structure tree as redaction does: the elements describing the +/// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. +#[test] +fn removing_hidden_text_cleans_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = one_page( + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC /Fm Do", + "/XObject << /Fm 7 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", + b"/P <> BDC BT /F1 10 Tf 3 Tr 10 150 Td (FORMSECRET) Tj ET EMC", + ), + b"<< /Type /StructTreeRoot /K 9 0 R >>".to_vec(), + b"<< /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (LEAK_PAGE_TAG) /K 0 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /P /P 9 0 R /Pg 3 0 R /Alt (LEAK_FORM_TAG) /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"StructTreeRoot".to_vec(), Object::Ref(ObjRef::new(8, 0)))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"StructParents".to_vec(), Object::Int(0))).unwrap(); + remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"), "untouched content keeps its tags"); +} + +/// One page drawing image /Im (object 7) and form /Fm (object 8), with `extra` objects from 9 on. +fn owners_fixture(image: &str, form: &str, extra: Vec>) -> Document { + let mut objs = vec![ + stream(&format!("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 {image}"), b"\x80"), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 10 10] {form}"), b"0 0 5 5 re f"), + ]; + objs.extend(extra); + one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET q 10 0 0 10 0 0 cm /Im Do Q /Fm Do", "/XObject << /Im 7 0 R /Fm 8 0 R >>", objs) +} + +/// Removing one category and saving leaves none of `secret` in the file, and the scan counted it. +fn removes_from_every_owner(mut doc: Document, h: crate::sanitize::Hidden, secret: &[u8]) { + use crate::sanitize::{remove_hidden, scan}; + let counts: std::collections::HashMap<_, usize> = scan(&doc).into_iter().collect(); + assert!(counts[&h] > 0, "{h:?} is counted"); + remove_hidden(&mut doc, &[h]).unwrap(); + assert_no_secret(&doc, secret); + assert!(scan(&reopen(&doc)).into_iter().all(|(k, n)| k != h || n == 0 || h == crate::sanitize::Hidden::Metadata)); +} + +#[test] +fn private_data_of_a_form_xobject_is_removed() { + let doc = owners_fixture("", "/PieceInfo << /App << /Private (LEAK_PRIVATE) >> >>", vec![]); + removes_from_every_owner(doc, crate::sanitize::Hidden::PrivateData, b"LEAK_"); +} + +#[test] +fn metadata_of_an_image_is_removed() { + let doc = owners_fixture("/Metadata 9 0 R", "", vec![stream("/Type /Metadata /Subtype /XML", b"LEAK_XMP")]); + removes_from_every_owner(doc, crate::sanitize::Hidden::Metadata, b"LEAK_"); +} + +#[test] +fn a_pages_associated_files_are_removed_with_attachments() { + let mut doc = owners_fixture( + "", + "", + vec![b"<< /Type /Filespec /F (a.txt) /AFRelationship /Data /EF << /F 10 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], + ); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"AF".to_vec(), ref_array(&[9]))).unwrap(); + removes_from_every_owner(doc, crate::sanitize::Hidden::Attachments, b"LEAK_"); +} + +#[test] +fn a_fields_own_actions_are_removed_with_links_actions_and_scripts() { + let mut doc = owners_fixture( + "", + "", + vec![ + b"<< /FT /Tx /T (total) /AA << /C << /S /JavaScript /JS (LEAK_SCRIPT) >> >> /Kids [10 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 200 150 220] >>".to_vec(), + ], + ); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[9]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[10]))).unwrap(); + removes_from_every_owner(doc, crate::sanitize::Hidden::LinksActionsScripts, b"LEAK_"); +} + +/// Resource names are chosen by the producer: a font named like an owned entry stays. +#[test] +fn a_resource_named_like_an_owned_entry_is_kept() { + let mut doc = one_page( + b"BT /AF 10 Tf 10 100 Td (Visible) Tj ET", + "", + vec![b"<< /Type /Filespec /F (a.txt) /EF << /F 8 0 R >> >>".to_vec(), stream("/Type /EmbeddedFile", b"LEAK_ATTACHED")], + ); + doc.update_dict(ObjRef::new(3, 0), |d| { + let mut fonts = Dict::new(); + fonts.set(b"AF".to_vec(), Object::Ref(ObjRef::new(5, 0))); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"AF".to_vec(), ref_array(&[7])); + }) + .unwrap(); + removes_from_every_owner(doc.clone(), crate::sanitize::Hidden::Attachments, b"LEAK_"); + crate::sanitize::remove_hidden(&mut doc, &[crate::sanitize::Hidden::Attachments]).unwrap(); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + assert!(res.get(b"Font").and_then(|f| f.as_dict()).is_some_and(|f| f.contains(b"AF"))); + assert!(!page.dict.contains(b"AF")); +} + +/// Owners written in place deeper than the sweep follows are refused, not skipped. +#[test] +fn owners_nested_too_deeply_are_refused() { + use crate::sanitize::{Hidden, remove_hidden}; + let nested = format!("{}<< /AA << /O << /S /JavaScript /JS (LEAK_DEEP) >> >> >>{}", "[".repeat(100), "]".repeat(100)); + let mut doc = one_page(b"BT ET", "", vec![format!("<< /Deep {nested} >>").into_bytes()]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"PieceInfo".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap(); + let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); + assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); +} + +/// A name tree node that lists the same kid more times than the node budget: each repeat costs a +/// step, so the walk stops at the budget instead of scanning every repeat, and the distinct kid +/// listed after them is never reached. +#[test] +fn repeated_name_tree_kids_count_towards_the_node_budget() { + use crate::sanitize::{Hidden, scan}; + let repeats = "6 0 R ".repeat(100_010); + let doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 5 0 R >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + format!("<< /Kids [{repeats}7 0 R] >>").into_bytes(), + b"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".to_vec(), + b"<< /Names [(b.txt) << /Type /Filespec /F (b.txt) >>] >>".to_vec(), + ]); + 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"); +} + +/// A page that fails to read after another page was already redacted leaves the whole document as +/// it was, the shared resources the first page's rewrite localised included. +#[test] +fn a_failure_on_a_later_page_leaves_the_document_unchanged() { + let mut doc = two_pages_one_resource_dictionary(true, false); + let all = pdfcraft_model::pages(&doc); + let (first, second, parent) = (all[0].obj, all[1].obj, doc.get(all[0].obj).as_dict().unwrap().reference(b"Parent").unwrap()); + let mut dct = Dict::new(); + dct.set(b"Filter".to_vec(), Object::name("DCTDecode")); + let bad = doc.add(Object::Stream(Stream::from_raw(dct, b"garbage".to_vec()))); + doc.update_dict(second, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap(); + mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]], ""); + mark(&mut doc, 1, &[[0.0, 0.0, 50.0, 50.0]], ""); + let before: Vec = [first, second, parent].iter().map(|r| (*doc.get(*r)).clone()).collect(); + let text = content(&doc, 0); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(2))); + let after: Vec = [first, second, parent].iter().map(|r| (*doc.get(*r)).clone()).collect(); + assert_eq!(after, before); + assert_eq!(content(&doc, 0), text); +} diff --git a/crates/redact/src/tests/descriptions.rs b/crates/redact/src/tests/descriptions.rs new file mode 100644 index 0000000000..df6b8555dc --- /dev/null +++ b/crates/redact/src/tests/descriptions.rs @@ -0,0 +1,64 @@ +//! Descriptions in the structure tree (alternate text, actual text, expansions) go with the +//! content they describe, on the element that owns it and on every element above it. + +use super::surviving_references::full_save_holds; +use super::*; + +/// A tagged page: "SECRET" (MCID 0) inside a section whose descriptions repeat it, and "Public" +/// (MCID 1). `paragraph_parent` is the secret paragraph's /P. +fn sectioned(paragraph_parent: &str) -> Document { + let content = b"/P <> BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC /P <> BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC"; + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R /MarkInfo << /Marked true >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Type /StructTreeRoot /K 8 0 R >>".to_vec(), + b"<< /S /Document /P 7 0 R /K [11 0 R 10 0 R] >>".to_vec(), + format!("<< /S /P /P {paragraph_parent} /Pg 3 0 R /K 0 >>").into_bytes(), + b"<< /S /P /P 8 0 R /Pg 3 0 R /ActualText (Public) /K 1 >>".to_vec(), + b"<< /S /Sect /P 8 0 R /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /K 9 0 R >>".to_vec(), + ]) +} + +#[test] +fn an_ancestor_describing_redacted_content_loses_its_descriptions() { + // The paragraph names its section as parent, or (wrongly) the document: the section is found + // either way, by its /K. + for parent in ["11 0 R", "8 0 R"] { + let mut doc = sectioned(parent); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the redacted word survived (paragraph /P {parent})"); + let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + assert!(public.contains(b"ActualText"), "an element beside it keeps its own"); + } +} + +#[test] +fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annotation() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>" + .to_vec(), + stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(), + b"null".to_vec(), + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /ActualText (See LEAK_PARENT) /K 11 0 R >>".to_vec(), + // The link element tags its text and the annotation; its descriptions repeat the address. + b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /Alt (LEAK_ALT) /ActualText (LEAK_ACTUAL) /E (LEAK_E) /T (LEAK_T) /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + widths(), + ]); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)), "{done:?}"); + assert!(!full_save_holds(&doc, b"LEAK_"), "a description of the removed link survived"); + let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap(); + assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text"); +} diff --git a/crates/redact/src/tests/inline_images.rs b/crates/redact/src/tests/inline_images.rs new file mode 100644 index 0000000000..77fd4dc2d7 --- /dev/null +++ b/crates/redact/src/tests/inline_images.rs @@ -0,0 +1,34 @@ +//! Inline image data ends where the renderer ends it, so the operators after it are the ones the +//! page draws. + +use super::*; + +/// The image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` straight after `7F`. The renderer ends +/// the image there (text follows, so what comes after reads as content) and runs the `cm`, +/// drawing SECRET at x = 210, not 10. +const FAKE_EI: &[u8] = b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET"; + +#[test] +fn text_after_an_inline_image_is_redacted_where_the_renderer_draws_it() { + let mut doc = one_page(FAKE_EI, "", vec![]); + assert_eq!(under(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]]), 0, "nothing is drawn at x = 10"); + mark(&mut doc, 0, &[[205.0, 95.0, 245.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +#[test] +fn an_inline_image_with_one_possible_end_is_redacted() { + let mut doc = one_page(b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", "", vec![]); + mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +/// Without an `EI` the renderer accepts, where the operators resume is a guess: refused. +#[test] +fn a_page_whose_inline_image_the_renderer_cannot_end_is_refused() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F", "", vec![]); + mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); +} diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs new file mode 100644 index 0000000000..8519e08608 --- /dev/null +++ b/crates/redact/src/tests/limits.rs @@ -0,0 +1,55 @@ +//! Work and memory stay bounded by what the page declares, whatever the file holds. + +use super::*; + +/// A half-covered 4×1 grey image whose Flate data decodes to `decoded`. +fn half_covered_image(decoded: &[u8]) -> Report { + let dict = "/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8"; + let mut doc = one_page(b"q 40 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"")]); + let Object::Stream(s) = &*doc.get(ObjRef::new(7, 0)) else { panic!() }; + doc.set(ObjRef::new(7, 0), Object::Stream(Stream::flate(s.dict.clone(), decoded))); + mark(&mut doc, 0, &[[95.0, 95.0, 120.0, 115.0]], ""); + apply(&mut doc, None).unwrap() +} + +#[test] +fn an_image_decoding_far_past_its_raster_is_removed_without_decoding_it_all() { + let r = half_covered_image(&[255; 4]); + assert_eq!((r.images_removed, r.images_cleared), (0, 1), "{r:?}"); + // 4 bytes of raster, then 4 MiB the image never uses: decoding stops at a bound derived from + // the raster, and the image goes whole. + let mut bomb = vec![255; 4]; + bomb.resize(4 << 20, 0); + let r = half_covered_image(&bomb); + assert_eq!((r.images_removed, r.images_cleared), (1, 0), "{r:?}"); +} + +/// Marked content nested `depth` deep, each level with its own MCID, around `depth` drawing +/// operators. +fn nested_marked_content(depth: usize) -> Vec { + let mut data = Vec::new(); + for id in 0..depth { + data.extend_from_slice(format!("/P <> BDC\n").as_bytes()); + } + data.extend(b"0 0 1 1 re f\n".repeat(depth)); + data.extend(b"EMC\n".repeat(depth)); + data +} + +#[test] +fn comparing_deeply_nested_marked_content_holds_each_operator_once() { + let doc = one_page(b"", "", vec![]); + let data = nested_marked_content(2000); + let held = tags::retained_for_comparison(&doc, &Dict::new(), &data); + assert!(held <= 2 * data.len(), "{held} bytes held for {} bytes of content", data.len()); +} + +#[test] +fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() { + let doc = one_page(b"", "", vec![]); + let before = b"/P <> BDC /P <> BDC BT (A) Tj ET EMC 0 0 1 1 re f EMC /P <> BDC BT (B) Tj ET EMC"; + let after = b"/P <> BDC /P <> BDC BT ET EMC 0 0 1 1 re f EMC /P <> BDC BT (B) Tj ET EMC"; + let (changed, empty) = tags::touched(&doc, &Dict::new(), before, after); + assert_eq!(changed, HashSet::from([0, 1])); + assert_eq!(empty, HashSet::from([1])); +} diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs new file mode 100644 index 0000000000..d3aa98af67 --- /dev/null +++ b/crates/redact/src/tests/surviving_references.rs @@ -0,0 +1,68 @@ +//! Redacted content must leave the file: no other reference may keep it reachable by a full save. + +use super::*; + +/// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream. +pub(super) fn full_save_holds(doc: &Document, secret: &[u8]) -> bool { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret); + if found(&bytes) { + return true; + } + let saved = Document::open(Arc::new(bytes)).unwrap(); + saved.scan_objects().into_iter().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.decoded().is_ok_and(|d| found(&d)))) +} + +#[test] +fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() { + let mut doc = one_page(b"", "", vec![]); + pdfcraft_forms::add_field(&mut doc, 0, [10.0, 50.0, 100.0, 70.0], &pdfcraft_forms::NewField::Text { multiline: false }, Some("ssn")).unwrap(); + pdfcraft_forms::set_value(&mut doc, "ssn", &pdfcraft_forms::FieldValue::Text("SSN-078-05-1120".into())).unwrap(); + let field = pdfcraft_forms::fields(&doc).swap_remove(0).obj; + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().unwrap().reference(b"AcroForm").unwrap(); + doc.update_dict(af, |d| d.set(b"CO".to_vec(), Object::Array(vec![Object::Ref(field)]))).unwrap(); + mark(&mut doc, 0, &[[0.0, 40.0, 120.0, 80.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().fields, 1); + assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable"); +} + +/// A surviving form shares the page's resource dictionary, which lists the image the mark +/// removes; the form never draws it. +#[test] +fn a_redacted_image_does_not_survive_in_a_surviving_forms_resources() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"BT /F1 10 Tf 10 200 Td (Public) Tj ET"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(content(&doc, 0).contains("Do"), "the form is still drawn"); + assert!(!full_save_holds(&doc, b"IMAGESECRET"), "the removed image is still reachable"); +} + +/// The same form drawing the image itself keeps it: the page still shows it there. +#[test] +fn an_image_a_surviving_form_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Font << /F1 5 0 R >> /XObject << /Im 8 0 R /Fm 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 7 0 R", b"q 50 0 0 50 200 200 cm /Im Do Q"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); +} diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs new file mode 100644 index 0000000000..4e888f262c --- /dev/null +++ b/crates/redact/src/tests/text_state.rs @@ -0,0 +1,27 @@ +//! Glyphs are placed with the whole text state, wherever it was set. + +use super::*; + +const SECRET_AT_10_100: [f64; 4] = [5.0, 95.0, 45.0, 110.0]; + +#[test] +fn a_font_set_by_an_extgstate_places_its_glyphs() { + let mut doc = one_page(b"BT /GS1 gs 10 100 Td (SECRET) Tj ET", "/ExtGState << /GS1 << /Type /ExtGState /Font [5 0 R 10] >> >>", vec![]); + // "ET" spans x 30–40. + assert_eq!(under(&mut doc, 0, &[[30.0, 95.0, 40.0, 110.0]]), 2); + mark(&mut doc, 0, &[SECRET_AT_10_100], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} + +#[test] +fn a_form_inherits_the_text_state_of_the_page_that_draws_it() { + // The form shows text with the font, size and spacing the page set before `Do`. + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT 10 100 Td (SECRET) Tj ET"); + let mut doc = one_page(b"/F1 10 Tf 1 Tc /Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]); + // Glyphs 5 pt wide, 1 pt apart: the last one spans x 40–45. + assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 46.0, 110.0]]), 1); + mark(&mut doc, 0, &[SECRET_AT_10_100], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); +} 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..cb0069330c 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -587,7 +587,10 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig .and_then(|(_, w)| nums(doc, w, b"Rect")) .filter(|r| r.len() == 4) .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); - let visible = rect.is_some_and(|r| r[2] - r[0] > 0.5 && r[3] - r[1] > 0.5); + // Shown when it has a box, and its widget isn't Hidden (2) or NoView (32), as the forms + // crate reads a widget's flags. + let flags = widget.and_then(|(_, w)| w.get(b"F")).and_then(|o| doc.resolve(o).as_int()).unwrap_or(0); + let visible = rect.is_some_and(|r| r[2] - r[0] > 0.5 && r[3] - r[1] > 0.5) && flags & (2 | 32) == 0; let v = f.signature(doc); let mut info = SignatureInfo { field: f.name.clone(), @@ -892,7 +895,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 +933,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 +985,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