From a9a7eea2cc8e37ff2a0458a9f47c5a5417573b11 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Fri, 9 Oct 2026 15:13:46 +0200 Subject: [PATCH] M1: enforce decoded limits on unfiltered COS streams (#477) Check the effective output limit before cloning a stream with an empty decoder chain. Share the raw guard between tolerant and strict decoding, including empty filter arrays and already-applied Crypt filters. Add small regressions for exceeded, exact and zero-byte limits and keep filtered decoding behavior unchanged. Co-authored-by: OpenAI Codex --- ROADMAP.md | 4 ++- crates/cos/src/object.rs | 67 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e61995ad1b..6793596238 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,7 +90,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M | Milestone | Est. hours | Done | Remaining (h) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro | | M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. | | M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web | @@ -125,6 +125,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log +- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%). + - **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. - **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. - **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 983b53736f..808fdef160 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -223,19 +223,29 @@ impl Stream { pub fn decoded_within(&self, max: usize) -> Result, CosError> { let chain = self.filters(); if chain.is_empty() { - return Ok(self.raw.as_ref().clone()); + return self.raw_within(max); } pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string())) } - /// Decoded data; any corruption is an error. + /// 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 Ok(self.raw.as_ref().clone()); + return self.raw_within(MAX_DECODED); } pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) } + + fn raw_within(&self, max: usize) -> Result, CosError> { + 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())); + } + Ok(self.raw.as_ref().clone()) + } } #[derive(Clone, Debug, PartialEq)] @@ -391,4 +401,55 @@ mod tests { assert_eq!(keys, vec![b"B".to_vec(), b"A".to_vec()]); assert_eq!(d.int(b"B"), Some(3)); } + + #[test] + fn raw_streams_refuse_decoding_past_a_caller_limit() { + let stream = Stream::from_raw(Dict::new(), b"abc".to_vec()); + for max in [0, 2] { + assert!(matches!(stream.decoded_within(max), Err(CosError::Filter(_)))); + } + } + + #[test] + fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(Vec::new())); + let stream = Stream::from_raw(dict, b"abc".to_vec()); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + } + + #[test] + fn crypt_only_streams_refuse_decoding_past_a_caller_limit() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("Crypt")); + let mut parms = Dict::new(); + parms.set(b"Name".to_vec(), Object::name("Identity")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let stream = Stream::from_raw(dict, b"abc".to_vec()); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + } + + #[test] + fn identity_streams_preserve_bytes_within_a_caller_limit() { + let mut array = Dict::new(); + array.set(b"Filter".to_vec(), Object::Array(Vec::new())); + let mut crypt = Dict::new(); + crypt.set(b"Filter".to_vec(), Object::name("Crypt")); + for dict in [Dict::new(), array, crypt] { + let stream = Stream::from_raw(dict.clone(), b"abc".to_vec()); + assert_eq!(stream.decoded_within(3).unwrap(), b"abc"); + assert_eq!(stream.decoded_within(usize::MAX).unwrap(), b"abc"); + assert_eq!(stream.decoded().unwrap(), b"abc"); + assert_eq!(stream.decoded_strict().unwrap(), b"abc"); + assert!(Stream::from_raw(dict, Vec::new()).decoded_within(0).unwrap().is_empty()); + } + } + + #[test] + fn filtered_streams_preserve_the_caller_limit() { + let stream = Stream::flate(Dict::new(), b"abc"); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + assert_eq!(stream.decoded_within(3).unwrap(), b"abc"); + assert_eq!(stream.decoded_strict().unwrap(), b"abc"); + } }