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");
+ }
}