mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 05:28:09 -06:00
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 <codex@openai.com>
This commit is contained in:
co-authored by
OpenAI Codex
parent
4e79bbdbdf
commit
a9a7eea2cc
+3
-1
@@ -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:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>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:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- 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.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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%).
|
||||
|
||||
@@ -223,19 +223,29 @@ impl Stream {
|
||||
pub fn decoded_within(&self, max: usize) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user