diff --git a/ROADMAP.md b/ROADMAP.md index bad75fc3b..e0e7f5776 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,6 +171,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged. + - **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8cfa82674..cd9e0744c 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -230,15 +230,19 @@ fn is_dct(s: &Stream) -> Option { /// 8-bit samples of an image (`n` components), decoded. pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { - let (w, h) = (s.dict.int(b"Width")? as usize, s.dict.int(b"Height")? as usize); + let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + 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()?; // 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() == w * h * n).then_some(v) + (v.len() == bytes).then_some(v) } else { let v = s.decoded().ok()?; - (v.len() >= w * h * n).then(|| v[..w * h * n].to_vec()) + Some(v.get(..bytes)?.to_vec()) } } @@ -316,7 +320,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let was_dct = is_dct(s)?; - let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; } @@ -347,7 +351,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us if m.dict.int(b"BitsPerComponent") != Some(8) || m.dict.contains(b"Matte") || m.dict.contains(b"Decode") { return None; } - let (mw, mh) = (m.dict.int(b"Width")? as u32, m.dict.int(b"Height")? as u32); + let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?); let mpx = pixels(m, 1)?; let mpx = resample(&mpx, 1, mw, mh, nw, nh)?; smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?)); @@ -409,3 +413,24 @@ pub(crate) fn run( } Ok(()) } + +#[cfg(test)] +mod dimension_tests { + use super::*; + + #[test] + fn pixel_reader_rejects_hostile_dimensions_before_decoding() { + for (width, height, channels) in [(-1, 1, 1), (0, 1, 1), (i64::from(u32::MAX) + 101, 1, 1), (80_000_001, 1, 1), (2, 2, usize::MAX)] { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(width)); + dict.set(b"Height".to_vec(), Object::Int(height)); + let stream = Stream::from_raw(dict, vec![0; 4]); + assert!(pixels(&stream, channels).is_none(), "{width} x {height} x {channels}"); + } + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(2)); + dict.set(b"Height".to_vec(), Object::Int(2)); + let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); + assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); + } +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2a83e8d6a..4d16f59cd 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1022,3 +1022,39 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { let refs = pdfcraft_annot::page_refs(&doc).unwrap(); assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } + +#[test] +fn a_hostile_image_size_is_left_alone_not_multiplied_into_overflow() { + // /Width and /Height are file-controlled: 2^32 + 100 wraps to 100 when read as u32, while the + // real values overflow `w * h * n`. The image must be skipped, not sized by a wrapped product. + let mut doc = Document::new_empty(); + let side = i64::from(u32::MAX) + 101; + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(side)); + d.set(b"Height".to_vec(), Object::Int(side)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let hostile = doc.add(Object::Stream(Stream::flate(d, &[7u8; 256]))); + page(&mut doc, &[("Im", hostile)], "q 1 0 0 1 10 10 cm /Im Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, hostile).dict.int(b"Width"), Some(side), "the image is left as it was"); +} + +#[test] +fn a_hostile_soft_mask_leaves_its_image_unchanged() { + let mut doc = Document::new_empty(); + let mask = image(&mut doc, 400, 400, 1, false, None); + let mut hostile = stream(&doc, mask); + hostile.dict.set(b"Width".to_vec(), Object::Int(i64::from(u32::MAX) + 401)); + doc.set(mask, Object::Stream(hostile.clone())); + let photo = image(&mut doc, 400, 400, 3, false, Some(mask)); + let before = stream(&doc, photo); + page(&mut doc, &[("Photo", photo)], "q 72 0 0 72 10 10 cm /Photo Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, photo), before); + assert_eq!(stream(&doc, mask), hostile); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index e1fdce650..6dcb6658b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -145,7 +145,7 @@ total matches the parity estimate. | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | -| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). |