mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
M11: reject hostile optimizer image dimensions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -230,15 +230,19 @@ fn is_dct(s: &Stream) -> Option<bool> {
|
||||
|
||||
/// 8-bit samples of an image (`n` components), decoded.
|
||||
pub(crate) fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
|
||||
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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.<br>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.<br>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).<br>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.<br>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<br>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.<br>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<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). |
|
||||
|
||||
Reference in New Issue
Block a user