diff --git a/.github/release-notes/v0.4.0.md b/.github/release-notes/v0.4.0.md new file mode 100644 index 0000000..0ee3d96 --- /dev/null +++ b/.github/release-notes/v0.4.0.md @@ -0,0 +1,25 @@ +OffPDF v0.4.0 completes the direct-editing foundation while keeping unsupported PDF structures fail-closed. + +Highlights: + +- Source-content inspection uses one bounded input snapshot with deterministic locators. +- Oversized, malformed, compressed, or ambiguous content is rejected explicitly. +- Font encodings and unsafe PDF text-state operators are checked before a source edit can be considered. +- Desktop job identifiers are validated before filesystem use. +- Operating-system file intake keeps the original file order. +- CI requires the same PDF engines exercised by production paths. + +This release does not claim Word-like editing for arbitrary PDFs. Existing source text and image replacement remains limited to separately proven structures; overlays continue to be treated as new content. + +Known issue: + +- Some real-device HEIC/HEIF files may still crash on Windows; investigation continues in issue #14. + +Thanks to @Aaqibhafeezkhan for contributing to this release. + +Packages: + +- Signed and notarized DMG for Apple Silicon Macs +- Windows x64 NSIS installer — unsigned while Authenticode signing is pending. Verify the accompanying SHA-256 file before use. + +See [CHANGELOG.md](https://github.com/McanKul/offpdf/blob/v0.4.0/CHANGELOG.md) for full details. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e8949..14d2486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,9 +63,16 @@ jobs: librsvg2-dev \ libwebkit2gtk-4.1-dev \ patchelf \ + poppler-utils \ qpdf \ wget + - name: Show PDF engine versions + run: | + qpdf --version | head -n 1 + pdftotext -v 2>&1 | head -n 1 + pdftoppm -v 2>&1 | head -n 1 + - name: Use stable Rust run: rustup default stable @@ -79,4 +86,18 @@ jobs: run: cargo check --manifest-path src-tauri/Cargo.toml - name: Test Rust backend - run: cargo test --manifest-path src-tauri/Cargo.toml --lib + shell: bash + env: + # Edit text tests fail instead of skipping when qpdf or Poppler is missing; + # the grep below also catches the older tests' `skip:` lines. + OFFPDF_REQUIRE_ENGINES: "1" + run: | + set -o pipefail + cargo test --manifest-path src-tauri/Cargo.toml --lib -- --nocapture 2>&1 | tee rust-test.log + skips=$(grep -c 'skip:' rust-test.log || true) + echo "skip lines: $skips" + if [ "$skips" -ne 0 ]; then + grep 'skip:' rust-test.log + echo "::error::Rust tests skipped because a PDF engine was missing" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf5add..bff5edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable project changes should be documented here. ## Unreleased +## 0.4.0 - 2026-10-05 + +### Added + +- Completed the v0.4 direct-editing foundation with a documented compatibility matrix and an explicit boundary between safe overlays and narrowly proven source-content edits. +- Added deterministic, read-only source locators for supported whole-string text and uniquely referenced image structures without exposing a general save capability. + +### Fixed + +- Source-content inspection now reads from one bounded snapshot and fails closed on oversized, malformed, compressed, or ambiguous PDF structures. +- Cross-reference tables, indirect objects, decoded content streams, font encodings, and unsafe text-state operators are bounded or rejected with explicit reasons. +- Desktop job identifiers are validated before they can reach temporary filesystem paths. +- Files opened through the operating system retain their original intake order. + +### CI + +- Rust CI now installs and requires the PDF engines used by production paths instead of silently skipping their tests. + +### Contributors + +- Thank you to @Aaqibhafeezkhan for contributing to this release. + +### Known issues + +- Some real-device HEIC/HEIF files may still crash on Windows; investigation continues in issue #14. + ## 0.3.2 - 2026-09-11 ### Added diff --git a/README.md b/README.md index 63ea5cf..fcbf920 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,9 @@ be filled when several files are combined; and Edit PDF does not rewrite text or images already embedded in a source page. New text and images are added as overlays instead. +The investigation behind that boundary is recorded in the +[source-content editing decision](./docs/source-editing-decision.md). + ## Downloads | Platform | Package | Status | diff --git a/docs/source-editing-decision.md b/docs/source-editing-decision.md new file mode 100644 index 0000000..96bb943 --- /dev/null +++ b/docs/source-editing-decision.md @@ -0,0 +1,112 @@ +# Existing source-content editing decision + +Status: concluded on 2026-10-04 for #11 and #33. + +## Decision + +OffPDF should not present existing PDF text or images as generally editable. +The read-only classifier is useful as an evidence gate, but a `supported` result +does not authorize a Save action. It is not connected to a Tauri command, React, +or a mutation path. + +The only candidates demonstrated by the committed corpus are: + +| Fixture | Candidate operation | Boundary | +| --- | --- | --- | +| `text-tj` | Replace the complete `Tj` string | Same simple Helvetica font; no substring edit or reflow | +| `text-tj-kerned` | Replace the complete `TJ` array | Preserve explicit positioning; no paragraph reflow | +| `image-unique` | Replace one Image XObject stream | The object must not be shared, nested, inline, or masked | + +These remain research candidates. They need a separate mutation prototype and a +publish gate that proves the source operator or image object was replaced. The +existing overlay validation gate cannot prove that: a white rectangle plus new +text can leave the old searchable content in the PDF. + +## Compatibility matrix + +| Structure | Classifier result | Reason or note | +| --- | --- | --- | +| Simple `Tj` text | Supported candidate | Whole operand only; demonstrated with Helvetica | +| Kerned `TJ` text | Supported candidate | Whole array only; spacing is included in bounds | +| CID text with ToUnicode | Unsupported | `AMBIGUOUS_UNICODE`; decoding does not prove that replacement text can be encoded | +| CID text without ToUnicode | Unsupported | `NO_TOUNICODE` | +| Subset or custom-encoded simple font | Unsupported | `SUBSET_FONT` or `CUSTOM_ENCODING` | +| Type 3 or vertical text | Unsupported | `TYPE3` or `VERTICAL` | +| Raised text or non-default rendering mode | Unsupported | `TEXT_RISE` or `TEXT_RENDER_MODE` | +| Rotated, skewed, clipped, patterned, or nested text | Unsupported | Explicit geometry/content reason | +| Unique Image XObject | Supported candidate | One independently owned image object | +| Reused, nested, inline, or masked image | Unsupported | `SHARED_XOBJECT`, `NESTED_FORM`, `INLINE_IMAGE`, or `MASKED_IMAGE` | +| Rotated page, offset crop origin, or non-default UserUnit | Unsupported when paint is present | `GEOMETRY` | +| Encrypted, signed, stale, oversized, or malformed source | Rejected | Recoverable file/content error; no silent fallback | + +The corpus contains 18 synthetic, license-safe PDFs (14,624 bytes total). It covers +the structures above but is not representative of every Office, browser, +scanner, or CAD producer. Passing it is evidence for the bounded subset, not a +claim that arbitrary PDFs are editable. + +## Bounds and failure behavior + +The classifier reads and hashes one bounded source snapshot. Raw cross-reference +data, object values, object streams, decoded content, operands, operations, +recursive containers, graphics-state depth, Form recursion, and occurrence count +are capped before their respective parser or traversal can grow without bound. +Unsupported filters and unprovable inline-image boundaries fail closed. Malformed +input returns an application error instead of falling back to compressed bytes +or a visual overlay. + +The implementation was built in small reviews: #97 and #117–#123. The fixture +corpus is #32; output validation is #34. + +## Performance and packaging evidence + +Measurement environment: macOS arm64 (Darwin 25.4.0), Rust 1.96.0, one test +thread, debug test harness, 2026-10-04. + +| Measurement | Result | +| --- | --- | +| Classifier and preflight tests | 68 passed | +| Test-reported execution time | 0.13 s | +| Wall time measured with `/usr/bin/time -l` | 0.14 s | +| Peak resident set size | 21,381,120 bytes (about 20.4 MiB) | +| Full Rust library regression suite | 325 passed | +| New native sidecar payload | 0 bytes | +| Fixture bytes included in Tauri resources | 0 bytes | + +This is a regression-harness measurement over a small synthetic corpus, not a +production throughput or worst-case memory benchmark. The exact signed-installer +delta was not isolated: lopdf 0.34 was already a direct OffPDF dependency before +the classifier, and the fixture corpus is not bundled. No new native runtime or +sidecar was added. A release-size comparison should be repeated only if this +currently unexposed classifier is wired into the product. + +The full Rust suite passes locally on macOS and in the Ubuntu GitHub workflow. +The classifier has not received a dedicated Windows execution run. Because the +current path is Rust code linked into the existing application, it adds no +separate Windows or macOS signing target. + +## Engine choice + +| Concern | Bounded lopdf prototype | PDFium sidecar | +| --- | --- | --- | +| Current implementation | Complete for the committed classifier corpus | Not implemented | +| Native code boundary | None added | Native library and process boundary required | +| Crash isolation | Runs in-process; bounded safe-Rust preflight reduces parser exposure | Must run out of process before accepting untrusted PDFs | +| Package and signing work | No new native artifact | Per-platform binary packaging and signing; not measured | +| Evidence gained for this spike | Deterministic reasons and locators across the corpus | No additional evidence yet | + +Stay on lopdf 0.34 for the read-only classifier. A PDFium prototype is not +justified by the current corpus and was deliberately not built. If a future +fixture cannot be classified safely with lopdf and materially blocks a supported +subset, evaluate PDFium in a separate issue and require an out-of-process +protocol before loading untrusted documents. + +## Product boundary + +- Keep overlay text and images described as newly added content, never as a + replacement for existing source content. +- Use secure redaction when the goal is to remove existing content. +- Show a specific unsupported reason; do not flatten, cover, or silently fall + back. +- Do not expose the classifier as an editing permission until a narrow mutation + issue proves replacement, output validation, stale-source handling, and + sibling-destination publishing for its exact subset. diff --git a/package-lock.json b/package-lock.json index 26e4eb3..e0d31b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "offpdf", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "offpdf", - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2.1.1", diff --git a/package.json b/package.json index 0049c24..316e619 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "offpdf", "private": true, - "version": "0.3.2", + "version": "0.4.0", "type": "module", "description": "Free, open-source, offline-first desktop PDF utility.", "license": "MIT", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 95f89a8..d357649 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2846,7 +2846,7 @@ dependencies = [ [[package]] name = "offpdf" -version = "0.3.2" +version = "0.4.0" dependencies = [ "flate2", "fs2", @@ -2855,6 +2855,7 @@ dependencies = [ "lopdf", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 027e909..72fe163 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "offpdf" -version = "0.3.2" +version = "0.4.0" description = "Free, open-source, offline-first desktop PDF utility." authors = ["OffPDF contributors"] edition = "2021" @@ -27,6 +27,8 @@ fs2 = "0.4" # Read/write PDF page boxes (used for cropping). lopdf = "0.34" +flate2 = "1" +sha2 = "0.10" # Glyph metrics for the bundled editor font (Noto Sans). ttf-parser = "0.25" @@ -44,9 +46,6 @@ image = { version = "0.25", default-features = false, features = [ # app so users do not need to install a system codec or upload their photos. heif-rs = { path = "vendor/heif-rs" } -[dev-dependencies] -flate2 = "1" - [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands/jobs.rs b/src-tauri/src/commands/jobs.rs index c0656ad..1213420 100644 --- a/src-tauri/src/commands/jobs.rs +++ b/src-tauri/src/commands/jobs.rs @@ -3,6 +3,28 @@ use crate::error::AppError; use crate::models::JobRegistry; +/// Longest job id the frontend sends (`crypto.randomUUID()` is 36 characters). +const JOB_ID_MAX: usize = 128; + +/// The webview's job id names a work folder (`/work/`) that the job deletes when it +/// ends: only `[A-Za-z0-9_-]{1,128}` is accepted, so it can never step out of the temp root. +pub(crate) fn check_job_id(job_id: &str) -> Result<(), AppError> { + let ok = !job_id.is_empty() + && job_id.len() <= JOB_ID_MAX + && job_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + if ok { + return Ok(()); + } + Err(AppError::new( + "INVALID_JOB", + "Invalid job", + "OffPDF received a job id it does not accept.", + ) + .with_details(format!("job id of {} bytes", job_id.len()))) +} + /// Cancel a running job by id. Killing the child (if any) is handled by the /// `JobHandle`; the worker thread reaps it and returns `AppError::cancelled`. #[tauri::command] @@ -15,3 +37,37 @@ pub async fn cancel_job( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::check_job_id; + + /// review-T5: a job id reaches `remove_dir_all(/work/)`. + #[test] + fn job_ids_cannot_leave_the_work_folder() { + for ok in [ + "3f2b8c1e-0d4a-4c6e-9a51-7b2f0e9d1c34", + "job-1700000000000-1a2b", + "a_b", + ] { + assert!(check_job_id(ok).is_ok(), "{ok}"); + } + let long = "a".repeat(129); + for bad in [ + "", + "..", + "../x", + "a/b", + "a\\b", + "C:x", + "a b", + long.as_str(), + "x\0", + ] { + let e = check_job_id(bad) + .err() + .unwrap_or_else(|| panic!("{bad:?} accepted")); + assert_eq!(e.code, "INVALID_JOB"); + } + } +} diff --git a/src-tauri/src/commands/pdf.rs b/src-tauri/src/commands/pdf.rs index bc1bc16..0c13757 100644 --- a/src-tauri/src/commands/pdf.rs +++ b/src-tauri/src/commands/pdf.rs @@ -43,6 +43,7 @@ pub async fn merge_pdfs( input_paths: Vec, output_path: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -65,6 +66,7 @@ pub async fn split_pdf( picks: Vec, mode: SplitMode, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -86,6 +88,7 @@ pub async fn assemble_pdf( output_path: String, groups: Vec, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -108,6 +111,7 @@ pub async fn edit_pdf( groups: Vec, rotations: Vec, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -130,6 +134,7 @@ pub async fn unlock_pdf( output_path: String, password: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -153,6 +158,7 @@ pub async fn watermark_pdf( text: String, opacity: f64, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -187,6 +193,7 @@ pub async fn crop_pdf( right: f64, bottom: f64, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -223,6 +230,7 @@ pub async fn edit_pdf_overlays( flatten_form: Option, flatten_annotations: Option, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -278,6 +286,7 @@ pub async fn stamp_pdf( color: [f64; 3], size_pct: f64, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -316,6 +325,7 @@ pub async fn poster_pdf( overlap: f64, marks: bool, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -352,6 +362,7 @@ pub async fn nup_pdf( sheet_w: f64, sheet_h: f64, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -388,6 +399,7 @@ pub async fn add_page_numbers( pad_width: Option, with_date: Option, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -430,6 +442,7 @@ pub async fn protect_pdf( user_password: String, owner_password: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -460,6 +473,7 @@ pub async fn extract_pages( output_path: String, pages: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -482,6 +496,7 @@ pub async fn delete_pages( output_path: String, pages: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -505,6 +520,7 @@ pub async fn rotate_pages( angle: i32, rotate_pages: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -535,6 +551,7 @@ pub async fn reorder_pages( output_path: String, order: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -559,6 +576,7 @@ pub async fn compress_pdf( quality: u32, target_bytes: Option, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -589,6 +607,7 @@ pub async fn optimize_pdf( output_path: String, groups: Vec, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); diff --git a/src-tauri/src/commands/render.rs b/src-tauri/src/commands/render.rs index ab5273a..2bfd15d 100644 --- a/src-tauri/src/commands/render.rs +++ b/src-tauri/src/commands/render.rs @@ -82,6 +82,7 @@ pub async fn pdf_to_images( format: String, dpi: u32, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -211,6 +212,7 @@ pub async fn ocr_pdf( picks: Vec, lang: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -258,6 +260,7 @@ pub async fn office_to_pdf_batch( output_dir: String, input_paths: Vec, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -335,6 +338,7 @@ pub async fn pdfa_pdf( output_path: String, groups: Vec, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -394,6 +398,7 @@ pub async fn detect_blank_pages( input_path: String, sensitivity: String, ) -> Result, AppError> { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let jid = job_id.clone(); let res = tauri::async_runtime::spawn_blocking(move || { @@ -431,6 +436,7 @@ pub async fn write_pdf_meta( fields: crate::pdf_engine::metadata::PdfMeta, clear_all: bool, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -468,6 +474,7 @@ pub async fn export_pdf_text( first_page: Option, last_page: Option, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); @@ -504,6 +511,7 @@ pub async fn pdf_to_office( groups: Vec, format: String, ) -> Result { + super::jobs::check_job_id(&job_id)?; let handle = registry.register(&job_id); let app2 = app.clone(); let jid = job_id.clone(); diff --git a/src-tauri/src/os_open.rs b/src-tauri/src/os_open.rs index cc32954..134d529 100644 --- a/src-tauri/src/os_open.rs +++ b/src-tauri/src/os_open.rs @@ -17,6 +17,27 @@ pub struct OpenedPathQueue { frontend_ready: bool, } +impl OpenedPathQueue { + pub(crate) fn enqueue(&mut self, paths: Vec) -> Option> { + let mut paths = dedupe_paths(paths); + if paths.is_empty() { + return None; + } + if self.frontend_ready { + return Some(paths); + } + + paths.retain(|path| !self.pending.contains(path)); + self.pending.extend(paths); + None + } + + pub(crate) fn take_pending(&mut self) -> Vec { + self.frontend_ready = true; + std::mem::take(&mut self.pending) + } +} + /// Skip argv[0], then map the rest through [`parse_opened_token`]. pub fn parse_opened_argv(args: impl IntoIterator>) -> Vec { let mut iter = args.into_iter(); @@ -69,11 +90,9 @@ pub fn enqueue_opened_paths(app: &AppHandle, paths: Vec) { Ok(g) => g, Err(_) => return, }; - if guard.frontend_ready { + if let Some(live_paths) = guard.enqueue(strings) { drop(guard); - let _ = app.emit(OPENED_PATHS_EVENT, strings); - } else { - guard.pending.extend(strings); + let _ = app.emit(OPENED_PATHS_EVENT, live_paths); } } @@ -93,8 +112,17 @@ pub fn take_opened_paths(queue: State<'_, Mutex>) -> Vec g, Err(poisoned) => poisoned.into_inner(), }; - guard.frontend_ready = true; - std::mem::take(&mut guard.pending) + guard.take_pending() +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut unique = Vec::with_capacity(paths.len()); + for path in paths { + if !unique.contains(&path) { + unique.push(path); + } + } + unique } fn strip_file_scheme(token: &str) -> Option<&str> { diff --git a/src-tauri/src/os_open_tests.rs b/src-tauri/src/os_open_tests.rs index aa10b75..33e2c90 100644 --- a/src-tauri/src/os_open_tests.rs +++ b/src-tauri/src/os_open_tests.rs @@ -116,3 +116,41 @@ fn os_open_parse_utf8_cjk_argv() { "R-UTF8: argv CJK paths must survive parse_opened_argv", ); } + +#[test] +fn os_open_queue_preserves_order_and_deduplicates_pending_paths() { + let mut queue = crate::os_open::OpenedPathQueue::default(); + + assert_eq!( + queue.enqueue(vec![ + "/tmp/first file.pdf".into(), + "/tmp/second.pdf".into(), + "/tmp/first file.pdf".into(), + ]), + None, + ); + assert_eq!( + queue.enqueue(vec!["/tmp/second.pdf".into(), "/tmp/第三.pdf".into()]), + None, + ); + assert_eq!( + queue.take_pending(), + vec![ + "/tmp/first file.pdf", + "/tmp/second.pdf", + "/tmp/第三.pdf", + ], + ); +} + +#[test] +fn os_open_queue_switches_atomically_to_live_events() { + let mut queue = crate::os_open::OpenedPathQueue::default(); + queue.enqueue(vec!["/tmp/cold.pdf".into()]); + + assert_eq!(queue.take_pending(), vec!["/tmp/cold.pdf"]); + assert_eq!( + queue.enqueue(vec!["/tmp/live.pdf".into()]), + Some(vec!["/tmp/live.pdf".to_string()]), + ); +} diff --git a/src-tauri/src/pdf_engine/mod.rs b/src-tauri/src/pdf_engine/mod.rs index 3fc5b4f..5644f12 100644 --- a/src-tauri/src/pdf_engine/mod.rs +++ b/src-tauri/src/pdf_engine/mod.rs @@ -37,6 +37,8 @@ pub mod poster; pub mod qpdf; pub mod render; pub mod source_content; +mod source_content_decode; +mod source_content_preflight; #[cfg(test)] mod source_edit_fixtures; #[cfg(test)] diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index 34c7da7..662a96b 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -3,15 +3,21 @@ //! Walks page streams and Form XObjects on the original source path. Does not //! write a dest, call `Document::replace_text`, or apply page `/Rotate`. //! -//! Research prototype only; no Tauri command or UI invokes this module. -//! Decoded-size checks currently run after allocation, and font/geometry -//! support is incomplete. Complete #33's resource bounds and compatibility -//! evaluation before exposing this API to user files or enabling editing. +//! Research prototype only; no Tauri command or UI invokes this module. The +//! source is read once through a hard cap, then both fingerprinted and parsed +//! from that same snapshot. Object values, object streams, and content stream +//! decoding and content parsing are bounded. Font/geometry support is still +//! incomplete. Complete #33's remaining compatibility evaluation before exposing +//! this API to user files or enabling editing. use crate::error::AppError; use crate::pdf_engine::crop; +use crate::pdf_engine::source_content_decode::{self, DecodeError}; +use crate::pdf_engine::source_content_preflight; use lopdf::{content::Content, Dictionary, Document, Object, ObjectId, Stream}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; +use std::io::Read; use std::path::Path; const FILE_CAP_BYTES: u64 = 400 * 1024 * 1024; @@ -23,8 +29,18 @@ const MAX_OCCURRENCES: usize = 5_000; const MAX_GSTATE_STACK: usize = 64; const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; -const FNV_OFFSET: u64 = 0xcbf29ce484222325; -const FNV_PRIME: u64 = 0x100000001b3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SourceFingerprint([u8; 32]); + +impl std::fmt::Display for SourceFingerprint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceKind { @@ -65,38 +81,41 @@ pub fn resolve_source_locator( path: &Path, locator: &str, ) -> Result { - if !path.is_file() { - return Err(AppError::invalid_pdf(&path_str(path))); - } - let meta = std::fs::metadata(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; - if meta.len() > FILE_CAP_BYTES { - return Err(file_too_large()); - } - let bytes = std::fs::read(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; - let fp = fnv1a_u64(&bytes); + let (doc, fp) = open_source(path)?; let loc_fp = parse_locator_fp(locator).ok_or_else(stale)?; if loc_fp != fp { return Err(stale()); } - classify_source_content(path)? + classify_doc(&doc, fp)? .into_iter() .find(|o| o.locator == locator) .ok_or_else(stale) } -fn open_source(path: &Path) -> Result<(Document, u64), AppError> { - if !path.is_file() { +fn open_source(path: &Path) -> Result<(Document, SourceFingerprint), AppError> { + let file = std::fs::File::open(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; + let meta = file + .metadata() + .map_err(|_| AppError::invalid_pdf(&path_str(path)))?; + if !meta.is_file() { return Err(AppError::invalid_pdf(&path_str(path))); } - let meta = std::fs::metadata(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; if meta.len() > FILE_CAP_BYTES { return Err(file_too_large()); } - let bytes = std::fs::read(path).map_err(|_| AppError::invalid_pdf(&path_str(path)))?; - let fp = fnv1a_u64(&bytes); - let doc = Document::load(path).map_err(|e| { - AppError::invalid_pdf(&path_str(path)).with_details(format!("lopdf: {e}")) - })?; + let mut bytes = Vec::new(); + let reserve = usize::try_from(meta.len()).map_err(|_| file_too_large())?; + bytes + .try_reserve_exact(reserve) + .map_err(|_| file_too_large())?; + file.take(FILE_CAP_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| AppError::invalid_pdf(&path_str(path)))?; + if bytes.len() as u64 > FILE_CAP_BYTES { + return Err(file_too_large()); + } + let fp = SourceFingerprint(Sha256::digest(&bytes).into()); + let doc = source_content_preflight::load_document(&bytes, &path_str(path))?; if doc.is_encrypted() { return Err(encrypted()); } @@ -109,7 +128,7 @@ fn open_source(path: &Path) -> Result<(Document, u64), AppError> { Ok((doc, fp)) } -fn classify_doc(doc: &Document, fp: u64) -> Result, AppError> { +fn classify_doc(doc: &Document, fp: SourceFingerprint) -> Result, AppError> { let mut walker = Walker { doc, fp, @@ -132,7 +151,7 @@ fn classify_doc(doc: &Document, fp: u64) -> Result, AppErr struct Walker<'a> { doc: &'a Document, - fp: u64, + fp: SourceFingerprint, decoded_total: usize, op_count: usize, pending: Vec, @@ -152,6 +171,10 @@ struct Flags { no_tounicode: bool, missing_font: bool, ambiguous: bool, + subset_font: bool, + custom_encoding: bool, + text_rise: bool, + text_render_mode: bool, masked: bool, shared: bool, geometry: bool, @@ -180,6 +203,8 @@ struct GState { hscale: f64, tc: f64, tw: f64, + text_rise: f64, + text_render_mode: u8, } impl Default for GState { @@ -197,6 +222,8 @@ impl Default for GState { hscale: 100.0, tc: 0.0, tw: 0.0, + text_rise: 0.0, + text_render_mode: 0, } } } @@ -214,6 +241,7 @@ impl GState { struct TextState { tm: [f64; 6], tlm: [f64; 6], + clip_pending: bool, } impl Default for TextState { @@ -221,6 +249,7 @@ impl Default for TextState { Self { tm: IDENTITY, tlm: IDENTITY, + clip_pending: false, } } } @@ -237,6 +266,8 @@ struct TextInspect { vertical: bool, no_tounicode: bool, ambiguous: bool, + subset_font: bool, + custom_encoding: bool, width: f64, font_id: ObjectId, } @@ -289,15 +320,9 @@ impl Walker<'_> { visiting: &mut Vec, geom_unsafe: bool, ) -> Result<(), AppError> { - let inline_total = count_inline_images(bytes)?; - // BI…EI: do not trust a prefix Content::decode Ok; strip payloads first. - let stripped = if inline_total > 0 { - Some(strip_inline_image_payloads(bytes)?) - } else { - None - }; - let decode_src = stripped.as_deref().unwrap_or(bytes); - let ops = match Content::decode(decode_src) { + let prepared = source_content_preflight::prepare_content(bytes)?; + let inline_total = prepared.inline_images; + let ops = match Content::decode(prepared.bytes()) { Ok(c) => c.operations, Err(_) => { return Err(malformed_content( @@ -315,9 +340,12 @@ impl Walker<'_> { for (op_index, op) in ops.iter().enumerate() { match op.operator.as_str() { "q" => { - if stack.len() < MAX_GSTATE_STACK { - stack.push(gs.clone()); + if stack.len() >= MAX_GSTATE_STACK { + return Err(malformed_content( + "Graphics state nesting is deeper than 64 levels.", + )); } + stack.push(gs.clone()); } "Q" => { if let Some(prev) = stack.pop() { @@ -376,8 +404,14 @@ impl Walker<'_> { "BT" => { ts.tm = IDENTITY; ts.tlm = IDENTITY; + ts.clip_pending = false; + } + "ET" => { + if ts.clip_pending { + gs.clip_active = true; + ts.clip_pending = false; + } } - "ET" => {} "Tf" => { if let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) { gs.font_name = Some(name.to_vec()); @@ -401,6 +435,28 @@ impl Walker<'_> { gs.hscale = z; } } + "Ts" => { + gs.text_rise = match op.operands.as_slice() { + [value] => obj_f64(value).ok_or_else(|| { + malformed_content("A text-rise operator has an invalid operand.") + })?, + _ => { + return Err(malformed_content( + "A text-rise operator must have one operand.", + )) + } + }; + } + "Tr" => { + gs.text_render_mode = match op.operands.as_slice() { + [Object::Integer(mode)] if (0..=7).contains(mode) => *mode as u8, + _ => { + return Err(malformed_content( + "A text-rendering-mode operator must contain an integer from 0 to 7.", + )) + } + }; + } "TL" => { if let Some(l) = op.operands.first().and_then(obj_f64) { gs.leading = l; @@ -460,6 +516,9 @@ impl Walker<'_> { nested, geom_unsafe, )?; + if gs.text_render_mode >= 4 && pieces.iter().any(|piece| !piece.is_empty()) { + ts.clip_pending = true; + } } "Do" => { let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) else { @@ -591,6 +650,7 @@ impl Walker<'_> { let shown = inspect.width * th; let width = (shown * sx).abs().max(0.01); let tx = (inspect.width + spacing_advance(pieces, gs.tc, gs.tw)) * th; + let origin = apply_point(effective, 0.0, gs.text_rise); let flags = Flags { nested_form: nested, type3: inspect.type3, @@ -602,6 +662,10 @@ impl Walker<'_> { no_tounicode: inspect.no_tounicode, missing_font: inspect.missing_font, ambiguous: inspect.ambiguous, + subset_font: inspect.subset_font, + custom_encoding: inspect.custom_encoding, + text_rise: gs.text_rise != 0.0, + text_render_mode: gs.text_render_mode != 0, masked: gs.masked, geometry: geom_unsafe, ..Flags::default() @@ -610,8 +674,8 @@ impl Walker<'_> { page_index, kind: SourceKind::Text, rect: SourceRect { - x: effective[4], - y: effective[5], + x: origin.0, + y: origin.1, w: width, h: height, }, @@ -782,6 +846,14 @@ fn pick_reason(flags: &Flags) -> (SourceCapability, Option) { Some("NO_TOUNICODE") } else if flags.ambiguous { Some("AMBIGUOUS_UNICODE") + } else if flags.subset_font { + Some("SUBSET_FONT") + } else if flags.custom_encoding { + Some("CUSTOM_ENCODING") + } else if flags.text_rise { + Some("TEXT_RISE") + } else if flags.text_render_mode { + Some("TEXT_RENDER_MODE") } else if flags.masked { Some("MASKED_IMAGE") } else if flags.shared { @@ -965,16 +1037,15 @@ fn matrix_from_dict(dict: &Dictionary) -> [f64; 6] { } fn decompress_stream(stream: &Stream) -> Result, AppError> { - let data = match stream.decompressed_content() { - Ok(d) => d, - Err(_) => stream.content.clone(), - }; - if data.len() > MAX_STREAM_BYTES { - return Err(malformed_content( - "A content stream is larger than 32 MB decompressed.", - )); - } - Ok(data) + source_content_decode::decode_stream(stream, MAX_STREAM_BYTES).map_err(|error| match error { + DecodeError::UnsupportedFilter(name) => unsupported_filter(&name), + DecodeError::Corrupt => { + malformed_content("A filtered content stream could not be decompressed.") + } + DecodeError::TooLarge => { + malformed_content("A content stream is larger than 32 MB decompressed.") + } + }) } fn inspect_text( @@ -995,10 +1066,14 @@ fn inspect_text( let mut vertical = false; let mut no_tounicode = false; let mut ambiguous = false; + let mut subset_font = false; + let mut custom_encoding = false; let mut width_sum = 0.0; if let Some(font) = dict { type3 = is_type3(font); vertical = is_vertical(doc, font); + subset_font = is_subset_font(doc, font); + custom_encoding = !is_cid_or_type0(font) && has_custom_encoding(doc, font); if is_cid_or_type0(font) { if tounicode_usable(doc, font) { ambiguous = true; @@ -1021,6 +1096,8 @@ fn inspect_text( vertical, no_tounicode, ambiguous, + subset_font, + custom_encoding, width: (width_sum + tj_adj) / 1000.0 * size, font_id, } @@ -1031,6 +1108,32 @@ fn is_type3(font: &Dictionary) -> bool { || font.get(b"CharProcs").is_ok() } +fn is_subset_font(doc: &Document, font: &Dictionary) -> bool { + let Ok(Object::Name(name)) = font.get_deref(b"BaseFont", doc) else { + return false; + }; + name.len() > 7 + && name.get(6) == Some(&b'+') + && name[..6].iter().all(u8::is_ascii_uppercase) +} + +fn has_custom_encoding(doc: &Document, font: &Dictionary) -> bool { + if font.get(b"Encoding").is_err() { + return false; + } + let Ok(encoding) = font.get_deref(b"Encoding", doc) else { + return true; + }; + !matches!( + encoding, + Object::Name(name) + if matches!( + name.as_slice(), + b"StandardEncoding" | b"WinAnsiEncoding" | b"MacRomanEncoding" + ) + ) +} + fn is_cid_or_type0(font: &Dictionary) -> bool { match font.get(b"Subtype").ok().and_then(|o| o.as_name().ok()) { Some(b"Type0") | Some(b"CIDFontType0") | Some(b"CIDFontType2") => return true, @@ -1458,171 +1561,6 @@ fn spacing_advance(pieces: &[Vec], tc: f64, tw: f64) -> f64 { extra } -fn strip_inline_image_payloads(data: &[u8]) -> Result, AppError> { - let mut out = Vec::with_capacity(data.len()); - let mut i = 0; - // Normal → Keys (after BI) → Payload (after ID). Copy BI/keys/ID/EI; - // drop only the raw bytes between ID and EI so Content::decode still - // yields BI at the CTM in force there. - let mut after_bi = false; - let mut after_id = false; - while i < data.len() { - if after_id { - if is_op_token(data, i, b"EI") { - out.extend_from_slice(b"EI"); - i += 2; - after_id = false; - after_bi = false; - } else { - i += 1; - } - continue; - } - if data[i].is_ascii_whitespace() { - out.push(data[i]); - i += 1; - continue; - } - if data[i] == b'%' { - let start = i; - while i < data.len() && data[i] != b'\n' && data[i] != b'\r' { - i += 1; - } - out.extend_from_slice(&data[start..i]); - continue; - } - if data[i] == b'(' { - let start = i; - i = skip_literal(data, i)?; - out.extend_from_slice(&data[start..i]); - continue; - } - if data[i] == b'<' && data.get(i + 1) != Some(&b'<') { - let start = i; - i = skip_hex(data, i); - out.extend_from_slice(&data[start..i]); - continue; - } - if !after_bi && is_op_token(data, i, b"BI") { - out.extend_from_slice(b"BI"); - i += 2; - after_bi = true; - continue; - } - if after_bi && is_op_token(data, i, b"ID") { - out.extend_from_slice(b"ID"); - i += 2; - out.push(b' '); - after_id = true; - continue; - } - if after_bi && is_op_token(data, i, b"EI") { - out.extend_from_slice(b"EI"); - i += 2; - after_bi = false; - continue; - } - out.push(data[i]); - i += 1; - } - if after_bi || after_id { - return Err(malformed_content("An inline image is unterminated.")); - } - Ok(out) -} - -fn count_inline_images(data: &[u8]) -> Result { - let mut count = 0; - let mut i = 0; - let mut in_inline = false; - while i < data.len() { - if data[i].is_ascii_whitespace() { - i += 1; - continue; - } - if !in_inline && data[i] == b'%' { - while i < data.len() && data[i] != b'\n' && data[i] != b'\r' { - i += 1; - } - continue; - } - if !in_inline && data[i] == b'(' { - i = skip_literal(data, i)?; - continue; - } - if !in_inline && data[i] == b'<' && data.get(i + 1) != Some(&b'<') { - i = skip_hex(data, i); - continue; - } - if !in_inline && is_op_token(data, i, b"BI") { - in_inline = true; - i += 2; - continue; - } - if in_inline && is_op_token(data, i, b"EI") { - count += 1; - in_inline = false; - i += 2; - continue; - } - i += 1; - } - if in_inline { - return Err(malformed_content("An inline image is unterminated.")); - } - Ok(count) -} - -fn is_delim(b: u8) -> bool { - b.is_ascii_whitespace() - || matches!( - b, - b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' - ) -} - -fn is_op_token(data: &[u8], i: usize, token: &[u8]) -> bool { - if i + token.len() > data.len() || &data[i..i + token.len()] != token { - return false; - } - let before = i == 0 || is_delim(data[i - 1]); - let after = i + token.len() == data.len() || is_delim(data[i + token.len()]); - before && after -} - -fn skip_literal(data: &[u8], start: usize) -> Result { - let mut i = start + 1; - let mut depth = 1; - while i < data.len() && depth > 0 { - match data[i] { - b'\\' => { - i = i.saturating_add(2).min(data.len()); - continue; - } - b'(' => depth += 1, - b')' => depth -= 1, - _ => {} - } - i += 1; - } - if depth != 0 { - return Err(malformed_content("A literal string is unterminated.")); - } - Ok(i) -} - -fn skip_hex(data: &[u8], start: usize) -> usize { - let mut i = start + 1; - while i < data.len() && data[i] != b'>' { - i += 1; - } - if i < data.len() { - i + 1 - } else { - i - } -} - fn document_is_signed(doc: &Document) -> bool { if let Ok(cat) = doc.catalog() { if cat.get(b"Perms").is_ok() { @@ -1651,17 +1589,8 @@ fn name_is(dict: &Dictionary, key: &[u8], expect: &[u8]) -> bool { dict.get(key).ok().and_then(|o| o.as_name().ok()) == Some(expect) } -fn fnv1a_u64(bytes: &[u8]) -> u64 { - let mut hash = FNV_OFFSET; - for byte in bytes { - hash ^= *byte as u64; - hash = hash.wrapping_mul(FNV_PRIME); - } - hash -} - fn encode_locator( - fp: u64, + fp: SourceFingerprint, page_index: u32, kind: SourceKind, contents_id: ObjectId, @@ -1673,15 +1602,27 @@ fn encode_locator( SourceKind::Image => 1u8, }; format!( - "v1:{fp:016x}:{page_index}:{k}:{}:{}:{op_index}:{}:{}", + "v2:{fp}:{page_index}:{k}:{}:{}:{op_index}:{}:{}", contents_id.0, contents_id.1, object_id.0, object_id.1 ) } -fn parse_locator_fp(locator: &str) -> Option { - let rest = locator.strip_prefix("v1:")?; +fn parse_locator_fp(locator: &str) -> Option { + let rest = locator.strip_prefix("v2:")?; let hex = rest.split(':').next()?; - u64::from_str_radix(hex, 16).ok() + if hex.len() != 64 + || !hex + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + return None; + } + let mut digest = [0u8; 32]; + for (slot, pair) in digest.iter_mut().zip(hex.as_bytes().chunks_exact(2)) { + let pair = std::str::from_utf8(pair).ok()?; + *slot = u8::from_str_radix(pair, 16).ok()?; + } + Some(SourceFingerprint(digest)) } fn path_str(path: &Path) -> String { @@ -1706,6 +1647,15 @@ fn malformed_content(message: impl Into) -> AppError { .with_suggestion("Open the file in a PDF editor that can repair it, or use a different PDF.") } +fn unsupported_filter(name: &str) -> AppError { + AppError::new( + "UNSUPPORTED_FILTER", + "This PDF uses an unsupported content filter", + format!("The source classifier cannot safely decode {name}."), + ) + .with_suggestion("Use a PDF editor to rewrite the file, then try again.") +} + fn encrypted() -> AppError { AppError::new( "ENCRYPTED", diff --git a/src-tauri/src/pdf_engine/source_content_decode.rs b/src-tauri/src/pdf_engine/source_content_decode.rs new file mode 100644 index 0000000..381841e --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content_decode.rs @@ -0,0 +1,334 @@ +//! Bounded decoding for the read-only source classifier. +//! +//! Filtered streams never fall back to their encoded bytes. Every supported +//! decoder checks its output limit while producing data. + +use flate2::{Decompress, FlushDecompress, Status}; +use lopdf::{Dictionary, Object, Stream}; +use std::borrow::Cow; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum DecodeError { + UnsupportedFilter(String), + Corrupt, + TooLarge, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Filter { + Flate, + AsciiHex, + Ascii85, +} + +pub(super) fn decode_stream(stream: &Stream, cap: usize) -> Result, DecodeError> { + let filters = filters_of(&stream.dict)?; + let mut data: Cow<'_, [u8]> = Cow::Borrowed(&stream.content); + for filter in filters { + let decoded = match filter { + Filter::Flate => inflate_capped(&data, cap)?, + Filter::AsciiHex => ascii_hex_decode(&data, cap)?, + Filter::Ascii85 => ascii85_decode(&data, cap)?, + }; + data = Cow::Owned(decoded); + } + if data.len() > cap { + return Err(DecodeError::TooLarge); + } + Ok(data.into_owned()) +} + +fn filters_of(dict: &Dictionary) -> Result, DecodeError> { + for key in [&b"F"[..], b"FFilter", b"FDecodeParms"] { + if dict.has(key) { + return Err(DecodeError::UnsupportedFilter( + "external file filter".into(), + )); + } + } + let names: Vec<&[u8]> = match dict.get(b"Filter").ok() { + None | Some(Object::Null) => Vec::new(), + Some(Object::Name(name)) => vec![name], + Some(Object::Array(items)) if items.len() <= 4 => items + .iter() + .map(|item| match item { + Object::Name(name) => Ok(name.as_slice()), + _ => Err(DecodeError::UnsupportedFilter("malformed /Filter".into())), + }) + .collect::>()?, + Some(Object::Array(_)) => { + return Err(DecodeError::UnsupportedFilter( + "more than four chained filters".into(), + )) + } + Some(_) => return Err(DecodeError::UnsupportedFilter("malformed /Filter".into())), + }; + check_decode_parms(dict.get(b"DecodeParms").ok())?; + names.into_iter().map(filter_from_name).collect() +} + +fn filter_from_name(name: &[u8]) -> Result { + match name { + b"FlateDecode" | b"Fl" => Ok(Filter::Flate), + b"ASCIIHexDecode" | b"AHx" => Ok(Filter::AsciiHex), + b"ASCII85Decode" | b"A85" => Ok(Filter::Ascii85), + other => Err(DecodeError::UnsupportedFilter( + String::from_utf8_lossy(other).into_owned(), + )), + } +} + +fn check_decode_parms(parms: Option<&Object>) -> Result<(), DecodeError> { + let check = |dict: &Dictionary| match dict.get(b"Predictor").ok() { + None | Some(Object::Integer(1)) => Ok(()), + Some(_) => Err(DecodeError::UnsupportedFilter("predictor".into())), + }; + match parms { + None | Some(Object::Null) => Ok(()), + Some(Object::Dictionary(dict)) => check(dict), + Some(Object::Array(items)) => items.iter().try_for_each(|item| match item { + Object::Null => Ok(()), + Object::Dictionary(dict) => check(dict), + _ => Err(DecodeError::UnsupportedFilter( + "malformed /DecodeParms".into(), + )), + }), + Some(_) => Err(DecodeError::UnsupportedFilter( + "malformed /DecodeParms".into(), + )), + } +} + +pub(super) fn inflate_capped(data: &[u8], cap: usize) -> Result, DecodeError> { + let mut decoder = Decompress::new(true); + let mut output = Vec::new(); + let mut step = vec![0u8; 64 * 1024]; + loop { + let input_before = + usize::try_from(decoder.total_in()).map_err(|_| DecodeError::TooLarge)?; + let output_before = + usize::try_from(decoder.total_out()).map_err(|_| DecodeError::TooLarge)?; + let input = data.get(input_before..).ok_or(DecodeError::Corrupt)?; + let status = decoder + .decompress(input, &mut step, FlushDecompress::None) + .map_err(|_| DecodeError::Corrupt)?; + let input_after = usize::try_from(decoder.total_in()).map_err(|_| DecodeError::TooLarge)?; + let output_after = + usize::try_from(decoder.total_out()).map_err(|_| DecodeError::TooLarge)?; + let produced = output_after + .checked_sub(output_before) + .ok_or(DecodeError::Corrupt)?; + if output.len().saturating_add(produced) > cap { + return Err(DecodeError::TooLarge); + } + output.extend_from_slice(step.get(..produced).ok_or(DecodeError::Corrupt)?); + match status { + Status::StreamEnd => return Ok(output), + Status::Ok | Status::BufError if produced == 0 && input_after == input_before => { + return Err(DecodeError::Corrupt) + } + Status::Ok | Status::BufError => {} + } + } +} + +/// Returns the encoded byte length of one complete zlib stream while bounding +/// its decoded output. Trailing bytes are intentionally left for the caller. +pub(super) fn inflate_end(data: &[u8], cap: usize) -> Result { + let mut decoder = Decompress::new(true); + let mut step = vec![0u8; 64 * 1024]; + loop { + let input_before = + usize::try_from(decoder.total_in()).map_err(|_| DecodeError::TooLarge)?; + let output_before = + usize::try_from(decoder.total_out()).map_err(|_| DecodeError::TooLarge)?; + let input = data.get(input_before..).ok_or(DecodeError::Corrupt)?; + let status = decoder + .decompress(input, &mut step, FlushDecompress::None) + .map_err(|_| DecodeError::Corrupt)?; + let input_after = usize::try_from(decoder.total_in()).map_err(|_| DecodeError::TooLarge)?; + let output_after = + usize::try_from(decoder.total_out()).map_err(|_| DecodeError::TooLarge)?; + if output_after > cap { + return Err(DecodeError::TooLarge); + } + match status { + Status::StreamEnd => return Ok(input_after), + Status::Ok | Status::BufError + if input_after == input_before && output_after == output_before => + { + return Err(DecodeError::Corrupt) + } + Status::Ok | Status::BufError => {} + } + } +} + +fn is_pdf_whitespace(byte: u8) -> bool { + matches!(byte, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +pub(super) fn ascii_hex_decode(data: &[u8], cap: usize) -> Result, DecodeError> { + let mut output = Vec::new(); + let mut high = None; + for &byte in data { + if byte == b'>' { + break; + } + if is_pdf_whitespace(byte) { + continue; + } + let value = hex_value(byte).ok_or(DecodeError::Corrupt)?; + match high.take() { + None => high = Some(value), + Some(first) => push_capped(&mut output, (first << 4) | value, cap)?, + } + } + if let Some(first) = high { + push_capped(&mut output, first << 4, cap)?; + } + Ok(output) +} + +pub(super) fn ascii85_decode(data: &[u8], cap: usize) -> Result, DecodeError> { + let body = data.strip_prefix(b"<~").unwrap_or(data); + let mut output = Vec::new(); + let mut group = [0u8; 5]; + let mut count = 0usize; + let mut bytes = body.iter().copied().peekable(); + while let Some(byte) = bytes.next() { + match byte { + byte if is_pdf_whitespace(byte) => {} + b'~' if bytes.next() == Some(b'>') => break, + b'z' if count == 0 => extend_capped(&mut output, &[0, 0, 0, 0], cap)?, + b'!'..=b'u' => { + group[count] = byte - b'!'; + count += 1; + if count == 5 { + extend_capped(&mut output, &ascii85_group(&group)?, cap)?; + count = 0; + } + } + _ => return Err(DecodeError::Corrupt), + } + } + match count { + 0 => {} + 1 => return Err(DecodeError::Corrupt), + count => { + for slot in group.iter_mut().skip(count) { + *slot = 84; + } + let decoded = ascii85_group(&group)?; + extend_capped(&mut output, &decoded[..count - 1], cap)?; + } + } + Ok(output) +} + +fn ascii85_group(group: &[u8; 5]) -> Result<[u8; 4], DecodeError> { + let value = group + .iter() + .fold(0u64, |acc, digit| acc * 85 + u64::from(*digit)); + Ok(u32::try_from(value) + .map_err(|_| DecodeError::Corrupt)? + .to_be_bytes()) +} + +fn push_capped(output: &mut Vec, byte: u8, cap: usize) -> Result<(), DecodeError> { + if output.len() >= cap { + return Err(DecodeError::TooLarge); + } + output.push(byte); + Ok(()) +} + +fn extend_capped(output: &mut Vec, bytes: &[u8], cap: usize) -> Result<(), DecodeError> { + if output.len().saturating_add(bytes.len()) > cap { + return Err(DecodeError::TooLarge); + } + output.extend_from_slice(bytes); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::io::Write; + + fn flate(data: &[u8]) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(data).unwrap(); + encoder.finish().unwrap() + } + + fn stream(filter: Object, data: Vec) -> Stream { + let mut dict = Dictionary::new(); + dict.set("Filter", filter); + Stream::new(dict, data) + } + + #[test] + fn flate_is_strict_and_capped() { + let encoded = flate(b"hello"); + let valid = stream(Object::Name(b"FlateDecode".to_vec()), encoded.clone()); + assert_eq!(decode_stream(&valid, 5).unwrap(), b"hello"); + assert_eq!(decode_stream(&valid, 4), Err(DecodeError::TooLarge)); + let mut with_trailing = encoded.clone(); + with_trailing.extend_from_slice(b" trailing"); + assert_eq!(inflate_end(&with_trailing, 5).unwrap(), encoded.len()); + assert_eq!(inflate_end(&with_trailing, 4), Err(DecodeError::TooLarge)); + assert_eq!( + inflate_end(&encoded[..encoded.len() - 1], 5), + Err(DecodeError::Corrupt) + ); + + let corrupt = stream( + Object::Name(b"FlateDecode".to_vec()), + b"not zlib data".to_vec(), + ); + assert_eq!(decode_stream(&corrupt, 1024), Err(DecodeError::Corrupt)); + + let mut truncated_data = flate(b"hello"); + truncated_data.truncate(truncated_data.len().saturating_sub(2)); + let truncated = stream(Object::Name(b"FlateDecode".to_vec()), truncated_data); + assert_eq!(decode_stream(&truncated, 1024), Err(DecodeError::Corrupt)); + } + + #[test] + fn common_ascii_filter_chain_is_bounded() { + let chained = stream( + Object::Array(vec![ + Object::Name(b"ASCIIHexDecode".to_vec()), + Object::Name(b"FlateDecode".to_vec()), + ]), + flate(b"hello") + .into_iter() + .flat_map(|byte| format!("{byte:02x}").into_bytes()) + .chain([b'>']) + .collect(), + ); + assert_eq!(decode_stream(&chained, 1024).unwrap(), b"hello"); + assert_eq!(decode_stream(&chained, 4), Err(DecodeError::TooLarge)); + } + + #[test] + fn unsupported_filters_fail_closed() { + let lzw = stream(Object::Name(b"LZWDecode".to_vec()), vec![0; 8]); + assert!(matches!( + decode_stream(&lzw, 1024), + Err(DecodeError::UnsupportedFilter(name)) if name == "LZWDecode" + )); + } +} diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index 9b32791..19ce888 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -35,6 +35,10 @@ const FILE_CAP_BYTES: u64 = 400 * 1024 * 1024; const FROZEN_REASONS: &[&str] = &[ "MISSING_FONT", + "SUBSET_FONT", + "CUSTOM_ENCODING", + "TEXT_RISE", + "TEXT_RENDER_MODE", "NO_TOUNICODE", "AMBIGUOUS_UNICODE", "TYPE3", @@ -592,6 +596,36 @@ fn classify_try_edit_is_not_auto_supported() { // --- CLASSIFY-STALE --------------------------------------------------------- +#[test] +fn classify_locators_use_full_sha256_fingerprints() { + let hits = classify(&fixture("text-tj.pdf"), "CLASSIFY-FINGERPRINT"); + let locator = &first_of_kind(&hits, "text", "CLASSIFY-FINGERPRINT").locator; + let mut parts = locator.split(':'); + assert_eq!(parts.next(), Some("v2")); + let digest = parts.next().expect("v2 locator has a fingerprint"); + assert_eq!(digest.len(), 64, "fingerprint must contain all 256 bits"); + assert!( + digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')), + "fingerprint must be canonical lowercase hex" + ); +} + +#[test] +fn legacy_or_malformed_locators_fail_closed_as_stale() { + let src = fixture("text-tj.pdf"); + for locator in [ + "v1:0000000000000000:0:0:1:0:0:1:0", + "v2:abc:0:0:1:0:0:1:0", + "v2:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:0:0:1:0:0:1:0", + ] { + let err = resolve_source_locator(&src, locator) + .expect_err("non-v2 or non-canonical fingerprints must fail closed"); + assert_eq!(err.code, "STALE"); + } +} + #[test] fn classify_mutated_copy_locator_is_stale() { let src = fixture("text-tj.pdf"); @@ -693,6 +727,38 @@ fn classify_oversize_sparse_file_is_file_too_large() { ); } +#[test] +fn classify_rejects_graphics_state_stack_overflow() { + let scratch = Scratch::new("q-overflow"); + let path = scratch.file("q-overflow.pdf"); + let mut content = b"q\n".repeat(65); + content.extend_from_slice(b"BT /F1 12 Tf 72 720 Td (Hidden state) Tj ET\n"); + write_helvetica_page(&path, &content); + expect_err_code( + classify_source_content(&path), + "MALFORMED_CONTENT", + "CLASSIFY-BOUNDS: graphics state stack overflow", + ); +} + +#[test] +fn classify_rejects_filtered_stream_when_decompression_fails() { + let scratch = Scratch::new("bad-filter"); + let path = scratch.file("bad-filter.pdf"); + let mut dict = Dictionary::new(); + dict.set("Filter", "FlateDecode"); + write_helvetica_page_with_stream_dict( + &path, + b"BT /F1 12 Tf 72 720 Td (Raw fallback must not run) Tj ET\n", + dict, + ); + expect_err_code( + classify_source_content(&path), + "MALFORMED_CONTENT", + "CLASSIFY-BOUNDS: invalid filtered stream", + ); +} + // --- PR 97 review fold (R1–R5) --------------------------------------------- // Extra PDFs are generated in temp with lopdf. Do not grow fixtures/source-edit/. @@ -722,6 +788,143 @@ fn classify_missing_font_is_not_supported() { assert_eq!(reason_code(&hits[0]).as_deref(), Some("MISSING_FONT")); } +#[test] +fn classify_subset_and_custom_encoded_fonts_are_not_supported() { + let scratch = Scratch::new("review-font-compatibility"); + + let mut subset = Dictionary::new(); + subset.set("Type", "Font"); + subset.set("Subtype", "Type1"); + subset.set("BaseFont", "ABCDEF+Helvetica"); + let subset_path = scratch.file("subset.pdf"); + write_text_page_with_font( + &subset_path, + b"BT /F1 12 Tf 72 400 Td (Hi) Tj ET", + subset, + ); + let subset_hits = classify(&subset_path, "REVIEW-SUBSET-FONT"); + assert_unsupported( + first_of_kind(&subset_hits, "text", "REVIEW-SUBSET-FONT"), + "text", + "SUBSET_FONT", + "REVIEW-SUBSET-FONT", + ); + + let mut differences = Dictionary::new(); + differences.set("Type", "Encoding"); + differences.set("BaseEncoding", "WinAnsiEncoding"); + differences.set( + "Differences", + vec![Object::Integer(72), Object::Name(b"customH".to_vec())], + ); + let mut custom = Dictionary::new(); + custom.set("Type", "Font"); + custom.set("Subtype", "Type1"); + custom.set("BaseFont", "Helvetica"); + custom.set("Encoding", differences); + let custom_path = scratch.file("custom-encoding.pdf"); + write_text_page_with_font( + &custom_path, + b"BT /F1 12 Tf 72 400 Td (Hi) Tj ET", + custom, + ); + let custom_hits = classify(&custom_path, "REVIEW-CUSTOM-ENCODING"); + assert_unsupported( + first_of_kind(&custom_hits, "text", "REVIEW-CUSTOM-ENCODING"), + "text", + "CUSTOM_ENCODING", + "REVIEW-CUSTOM-ENCODING", + ); +} + +#[test] +fn classify_named_standard_encoding_remains_supported() { + let scratch = Scratch::new("review-standard-encoding"); + let path = scratch.file("win-ansi.pdf"); + let mut font = Dictionary::new(); + font.set("Type", "Font"); + font.set("Subtype", "Type1"); + font.set("BaseFont", "Helvetica"); + font.set("Encoding", "WinAnsiEncoding"); + write_text_page_with_font(&path, b"BT /F1 12 Tf 72 400 Td (Hi) Tj ET", font); + let hits = classify(&path, "REVIEW-STANDARD-ENCODING"); + assert_supported_text_or_image( + first_of_kind(&hits, "text", "REVIEW-STANDARD-ENCODING"), + "text", + "REVIEW-STANDARD-ENCODING", + ); +} + +#[test] +fn classify_text_rise_moves_bounds_and_resets_to_supported() { + let scratch = Scratch::new("review-text-rise"); + let path = scratch.file("text-rise.pdf"); + write_helvetica_page( + &path, + b"BT /F1 12 Tf 72 700 Td 20 Ts (Hi) Tj 0 Ts (Lo) Tj ET", + ); + let hits = classify(&path, "REVIEW-TEXT-RISE"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|hit| kind_token(hit) == "text").collect(); + assert_eq!(texts.len(), 2); + assert_unsupported(texts[0], "text", "TEXT_RISE", "REVIEW-TEXT-RISE"); + assert!((texts[0].rect.y - 720.0).abs() <= 1.0); + assert_supported_text_or_image(texts[1], "text", "REVIEW-TEXT-RISE-RESET"); + assert!((texts[1].rect.y - 700.0).abs() <= 1.0); +} + +#[test] +fn classify_text_render_mode_applies_clip_only_after_text_object() { + let scratch = Scratch::new("review-text-render-mode"); + let path = scratch.file("text-render-mode.pdf"); + write_helvetica_page( + &path, + concat!( + "BT /F1 12 Tf 72 700 Td 7 Tr (A) Tj 0 Tr (B) Tj ET ", + "BT /F1 12 Tf 72 650 Td (C) Tj ET" + ) + .as_bytes(), + ); + let hits = classify(&path, "REVIEW-TEXT-RENDER-MODE"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|hit| kind_token(hit) == "text").collect(); + assert_eq!(texts.len(), 3); + assert_unsupported( + texts[0], + "text", + "TEXT_RENDER_MODE", + "REVIEW-TEXT-RENDER-MODE", + ); + assert_supported_text_or_image(texts[1], "text", "REVIEW-TEXT-RENDER-MODE-RESET"); + assert_unsupported( + texts[2], + "text", + "CLIPPED", + "REVIEW-TEXT-RENDER-MODE-CLIP", + ); +} + +#[test] +fn classify_malformed_text_state_operators_fail_closed() { + let scratch = Scratch::new("review-malformed-text-state"); + for (name, content) in [ + ("missing-rise.pdf", &b"BT /F1 12 Tf Ts (Hi) Tj ET"[..]), + ( + "invalid-render-mode.pdf", + &b"BT /F1 12 Tf 1.5 Tr (Hi) Tj ET"[..], + ), + ( + "out-of-range-render-mode.pdf", + &b"BT /F1 12 Tf 8 Tr (Hi) Tj ET"[..], + ), + ] { + let path = scratch.file(name); + write_helvetica_page(&path, content); + assert_eq!( + classify_source_content(&path).unwrap_err().code, + "MALFORMED_CONTENT" + ); + } +} + #[test] fn classify_repeated_form_occurrences_have_unique_locators() { let scratch = Scratch::new("review-repeated-form"); @@ -757,18 +960,40 @@ fn helvetica_resources() -> Dictionary { } fn write_helvetica_page(path: &Path, content: &[u8]) { + write_helvetica_page_with_stream_dict(path, content, Dictionary::new()); +} + +fn write_helvetica_page_with_stream_dict(path: &Path, content: &[u8], stream_dict: Dictionary) { + let mut font = Dictionary::new(); + font.set("Type", "Font"); + font.set("Subtype", "Type1"); + font.set("BaseFont", "Helvetica"); + write_text_page_with_font_and_stream_dict(path, content, font, stream_dict); +} + +fn write_text_page_with_font(path: &Path, content: &[u8], font: Dictionary) { + write_text_page_with_font_and_stream_dict(path, content, font, Dictionary::new()); +} + +fn write_text_page_with_font_and_stream_dict( + path: &Path, + content: &[u8], + font: Dictionary, + stream_dict: Dictionary, +) { let mut doc = Document::with_version("1.7"); let pages_id = doc.new_object_id(); - let content_id = doc.add_object(Object::Stream(Stream::new( - Dictionary::new(), - content.to_vec(), - ))); + let content_id = doc.add_object(Object::Stream(Stream::new(stream_dict, content.to_vec()))); + let mut fonts = Dictionary::new(); + fonts.set("F1", Object::Dictionary(font)); + let mut resources = Dictionary::new(); + resources.set("Font", Object::Dictionary(fonts)); let mut page = Dictionary::new(); page.set("Type", "Page"); page.set("Parent", pages_id); page.set("MediaBox", box_obj([0, 0, 612, 792])); page.set("Contents", content_id); - page.set("Resources", Object::Dictionary(helvetica_resources())); + page.set("Resources", Object::Dictionary(resources)); let page_id = doc.add_object(Object::Dictionary(page)); let mut pages = Dictionary::new(); diff --git a/src-tauri/src/pdf_engine/source_content_preflight.rs b/src-tauri/src/pdf_engine/source_content_preflight.rs new file mode 100644 index 0000000..57d9b1d --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content_preflight.rs @@ -0,0 +1,661 @@ +//! Resource bounds applied before lopdf parses source content. +//! +//! This bounds raw cross-reference data, object values, object streams, and +//! page/Form content before lopdf parses them. + +mod content; +mod headers; +mod xref; + +use self::headers::Headers; +use crate::error::AppError; +use crate::pdf_engine::source_content_decode; +use lopdf::{Dictionary, Document, Object}; +use std::ops::Range; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Mutex; + +pub(super) const MAX_OBJECTS: usize = 2_000_000; +const MAX_OBJECT_NESTING: usize = 100; +const OBJECT_SCAN_FACTOR: usize = 2; +const OBJECT_SCAN_SLACK_BYTES: usize = 1 << 20; +const LENGTH_REF_STREAMS_MAX: usize = MAX_OBJECTS / 2; +const LENGTH_REF_CHAIN_MAX: usize = 32; +const OBJSTM_MAX_DECODED: usize = 32 << 20; +const OBJSTM_TOTAL_DECODED: usize = 256 << 20; +const OBJSTM_SCAN_BYTES: usize = + OBJECT_SCAN_FACTOR * OBJSTM_TOTAL_DECODED + OBJECT_SCAN_SLACK_BYTES; + +static LOAD_LOCK: Mutex<()> = Mutex::new(()); +static OBJSTM_BUDGET: AtomicUsize = AtomicUsize::new(0); +static OBJSTM_SCAN_BUDGET: AtomicUsize = AtomicUsize::new(0); +static OBJSTM_REJECTED: AtomicBool = AtomicBool::new(false); +const REJECTED_OBJSTM: &[u8] = b"OffPdfRejectedObjStm"; + +pub(super) fn load_document(bytes: &[u8], path: &str) -> Result { + xref::check(bytes)?; + check_objects(bytes)?; + + // lopdf's object-stream filter is a function pointer. Serialize loads so + // the per-load atomic budgets cannot leak between concurrent classifiers. + let _lock = LOAD_LOCK.lock().unwrap_or_else(|error| error.into_inner()); + OBJSTM_BUDGET.store(OBJSTM_TOTAL_DECODED, Ordering::SeqCst); + OBJSTM_SCAN_BUDGET.store(OBJSTM_SCAN_BYTES, Ordering::SeqCst); + OBJSTM_REJECTED.store(false, Ordering::SeqCst); + + let document = lopdf::Reader { + buffer: bytes, + document: Document::new(), + } + .read(Some(object_stream_guard)) + .map_err(|error| AppError::invalid_pdf(path).with_details(format!("lopdf: {error}")))?; + + let rejected = OBJSTM_REJECTED.load(Ordering::SeqCst) + || document + .objects + .values() + .any(|object| matches!(object, Object::Stream(stream) if stream.dict.type_is(REJECTED_OBJSTM))); + if rejected { + return Err(file_too_complex( + "an object stream is too large, too deeply nested, or cannot be decoded", + )); + } + if document.objects.len() > MAX_OBJECTS { + return Err(file_too_complex("too many objects")); + } + Ok(document) +} + +pub(super) fn prepare_content(bytes: &[u8]) -> Result, AppError> { + content::prepare(bytes) +} + +fn object_stream_guard(id: (u32, u16), object: &mut Object) -> Option<((u32, u16), Object)> { + let Object::Stream(stream) = object else { + // lopdf uses this value for members of an object stream. At the top + // level the return value is ignored. + return Some((id, object.clone())); + }; + + if stream.dict.type_is(b"ObjStm") { + let remaining = OBJSTM_BUDGET.load(Ordering::SeqCst); + let cap = OBJSTM_MAX_DECODED.min(remaining); + let declared = stream.dict.get(b"N").and_then(Object::as_i64).unwrap_or(0); + let decoded = source_content_decode::decode_stream(stream, cap); + let accepted = match decoded { + Ok(data) + if !(data.is_empty() && declared > 0) + && object_stream_members_ok(&stream.dict, &data, &OBJSTM_SCAN_BUDGET) => + { + let debited = OBJSTM_BUDGET + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + left.checked_sub(data.len()) + }) + .is_ok(); + if debited { + stream.dict.remove(b"Filter"); + stream.dict.remove(b"DecodeParms"); + stream.set_content(data); + } + debited + } + _ => false, + }; + + if !accepted { + stream + .dict + .set("Type", Object::Name(REJECTED_OBJSTM.to_vec())); + stream.content.clear(); + OBJSTM_REJECTED.store(true, Ordering::SeqCst); + } + } + + // Streams are top-level objects, where lopdf ignores the returned value. + Some((id, Object::Null)) +} + +struct ScanBudget(usize); + +impl ScanBudget { + fn for_len(len: usize) -> Self { + Self( + len.saturating_mul(OBJECT_SCAN_FACTOR) + .saturating_add(OBJECT_SCAN_SLACK_BYTES), + ) + } + + fn debit(&mut self, amount: usize) -> Result<(), ScanError> { + self.0 = self.0.checked_sub(amount).ok_or(ScanError::Budget)?; + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScanError { + TooDeep, + Budget, +} + +impl ScanError { + fn into_app_error(self) -> AppError { + match self { + Self::TooDeep => file_too_complex(&format!( + "objects nested more than {MAX_OBJECT_NESTING} levels deep" + )), + Self::Budget => file_too_complex("object scan budget exceeded"), + } + } +} + +struct LengthRef { + run: Range, + target: u32, +} + +fn check_objects(bytes: &[u8]) -> Result<(), AppError> { + let mut budget = ScanBudget::for_len(bytes.len()); + let mut length_refs = Vec::new(); + let mut object_count = 0usize; + + for (run, value_at) in Headers::new(bytes) { + object_count = object_count.saturating_add(1); + if object_count > MAX_OBJECTS { + return Err(file_too_complex("too many object headers")); + } + + let target = scan_value(bytes, value_at, &mut budget).map_err(ScanError::into_app_error)?; + if let Some(target) = target { + if length_refs.len() >= LENGTH_REF_STREAMS_MAX { + return Err(file_too_complex( + "too many streams with a /Length reference", + )); + } + length_refs.push(LengthRef { run, target }); + } + } + + let longest = longest_length_chain(bytes, &length_refs); + if longest > LENGTH_REF_CHAIN_MAX { + return Err(file_too_complex(&format!( + "stream /Length references may chain through {longest} objects" + ))); + } + Ok(()) +} + +fn object_stream_members_ok(dictionary: &Dictionary, data: &[u8], shared: &AtomicUsize) -> bool { + let first = dictionary + .get(b"First") + .and_then(Object::as_i64) + .ok() + .and_then(|value| usize::try_from(value).ok()); + let Some(first) = first else { + return true; + }; + let header = data + .get(..first) + .and_then(|value| std::str::from_utf8(value).ok()); + let (Some(header), true) = ( + header, + dictionary.get(b"N").and_then(Object::as_i64).is_ok(), + ) else { + return true; + }; + + let numbers: Vec> = header + .split_whitespace() + .map(|number| number.parse::().ok()) + .collect(); + let granted = ScanBudget::for_len(data.len()) + .0 + .min(shared.load(Ordering::SeqCst)); + let mut budget = ScanBudget(granted); + let members_ok = numbers.chunks_exact(2).all(|pair| { + let (Some(Some(_)), Some(Some(offset))) = (pair.first(), pair.get(1)) else { + return true; + }; + let offset = usize::try_from(*offset) + .ok() + .and_then(|offset| first.checked_add(offset)); + match offset { + Some(offset) if offset < data.len() => scan_value(data, offset, &mut budget).is_ok(), + _ => true, + } + }); + let used = granted.saturating_sub(budget.0); + let debited = shared + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| { + left.checked_sub(used) + }) + .is_ok(); + members_ok && debited +} + +fn skip_space(bytes: &[u8], mut index: usize) -> usize { + loop { + match bytes.get(index) { + Some(byte) if is_whitespace(*byte) => index += 1, + Some(b'%') => { + index += bytes.get(index..).map_or(0, |rest| { + rest.iter() + .take_while(|byte| !matches!(byte, b'\r' | b'\n')) + .count() + }); + } + _ => return index, + } + } +} + +const VALUE_WORD_BYTES: &[u8] = b"0123456789+-.Rtruefalsn"; + +fn regular_end(bytes: &[u8], from: usize) -> usize { + from + bytes.get(from..).map_or(0, |rest| { + rest.iter() + .take_while(|byte| !is_whitespace(**byte) && !is_delimiter(**byte)) + .count() + }) +} + +fn skip_literal(bytes: &[u8], mut index: usize) -> usize { + let mut depth = 0usize; + while let Some(&byte) = bytes.get(index) { + index += 1; + match byte { + b'\\' => index += 1, + b'(' => depth += 1, + b')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + break; + } + } + _ => {} + } + } + index.min(bytes.len()) +} + +fn scan_value(bytes: &[u8], at: usize, budget: &mut ScanBudget) -> Result, ScanError> { + let mut index = at; + let mut depth = 0usize; + let mut top_dictionary = false; + let mut length_state = LengthState::Idle; + let mut length_ref = None; + + let closed_dictionary = loop { + index = skip_space(bytes, index); + let Some(&byte) = bytes.get(index) else { + break false; + }; + let pair = bytes.get(index + 1) == Some(&byte); + + if byte == b'[' || (byte == b'<' && pair) { + if depth == 0 { + top_dictionary = byte == b'<'; + } + depth += 1; + if depth > MAX_OBJECT_NESTING { + return Err(ScanError::TooDeep); + } + length_state = LengthState::Idle; + index += if byte == b'[' { 1 } else { 2 }; + continue; + } + if byte == b']' || (byte == b'>' && pair) { + let Some(next_depth) = depth.checked_sub(1) else { + break false; + }; + depth = next_depth; + index += if byte == b']' { 1 } else { 2 }; + if depth == 0 { + break top_dictionary; + } + continue; + } + + let end = match byte { + b'(' => skip_literal(bytes, index), + b'<' => bytes + .get(index..) + .and_then(|rest| rest.iter().position(|candidate| *candidate == b'>')) + .map_or(bytes.len(), |position| index + position + 1), + b'/' => regular_end(bytes, index + 1), + _ if is_delimiter(byte) => break false, + _ => { + let end = regular_end(bytes, index); + let word = bytes.get(index..end).unwrap_or_default(); + if !word + .iter() + .all(|candidate| VALUE_WORD_BYTES.contains(candidate)) + { + break false; + } + end + } + }; + + let token = bytes.get(index..end).unwrap_or_default(); + if depth == 1 && top_dictionary { + length_state = length_state.step(token, &mut length_ref); + } + index = end; + if depth == 0 { + break false; + } + }; + + let end = if closed_dictionary { + skip_space(bytes, index) + } else { + index + }; + budget.debit(end.saturating_sub(at))?; + let stream = closed_dictionary + && bytes + .get(end..) + .is_some_and(|rest| rest.starts_with(b"stream")); + Ok(length_ref.filter(|_| stream)) +} + +#[derive(Clone, Copy)] +enum LengthState { + Idle, + Key, + Id(u32), + Generation(u32), +} + +impl LengthState { + fn step(self, token: &[u8], found: &mut Option) -> Self { + if let Some(name) = token.strip_prefix(b"/") { + return if name_is(name, b"Length") { + Self::Key + } else { + Self::Idle + }; + } + + let digits = token + .iter() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + let number = token.get(..digits).unwrap_or_default(); + let rest = token.get(digits..).unwrap_or_default(); + match self { + Self::Key if rest.is_empty() => parse_u32(number).map_or(Self::Idle, Self::Id), + Self::Id(id) if digits > 0 && rest.is_empty() => Self::Generation(id), + Self::Id(id) if digits > 0 && rest.starts_with(b"R") => { + *found = Some(id); + Self::Idle + } + Self::Generation(id) if token.starts_with(b"R") => { + *found = Some(id); + Self::Idle + } + _ => Self::Idle, + } + } +} + +fn parse_u32(digits: &[u8]) -> Option { + std::str::from_utf8(digits).ok()?.parse().ok() +} + +fn name_is(raw: &[u8], wanted: &[u8]) -> bool { + let hex = |byte: u8| (byte as char).to_digit(16); + let mut expected = wanted.iter(); + let mut index = 0usize; + while let Some(&byte) = raw.get(index) { + let decoded = if byte == b'#' { + match ( + raw.get(index + 1).and_then(|byte| hex(*byte)), + raw.get(index + 2).and_then(|byte| hex(*byte)), + ) { + (Some(high), Some(low)) => { + index += 3; + high * 16 + low + } + _ => break, + } + } else { + index += 1; + u32::from(byte) + }; + if expected.next().map(|byte| u32::from(*byte)) != Some(decoded) { + return false; + } + } + expected.next().is_none() +} + +fn suffix_ids(run: &[u8]) -> Vec { + let mut ids: Vec = (1..=run.len().min(10)) + .filter_map(|length| parse_u32(run.get(run.len() - length..)?)) + .collect(); + ids.dedup(); + ids +} + +fn longest_length_chain(bytes: &[u8], references: &[LengthRef]) -> usize { + if references.is_empty() { + return 0; + } + + let mut nodes: Vec = references + .iter() + .map(|reference| reference.target) + .collect(); + nodes.sort_unstable(); + nodes.dedup(); + let node = |id: u32| nodes.binary_search(&id).ok(); + let mut edges = Vec::new(); + for reference in references { + let Some(to) = node(reference.target) else { + continue; + }; + let run = bytes.get(reference.run.clone()).unwrap_or_default(); + edges.extend( + suffix_ids(run) + .into_iter() + .filter_map(node) + .map(|from| (from, to)), + ); + } + edges.sort_unstable(); + edges.dedup(); + longest_scc_path(nodes.len(), &edges).saturating_add(1) +} + +fn longest_scc_path(node_count: usize, edges: &[(usize, usize)]) -> usize { + const UNSEEN: usize = usize::MAX; + let outgoing = |vertex: usize| { + let low = edges.partition_point(|edge| edge.0 < vertex); + let high = edges.partition_point(|edge| edge.0 <= vertex); + edges.get(low..high).unwrap_or_default() + }; + + let mut index = vec![UNSEEN; node_count]; + let mut low = vec![0usize; node_count]; + let mut on_stack = vec![false; node_count]; + let mut component = vec![UNSEEN; node_count]; + let mut best = Vec::new(); + let mut stack = Vec::new(); + let mut counter = 0usize; + + for root in 0..node_count { + if index.get(root) != Some(&UNSEEN) { + continue; + } + let mut calls = vec![(root, 0usize)]; + visit( + root, + &mut counter, + &mut index, + &mut low, + &mut on_stack, + &mut stack, + ); + + while let Some((vertex, position)) = calls.last().copied() { + if let Some(&(_, next)) = outgoing(vertex).get(position) { + if let Some(frame) = calls.last_mut() { + frame.1 += 1; + } + if index.get(next) == Some(&UNSEEN) { + visit( + next, + &mut counter, + &mut index, + &mut low, + &mut on_stack, + &mut stack, + ); + calls.push((next, 0)); + } else if on_stack.get(next) == Some(&true) { + let next_index = index.get(next).copied().unwrap_or(UNSEEN); + if let Some(vertex_low) = low.get_mut(vertex) { + *vertex_low = (*vertex_low).min(next_index); + } + } + continue; + } + + calls.pop(); + let vertex_low = low.get(vertex).copied().unwrap_or(0); + if let Some(&(parent, _)) = calls.last() { + if let Some(parent_low) = low.get_mut(parent) { + *parent_low = (*parent_low).min(vertex_low); + } + } + if Some(&vertex_low) != index.get(vertex) { + continue; + } + + let component_id = best.len(); + let mut members = Vec::new(); + while let Some(member) = stack.pop() { + if let Some(value) = on_stack.get_mut(member) { + *value = false; + } + if let Some(value) = component.get_mut(member) { + *value = component_id; + } + members.push(member); + if member == vertex { + break; + } + } + let successors = members + .iter() + .flat_map(|member| outgoing(*member).iter()) + .filter_map(|&(_, next)| { + component + .get(next) + .copied() + .filter(|id| *id != component_id && *id != UNSEEN) + }) + .filter_map(|id| best.get(id).copied()) + .max() + .unwrap_or(0); + best.push(members.len().saturating_add(successors)); + } + } + best.into_iter().max().unwrap_or(0) +} + +fn visit( + vertex: usize, + counter: &mut usize, + index: &mut [usize], + low: &mut [usize], + on_stack: &mut [bool], + stack: &mut Vec, +) { + if let (Some(index_value), Some(low_value), Some(stack_value)) = ( + index.get_mut(vertex), + low.get_mut(vertex), + on_stack.get_mut(vertex), + ) { + *index_value = *counter; + *low_value = *counter; + *stack_value = true; + } + *counter += 1; + stack.push(vertex); +} + +fn is_whitespace(byte: u8) -> bool { + matches!(byte, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn is_delimiter(byte: u8) -> bool { + matches!( + byte, + b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' + ) +} + +fn file_too_complex(detail: &str) -> AppError { + AppError::new( + "FILE_TOO_COMPLEX", + "This PDF is too complex to check", + "Some of its internal data is too large or too deeply nested to check safely.", + ) + .with_details(detail) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nested_object(levels: usize) -> Vec { + let mut bytes = b"%PDF-1.7\n1 0 obj\n".to_vec(); + bytes.extend(std::iter::repeat_n(b'[', levels)); + bytes.extend(std::iter::repeat_n(b']', levels)); + bytes.extend_from_slice(b"\nendobj\n"); + bytes + } + + #[test] + fn accepts_object_nesting_at_limit() { + check_objects(&nested_object(MAX_OBJECT_NESTING)).unwrap(); + } + + #[test] + fn rejects_object_nesting_over_limit() { + let error = check_objects(&nested_object(MAX_OBJECT_NESTING + 1)).unwrap_err(); + assert_eq!(error.code, "FILE_TOO_COMPLEX"); + assert!(error.details.unwrap_or_default().contains("nested")); + } + + #[test] + fn rejects_long_stream_length_reference_chain() { + let mut bytes = b"%PDF-1.7\n".to_vec(); + for id in 1..=LENGTH_REF_CHAIN_MAX + 2 { + bytes.extend_from_slice( + format!( + "{id} 0 obj\n<< /Length {} 0 R >>\nstream\nx\nendstream\nendobj\n", + id + 1 + ) + .as_bytes(), + ); + } + let error = check_objects(&bytes).unwrap_err(); + assert_eq!(error.code, "FILE_TOO_COMPLEX"); + assert!(error.details.unwrap_or_default().contains("/Length")); + } + + #[test] + fn checks_object_stream_member_nesting() { + let mut dictionary = Dictionary::new(); + dictionary.set("First", Object::Integer(4)); + dictionary.set("N", Object::Integer(1)); + let mut data = b"7 0 ".to_vec(); + data.extend(std::iter::repeat_n(b'[', MAX_OBJECT_NESTING + 1)); + data.extend(std::iter::repeat_n(b']', MAX_OBJECT_NESTING + 1)); + let shared = AtomicUsize::new(OBJSTM_SCAN_BYTES); + assert!(!object_stream_members_ok(&dictionary, &data, &shared)); + } +} diff --git a/src-tauri/src/pdf_engine/source_content_preflight/content.rs b/src-tauri/src/pdf_engine/source_content_preflight/content.rs new file mode 100644 index 0000000..92e3174 --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content_preflight/content.rs @@ -0,0 +1,815 @@ +//! Bounded lexical preflight for page and Form content streams. +//! +//! lopdf still produces the operation objects used by the classifier, but only +//! after this scanner has bounded every recursive value and removed verified +//! inline-image payloads from the bytes lopdf receives. + +use crate::error::AppError; +use crate::pdf_engine::source_content_decode::{self, DecodeError}; +const MAX_OPERATIONS: usize = 50_000; +const MAX_OPERAND_NODES: usize = 300_000; +const MAX_OPERANDS_PER_OPERATOR: usize = 65_536; +const MAX_CONTAINER_ITEMS: usize = 65_536; +const MAX_NESTING: usize = 32; +const MAX_STRING_BYTES: usize = 1 << 20; +const MAX_LITERAL_NESTING: usize = 100; +const MAX_INLINE_BYTES: usize = 16 << 20; +const MAX_INLINE_ENTRIES: usize = 256; + +#[derive(Debug)] +pub(crate) struct PreparedContent<'a> { + original: &'a [u8], + sanitized: Option>, + pub(crate) inline_images: usize, +} + +impl PreparedContent<'_> { + pub(crate) fn bytes(&self) -> &[u8] { + self.sanitized.as_deref().unwrap_or(self.original) + } +} + +pub(super) fn prepare(bytes: &[u8]) -> Result, AppError> { + let mut scanner = Scanner::new(bytes); + let mut pending_operands = 0usize; + let mut inline_spans = Vec::new(); + + loop { + scanner.skip_space(); + if scanner.position >= bytes.len() { + break; + } + if scanner.scan_value(0)?.is_some() { + pending_operands = pending_operands.saturating_add(1); + if pending_operands > MAX_OPERANDS_PER_OPERATOR { + return Err(content_too_complex("too many operands for one operator")); + } + continue; + } + + let operator = scanner.regular_word()?; + if operator == b"BI" { + if pending_operands != 0 { + return Err(malformed_content("Operands appear before an inline image.")); + } + scanner.state.add_operations(3)?; // BI, ID, EI + inline_spans.push(scanner.inline_image()?); + } else { + if matches!(operator, b"ID" | b"EI") { + return Err(malformed_content( + "An inline-image operator appears outside an inline image.", + )); + } + scanner.state.add_operations(1)?; + pending_operands = 0; + } + } + + if pending_operands != 0 { + return Err(malformed_content( + "A content stream ends with operands but no operator.", + )); + } + + let sanitized = if inline_spans.is_empty() { + None + } else { + let mut output = Vec::new(); + output + .try_reserve_exact(bytes.len()) + .map_err(|_| content_too_complex("inline-image sanitization allocation"))?; + let mut copied = 0usize; + for span in &inline_spans { + if span.data_start < copied || span.ei_end > bytes.len() { + return Err(malformed_content("Inline-image ranges overlap.")); + } + output.extend_from_slice(bytes.get(copied..span.data_start).unwrap_or_default()); + output.extend_from_slice(b"EI"); + copied = span.ei_end; + } + output.extend_from_slice(bytes.get(copied..).unwrap_or_default()); + Some(output) + }; + + Ok(PreparedContent { + original: bytes, + sanitized, + inline_images: inline_spans.len(), + }) +} + +struct InlineSpan { + data_start: usize, + ei_end: usize, +} + +#[derive(Default)] +struct ScanState { + nodes: usize, + operations: usize, +} + +impl ScanState { + fn add_node(&mut self) -> Result<(), AppError> { + self.nodes = self.nodes.saturating_add(1); + if self.nodes > MAX_OPERAND_NODES { + return Err(content_too_complex("too many operand nodes")); + } + Ok(()) + } + + fn add_operations(&mut self, count: usize) -> Result<(), AppError> { + self.operations = self.operations.saturating_add(count); + if self.operations > MAX_OPERATIONS { + return Err(content_too_complex("too many operations")); + } + Ok(()) + } +} + +struct Scanner<'a> { + bytes: &'a [u8], + position: usize, + state: ScanState, +} + +impl<'a> Scanner<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { + bytes, + position: 0, + state: ScanState::default(), + } + } + + fn skip_space(&mut self) { + loop { + match self.bytes.get(self.position) { + Some(byte) if is_whitespace(*byte) => self.position += 1, + Some(b'%') => { + self.position += self.bytes.get(self.position..).map_or(0, |rest| { + rest.iter() + .take_while(|byte| !matches!(byte, b'\r' | b'\n')) + .count() + }); + } + _ => break, + } + } + } + + fn scan_value(&mut self, depth: usize) -> Result, AppError> { + self.skip_space(); + let start = self.position; + let Some(byte) = self.bytes.get(start).copied() else { + return Ok(None); + }; + + let value = match byte { + b'/' => { + self.state.add_node()?; + Value::Name(self.name()?) + } + b'(' => { + self.state.add_node()?; + self.literal_string()?; + Value::Other + } + b'<' if self.bytes.get(start + 1) == Some(&b'<') => { + self.state.add_node()?; + self.dictionary(depth)?; + Value::Other + } + b'<' => { + self.state.add_node()?; + self.hex_string()?; + Value::Other + } + b'[' => { + self.state.add_node()?; + Value::Array(self.array(depth)?) + } + _ if token_at(self.bytes, start, b"true") => { + self.state.add_node()?; + self.position += 4; + Value::Bool(true) + } + _ if token_at(self.bytes, start, b"false") => { + self.state.add_node()?; + self.position += 5; + Value::Bool(false) + } + _ if token_at(self.bytes, start, b"null") => { + self.state.add_node()?; + self.position += 4; + Value::Other + } + _ if byte.is_ascii_digit() || matches!(byte, b'+' | b'-' | b'.') => { + self.state.add_node()?; + let token = self.regular_word()?; + let number = parse_number(token) + .filter(|number| number.abs() <= 1e9) + .ok_or_else(|| { + malformed_content("A content number is invalid or too large.") + })?; + Value::Number { + value: number, + integer: !token.contains(&b'.'), + } + } + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn array(&mut self, depth: usize) -> Result, AppError> { + if depth >= MAX_NESTING { + return Err(content_too_complex("array or dictionary nesting")); + } + self.position += 1; + let mut items = Vec::new(); + loop { + self.skip_space(); + if self.bytes.get(self.position) == Some(&b']') { + self.position += 1; + return Ok(items); + } + if items.len() >= MAX_CONTAINER_ITEMS { + return Err(content_too_complex("too many array items")); + } + let value = self + .scan_value(depth + 1)? + .ok_or_else(|| malformed_content("An array contains a non-value token."))?; + items.push(value); + } + } + + fn dictionary(&mut self, depth: usize) -> Result<(), AppError> { + if depth >= MAX_NESTING { + return Err(content_too_complex("array or dictionary nesting")); + } + self.position += 2; + let mut entries = 0usize; + loop { + self.skip_space(); + if self + .bytes + .get(self.position..) + .is_some_and(|rest| rest.starts_with(b">>")) + { + self.position += 2; + return Ok(()); + } + if entries >= MAX_CONTAINER_ITEMS { + return Err(content_too_complex("too many dictionary entries")); + } + if self.bytes.get(self.position) != Some(&b'/') { + return Err(malformed_content("A dictionary key is not a name.")); + } + self.state.add_node()?; + self.name()?; + self.scan_value(depth + 1)? + .ok_or_else(|| malformed_content("A dictionary value is missing."))?; + entries += 1; + } + } + + fn name(&mut self) -> Result, AppError> { + self.position += 1; + let mut output = Vec::new(); + while let Some(byte) = self + .bytes + .get(self.position) + .copied() + .filter(|byte| is_regular(*byte)) + { + let escaped = ( + self.bytes + .get(self.position + 1) + .and_then(|byte| hex_digit(*byte)), + self.bytes + .get(self.position + 2) + .and_then(|byte| hex_digit(*byte)), + ); + match (byte, escaped) { + (b'#', (Some(high), Some(low))) => { + output.push((high << 4) | low); + self.position += 3; + } + _ => { + output.push(byte); + self.position += 1; + } + } + if output.len() > MAX_STRING_BYTES { + return Err(content_too_complex("name length")); + } + } + Ok(output) + } + + fn literal_string(&mut self) -> Result<(), AppError> { + let start = self.position; + self.position += 1; + let mut depth = 1usize; + let mut decoded = 0usize; + loop { + let Some(byte) = self.bytes.get(self.position).copied() else { + return Err(malformed_content("A literal string is unterminated.")); + }; + self.position += 1; + match byte { + b'\\' => { + let Some(escaped) = self.bytes.get(self.position).copied() else { + return Err(malformed_content("A literal string is unterminated.")); + }; + self.position += 1; + match escaped { + b'0'..=b'7' => { + for _ in 0..2 { + if matches!(self.bytes.get(self.position), Some(b'0'..=b'7')) { + self.position += 1; + } else { + break; + } + } + decoded += 1; + } + b'\r' => { + if self.bytes.get(self.position) == Some(&b'\n') { + self.position += 1; + } + } + b'\n' => {} + _ => decoded += 1, + } + } + b'(' => { + depth += 1; + if depth > MAX_LITERAL_NESTING { + return Err(content_too_complex("literal string nesting")); + } + decoded += 1; + } + b')' => { + depth -= 1; + if depth == 0 { + return Ok(()); + } + decoded += 1; + } + b'\r' => { + if self.bytes.get(self.position) == Some(&b'\n') { + self.position += 1; + } + decoded += 1; + } + _ => decoded += 1, + } + if decoded > MAX_STRING_BYTES { + return Err(content_too_complex("literal string length")); + } + if self.position <= start { + return Err(malformed_content("A literal string could not be scanned.")); + } + } + } + + fn hex_string(&mut self) -> Result<(), AppError> { + self.position += 1; + let mut digits = 0usize; + loop { + let Some(byte) = self.bytes.get(self.position).copied() else { + return Err(malformed_content("A hex string is unterminated.")); + }; + self.position += 1; + if byte == b'>' { + return Ok(()); + } + if is_whitespace(byte) { + continue; + } + if hex_digit(byte).is_none() { + return Err(malformed_content("A hex string contains a non-hex byte.")); + } + digits += 1; + if digits.div_ceil(2) > MAX_STRING_BYTES { + return Err(content_too_complex("hex string length")); + } + } + } + + fn regular_word(&mut self) -> Result<&'a [u8], AppError> { + let start = self.position; + while self + .bytes + .get(self.position) + .is_some_and(|byte| is_regular(*byte)) + { + self.position += 1; + } + if self.position == start { + return Err(malformed_content( + "A content token starts with a stray delimiter.", + )); + } + Ok(self.bytes.get(start..self.position).unwrap_or_default()) + } + + fn inline_image(&mut self) -> Result { + let mut dictionary = Vec::new(); + loop { + self.skip_space(); + if token_at(self.bytes, self.position, b"ID") { + self.position += 2; + break; + } + if dictionary.len() >= MAX_INLINE_ENTRIES { + return Err(content_too_complex("inline-image dictionary")); + } + if self.bytes.get(self.position) != Some(&b'/') { + return Err(malformed_content( + "An inline-image dictionary key is not a name.", + )); + } + self.state.add_node()?; + let key = self.name()?; + let value = self + .scan_value(1)? + .ok_or_else(|| malformed_content("An inline-image value is missing."))?; + dictionary.push((key, value)); + } + + let separator = self + .bytes + .get(self.position) + .copied() + .filter(|byte| is_whitespace(*byte)) + .ok_or_else(|| malformed_content("Inline-image ID is not followed by whitespace."))?; + let one = self.position + 1; + let mut found = prove_inline_end(self.bytes, &dictionary, one)?; + if found.is_none() && separator == b'\r' && self.bytes.get(one) == Some(&b'\n') { + found = prove_inline_end(self.bytes, &dictionary, one + 1)?; + } + let (data_start, ei) = found.ok_or_else(|| { + malformed_content("An inline-image boundary could not be verified safely.") + })?; + self.position = ei + 2; + Ok(InlineSpan { + data_start, + ei_end: self.position, + }) + } +} + +#[derive(Debug)] +enum Value { + Number { value: f64, integer: bool }, + Name(Vec), + Array(Vec), + Bool(bool), + Other, +} + +fn prove_inline_end( + bytes: &[u8], + dictionary: &[(Vec, Value)], + start: usize, +) -> Result, AppError> { + let verify = |end: usize| ei_after(bytes, end).map(|ei| (start, ei)); + if let Some(length) = dictionary_uint(dictionary, &[b"L", b"Length"]) { + if length > MAX_INLINE_BYTES { + return Err(content_too_complex("inline-image size")); + } + if let Some(found) = start.checked_add(length).and_then(verify) { + return Ok(Some(found)); + } + } + + let filters = match image_filters(dictionary) { + Some(filters) => filters, + None => return Ok(None), + }; + if filters.is_empty() { + if let Some(size) = unfiltered_size(dictionary) { + if size > MAX_INLINE_BYTES as u64 { + return Err(content_too_complex("inline-image size")); + } + return Ok(start.checked_add(size as usize).and_then(verify)); + } + return Ok(None); + } + + let payload = bytes.get(start..).unwrap_or_default(); + match filters.first().map(Vec::as_slice) { + Some(b"Fl" | b"FlateDecode") if filters.len() == 1 => { + match source_content_decode::inflate_end(payload, MAX_INLINE_BYTES) { + Ok(consumed) => Ok(start.checked_add(consumed).and_then(verify)), + Err(DecodeError::TooLarge) => Err(content_too_complex("inline-image size")), + Err(_) => Ok(None), + } + } + Some(b"AHx" | b"ASCIIHexDecode") => { + Ok(find_terminator(payload, b">", start).and_then(&verify)) + } + Some(b"A85" | b"ASCII85Decode") => { + Ok(find_terminator(payload, b"~>", start).and_then(&verify)) + } + _ => Ok(None), + } +} + +fn find_terminator(payload: &[u8], needle: &[u8], start: usize) -> Option { + let search_len = payload + .len() + .min(MAX_INLINE_BYTES.saturating_add(needle.len())); + let relative = payload + .get(..search_len)? + .windows(needle.len()) + .position(|window| window == needle)?; + start.checked_add(relative)?.checked_add(needle.len()) +} + +fn ei_after(bytes: &[u8], mut position: usize) -> Option { + while bytes.get(position).is_some_and(|byte| is_whitespace(*byte)) { + position += 1; + } + if !bytes + .get(position..) + .is_some_and(|rest| rest.starts_with(b"EI")) + { + return None; + } + bytes + .get(position + 2) + .is_none_or(|byte| is_whitespace(*byte) || is_delimiter(*byte)) + .then_some(position) +} + +fn dictionary_get<'a>(dictionary: &'a [(Vec, Value)], keys: &[&[u8]]) -> Option<&'a Value> { + dictionary + .iter() + .rev() + .find(|(key, _)| keys.contains(&key.as_slice())) + .map(|(_, value)| value) +} + +fn dictionary_uint(dictionary: &[(Vec, Value)], keys: &[&[u8]]) -> Option { + match dictionary_get(dictionary, keys)? { + Value::Number { + value, + integer: true, + } if *value >= 0.0 => Some(*value as usize), + _ => None, + } +} + +fn image_filters(dictionary: &[(Vec, Value)]) -> Option>> { + match dictionary_get(dictionary, &[b"F", b"Filter"]) { + None => Some(Vec::new()), + Some(Value::Name(name)) => Some(vec![name.clone()]), + Some(Value::Array(items)) if items.len() <= 4 => items + .iter() + .map(|item| match item { + Value::Name(name) => Some(name.clone()), + _ => None, + }) + .collect(), + _ => None, + } +} + +fn unfiltered_size(dictionary: &[(Vec, Value)]) -> Option { + let width = dictionary_uint(dictionary, &[b"W", b"Width"])? as u64; + let height = dictionary_uint(dictionary, &[b"H", b"Height"])? as u64; + let image_mask = matches!( + dictionary_get(dictionary, &[b"IM", b"ImageMask"]), + Some(Value::Bool(true)) + ); + let (bits, components) = if image_mask { + (1u64, 1u64) + } else { + let bits = dictionary_uint(dictionary, &[b"BPC", b"BitsPerComponent"])? as u64; + if !matches!(bits, 1 | 2 | 4 | 8 | 16) { + return None; + } + let components = match dictionary_get(dictionary, &[b"CS", b"ColorSpace"])? { + Value::Name(name) => match name.as_slice() { + b"G" | b"DeviceGray" | b"I" | b"Indexed" => 1, + b"RGB" | b"DeviceRGB" => 3, + b"CMYK" | b"DeviceCMYK" => 4, + _ => return None, + }, + Value::Array(items) => match items.first() { + Some(Value::Name(name)) if matches!(name.as_slice(), b"I" | b"Indexed") => 1, + _ => return None, + }, + _ => return None, + }; + (bits, components) + }; + let row_bits = width.checked_mul(bits)?.checked_mul(components)?; + row_bits + .checked_add(7) + .map(|value| value / 8)? + .checked_mul(height) +} + +fn parse_number(token: &[u8]) -> Option { + let (negative, body) = match token.first() { + Some(b'+') => (false, token.get(1..)?), + Some(b'-') => (true, token.get(1..)?), + _ => (false, token), + }; + let dot = body.iter().position(|byte| *byte == b'.'); + let (integer, fraction) = match dot { + Some(position) => (body.get(..position)?, body.get(position + 1..)?), + None => (body, &b""[..]), + }; + if (integer.is_empty() && fraction.is_empty()) + || !integer.iter().chain(fraction).all(u8::is_ascii_digit) + { + return None; + } + let text = format!( + "{}{}.{}", + if negative { "-" } else { "" }, + if integer.is_empty() { + "0" + } else { + std::str::from_utf8(integer).ok()? + }, + if fraction.is_empty() { + "0" + } else { + std::str::from_utf8(fraction).ok()? + } + ); + text.parse::().ok().filter(|number| number.is_finite()) +} + +fn token_at(bytes: &[u8], at: usize, token: &[u8]) -> bool { + let Some(end) = at.checked_add(token.len()) else { + return false; + }; + bytes.get(at..end) == Some(token) + && bytes + .get(end) + .is_none_or(|byte| is_whitespace(*byte) || is_delimiter(*byte)) +} + +fn is_whitespace(byte: u8) -> bool { + matches!(byte, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn is_delimiter(byte: u8) -> bool { + matches!( + byte, + b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' + ) +} + +fn is_regular(byte: u8) -> bool { + !is_whitespace(byte) && !is_delimiter(byte) +} + +fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn malformed_content(detail: &str) -> AppError { + AppError::new( + "MALFORMED_CONTENT", + "Part of this PDF can't be read", + "OffPDF couldn't read this PDF's content stream safely.", + ) + .with_suggestion("Run it through Repair PDF, then try again.") + .with_details(detail) +} + +fn content_too_complex(detail: &str) -> AppError { + malformed_content(&format!("Content is too complex: {detail}.")) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::io::Write; + + fn flate(bytes: &[u8]) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + + #[test] + fn leaves_plain_content_borrowed_and_decodable() { + let bytes = b"q 1 0 0 1 72 400 cm BT /F1 12 Tf (Hello) Tj ET Q"; + let prepared = prepare(bytes).unwrap(); + assert!(prepared.sanitized.is_none()); + assert_eq!(prepared.inline_images, 0); + assert!(lopdf::content::Content::decode(prepared.bytes()).is_ok()); + } + + #[test] + fn rejects_container_and_literal_nesting_over_limits() { + let arrays = format!( + "{}{} q", + "[".repeat(MAX_NESTING + 1), + "]".repeat(MAX_NESTING + 1) + ); + assert_eq!( + prepare(arrays.as_bytes()).unwrap_err().code, + "MALFORMED_CONTENT" + ); + + let string = format!( + "({}{}) Tj", + "(".repeat(MAX_LITERAL_NESTING), + ")".repeat(MAX_LITERAL_NESTING) + ); + assert_eq!( + prepare(string.as_bytes()).unwrap_err().code, + "MALFORMED_CONTENT" + ); + } + + #[test] + fn rejects_operation_and_operand_budgets() { + let operations = "q ".repeat(MAX_OPERATIONS + 1); + assert!(prepare(operations.as_bytes()) + .unwrap_err() + .details + .unwrap_or_default() + .contains("operations")); + + let operands = format!("{}q", "1 ".repeat(MAX_OPERANDS_PER_OPERATOR + 1)); + assert!(prepare(operands.as_bytes()) + .unwrap_err() + .details + .unwrap_or_default() + .contains("operands")); + } + + #[test] + fn sanitizes_unfiltered_and_ascii_inline_images() { + for bytes in [ + &b"q BI /W 1 /H 1 /CS /G /BPC 8 ID x EI Q"[..], + &b"q BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 78> EI Q"[..], + &b"q BI /W 1 /H 1 /CS /G /BPC 8 /F /A85 ID GQ~> EI Q"[..], + ] { + let prepared = prepare(bytes).unwrap(); + assert_eq!(prepared.inline_images, 1); + assert!(!prepared.bytes().windows(2).any(|window| window == b"78")); + assert!(lopdf::content::Content::decode(prepared.bytes()).is_ok()); + } + } + + #[test] + fn sanitizes_multiple_inline_images_without_matching_text_or_comments() { + let bytes = concat!( + "% BI /F /DCT ID ignored EI\n", + "(BI ID EI) Tj ", + "BI /W 1 /H 1 /CS /G /BPC 8 ID x EI ", + "BI /W 1 /H 1 /CS /G /BPC 8 ID y EI" + ) + .as_bytes(); + let prepared = prepare(bytes).unwrap(); + assert_eq!(prepared.inline_images, 2); + assert!(lopdf::content::Content::decode(prepared.bytes()).is_ok()); + } + + #[test] + fn flate_proof_ignores_fake_ei_inside_payload() { + let payload = flate(b"pixels EI still pixels"); + let mut bytes = b"q BI /W 1 /H 1 /CS /G /BPC 8 /F /Fl ID ".to_vec(); + bytes.extend_from_slice(&payload); + bytes.extend_from_slice(b" EI Q"); + let prepared = prepare(&bytes).unwrap(); + assert_eq!(prepared.inline_images, 1); + assert!(lopdf::content::Content::decode(prepared.bytes()).is_ok()); + } + + #[test] + fn rejects_unproven_or_unterminated_inline_images() { + for bytes in [ + &b"BI /F /DCT ID fake EI Q"[..], + &b"BI /W 1 /H 1 /CS /G /BPC 8 ID x"[..], + ] { + assert_eq!(prepare(bytes).unwrap_err().code, "MALFORMED_CONTENT"); + } + } + + #[test] + fn rejects_malformed_tail_after_inline_image() { + let bytes = b"BI /W 1 /H 1 /BPC 8 /CS /G ID x EI (\\"; + assert_eq!(prepare(bytes).unwrap_err().code, "MALFORMED_CONTENT"); + } +} diff --git a/src-tauri/src/pdf_engine/source_content_preflight/headers.rs b/src-tauri/src/pdf_engine/source_content_preflight/headers.rs new file mode 100644 index 0000000..172093f --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content_preflight/headers.rs @@ -0,0 +1,147 @@ +//! Linear-time discovery of every `N G obj` header candidate lopdf can parse. + +use std::collections::BTreeMap; +use std::ops::Range; + +pub(super) struct Headers<'a> { + bytes: &'a [u8], + next: usize, + comment_runs: BTreeMap, + generations: BTreeMap>, +} + +impl<'a> Headers<'a> { + pub(super) fn new(bytes: &'a [u8]) -> Self { + Self { + bytes, + next: 0, + comment_runs: BTreeMap::new(), + generations: BTreeMap::new(), + } + } + + fn prune(&mut self, from: usize) { + while self + .comment_runs + .first_key_value() + .is_some_and(|(_, end)| *end <= from) + { + self.comment_runs.pop_first(); + } + while self + .generations + .first_key_value() + .is_some_and(|(at, _)| *at < from) + { + self.generations.pop_first(); + } + } + + fn space(&mut self, at: usize) -> (usize, bool) { + let mut index = at; + while let Some(&byte) = self.bytes.get(index) { + if byte == b'%' { + return (self.comment_space(index), true); + } + if !is_whitespace(byte) { + break; + } + index += 1; + } + (index, false) + } + + fn known_comment_end(&self, at: usize) -> Option { + self.comment_runs + .range(..=at) + .next_back() + .map(|(_, end)| *end) + .filter(|end| at < *end) + } + + fn comment_space(&mut self, at: usize) -> usize { + if let Some(end) = self.known_comment_end(at) { + return end; + } + let mut index = at; + let end = loop { + match self.bytes.get(index) { + Some(b'%') => { + if let Some(end) = self.known_comment_end(index) { + break end; + } + index = line_end(self.bytes, index); + } + Some(byte) if is_whitespace(*byte) => index += 1, + _ => break index, + } + }; + let inner: Vec = self + .comment_runs + .range(at..end) + .map(|(start, _)| *start) + .collect(); + for start in inner { + self.comment_runs.remove(&start); + } + self.comment_runs.insert(at, end); + end + } + + fn header_end(&mut self, run_end: usize) -> Option { + let (generation_at, via_comment) = self.space(run_end); + if let Some(found) = self.generations.get(&generation_at) { + return *found; + } + let generation_end = digits_end(self.bytes, generation_at); + let found = if generation_end == generation_at { + None + } else { + let (object_at, _) = self.space(generation_end); + let object_end = object_at.saturating_add(3); + (self.bytes.get(object_at..object_end) == Some(&b"obj"[..])).then_some(object_end) + }; + if via_comment { + self.generations.insert(generation_at, found); + } + found + } +} + +impl Iterator for Headers<'_> { + type Item = (Range, usize); + + fn next(&mut self) -> Option { + loop { + let relative = self + .bytes + .get(self.next..)? + .iter() + .position(u8::is_ascii_digit)?; + let start = self.next + relative; + let end = digits_end(self.bytes, start); + self.next = end; + self.prune(end); + if let Some(value_at) = self.header_end(end) { + return Some((start..end, value_at)); + } + } + } +} + +fn is_whitespace(byte: u8) -> bool { + matches!(byte, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn digits_end(bytes: &[u8], from: usize) -> usize { + from + bytes.get(from..).map_or(0, |rest| { + rest.iter().take_while(|c| c.is_ascii_digit()).count() + }) +} + +fn line_end(bytes: &[u8], from: usize) -> usize { + bytes + .get(from..) + .and_then(|rest| rest.iter().position(|c| matches!(c, b'\r' | b'\n'))) + .map_or(bytes.len(), |position| from + position) +} diff --git a/src-tauri/src/pdf_engine/source_content_preflight/xref.rs b/src-tauri/src/pdf_engine/source_content_preflight/xref.rs new file mode 100644 index 0000000..977de22 --- /dev/null +++ b/src-tauri/src/pdf_engine/source_content_preflight/xref.rs @@ -0,0 +1,952 @@ +//! Bounded parser for the cross-reference chain lopdf follows. + +use super::{file_too_complex, MAX_OBJECTS}; +use crate::error::AppError; +use crate::pdf_engine::source_content_decode::{self, DecodeError}; +use std::borrow::Cow; +use std::collections::HashSet; + +const MAX_XREF_CHAIN: usize = 64; +const XREF_TAIL_SEARCH_BYTES: usize = 2 << 10; +const XREF_STREAM_MAX_DECODED: usize = 64 << 20; +const XREF_STREAM_FIELD_WIDTH_MAX: i64 = 8; +const XREF_PREDICTOR_ROW_MAX: f64 = 1_024.0; +const DICT_TOKENS_MAX: usize = 100_000; +const DICT_NESTING_MAX: usize = 32; +const STRING_BYTES_MAX: usize = 1 << 20; +const STRING_PAREN_NESTING_MAX: usize = 100; + +pub(super) fn check(bytes: &[u8]) -> Result<(), AppError> { + let start = find_startxref(bytes).ok_or_else(|| invalid_xref("no startxref"))?; + let mut queue = vec![start]; + let mut visited = HashSet::new(); + + while let Some(offset) = queue.pop() { + if !visited.insert(offset) { + continue; + } + if visited.len() > MAX_XREF_CHAIN { + return Err(file_too_complex("cross-reference chain too long")); + } + + let at = skip_whitespace(bytes, offset); + let dictionary = if starts_with(bytes, at, b"xref") { + let trailer = table_trailer(bytes, at + 4).ok_or_else(|| invalid_xref("no trailer"))?; + scan_dictionary(bytes, trailer + 7)?.0 + } else { + check_xref_stream(bytes, at)? + }; + + if let Some(offset) = dictionary_uint(&dictionary, b"XRefStm") { + queue.push(offset); + } + if dictionary_get(&dictionary, b"Prev").is_some() { + let offset = + dictionary_uint(&dictionary, b"Prev").ok_or_else(|| invalid_xref("bad /Prev"))?; + queue.push(offset); + } + } + Ok(()) +} + +fn find_startxref(bytes: &[u8]) -> Option { + let tail_start = bytes.len().saturating_sub(XREF_TAIL_SEARCH_BYTES); + let tail = bytes.get(tail_start..)?; + let relative = tail.windows(9).rposition(|window| window == b"startxref")?; + let (offset, _) = parse_unsigned(bytes, skip_whitespace(bytes, tail_start + relative + 9))?; + usize::try_from(offset) + .ok() + .filter(|offset| *offset < bytes.len()) +} + +fn table_trailer(bytes: &[u8], from: usize) -> Option { + let mut index = from; + loop { + match *bytes.get(index)? { + byte if byte.is_ascii_digit() + || byte == b'n' + || byte == b'f' + || is_whitespace(byte) => + { + index += 1; + } + b'%' => { + index += bytes + .get(index..)? + .iter() + .take_while(|byte| !matches!(byte, b'\r' | b'\n')) + .count(); + } + _ => break, + } + } + starts_with(bytes, index, b"trailer").then_some(index) +} + +fn check_xref_stream(bytes: &[u8], at: usize) -> Result { + let invalid = |message: &str| invalid_xref(&format!("{message} at byte {at}")); + let (_, index) = parse_unsigned(bytes, at).ok_or_else(|| invalid("no xref"))?; + let (_, index) = + parse_unsigned(bytes, skip_whitespace(bytes, index)).ok_or_else(|| invalid("no xref"))?; + let index = skip_whitespace(bytes, index); + if !starts_with(bytes, index, b"obj") { + return Err(invalid("no xref")); + } + + let (dictionary, end) = scan_dictionary(bytes, index + 3)?; + if !matches!(dictionary_get(&dictionary, b"Type"), Some(Value::Name(name)) if name == b"XRef") { + return Err(invalid("not an xref stream")); + } + let stream = skip_whitespace(bytes, end); + if !starts_with(bytes, stream, b"stream") { + return Err(invalid("xref stream without data")); + } + + let mut data_start = stream + 6; + if bytes.get(data_start) == Some(&b'\r') { + data_start += 1; + } + if bytes.get(data_start) == Some(&b'\n') { + data_start += 1; + } + let by_length = dictionary_uint(&dictionary, b"Length") + .and_then(|length| data_start.checked_add(length)) + .filter(|end| starts_with(bytes, skip_whitespace(bytes, *end), b"endstream")); + let data_end = match by_length { + Some(end) => end, + None => find_from(bytes, data_start, b"endstream") + .ok_or_else(|| invalid("unterminated xref stream"))?, + }; + let payload = bytes.get(data_start..data_end).unwrap_or_default(); + decode_xref_payload(&dictionary, payload)?; + check_xref_fields(&dictionary, at)?; + Ok(dictionary) +} + +fn decode_xref_payload(dictionary: &Dictionary, payload: &[u8]) -> Result<(), AppError> { + let filters: Vec<&[u8]> = match dictionary_get(dictionary, b"Filter") { + None => Vec::new(), + Some(Value::Name(name)) => vec![name], + Some(Value::Array(items)) if items.len() <= 4 => items + .iter() + .map(|item| match item { + Value::Name(name) => Ok(name.as_slice()), + _ => Err(file_too_complex("xref stream filter")), + }) + .collect::>()?, + Some(_) => return Err(file_too_complex("xref stream filter")), + }; + + let mut data = Cow::Borrowed(payload); + for filter in filters { + let decoded = match filter { + b"FlateDecode" | b"Fl" => { + source_content_decode::inflate_capped(&data, XREF_STREAM_MAX_DECODED) + } + b"ASCIIHexDecode" | b"AHx" => { + source_content_decode::ascii_hex_decode(&data, XREF_STREAM_MAX_DECODED) + } + b"ASCII85Decode" | b"A85" => { + source_content_decode::ascii85_decode(&data, XREF_STREAM_MAX_DECODED) + } + _ => return Err(file_too_complex("xref stream filter")), + }; + data = Cow::Owned(decoded.map_err(|error| match error { + DecodeError::TooLarge => file_too_complex("xref stream too large"), + other => invalid_xref(&format!("xref stream data: {other:?}")), + })?); + } + if data.len() > XREF_STREAM_MAX_DECODED { + return Err(file_too_complex("xref stream too large")); + } + Ok(()) +} + +fn check_xref_fields(dictionary: &Dictionary, at: usize) -> Result<(), AppError> { + let invalid = |message: &str| invalid_xref(&format!("{message} at byte {at}")); + let too_large = |message: &str| file_too_complex(&format!("{message} at byte {at}")); + let integers = |value: Option<&Value>| -> Option> { + match value? { + Value::Array(items) => items + .iter() + .map(|item| match item { + Value::Number { value, integer } if *integer && value.abs() < 1e15 => { + Some(*value as i64) + } + _ => None, + }) + .collect(), + _ => None, + } + }; + + let widths = + integers(dictionary_get(dictionary, b"W")).ok_or_else(|| invalid("xref stream /W"))?; + if widths.len() != 3 + || widths + .iter() + .any(|width| !(0..=XREF_STREAM_FIELD_WIDTH_MAX).contains(width)) + || widths.iter().sum::() == 0 + { + return Err(invalid("xref stream /W")); + } + + let index = match dictionary_get(dictionary, b"Index") { + Some(value) => integers(Some(value)).ok_or_else(|| invalid("xref stream /Index"))?, + None => vec![ + 0, + dictionary_uint(dictionary, b"Size").ok_or_else(|| invalid("xref stream /Size"))? + as i64, + ], + }; + if index.len() % 2 != 0 || index.iter().any(|value| *value < 0) { + return Err(invalid("xref stream /Index")); + } + let count = index + .iter() + .skip(1) + .step_by(2) + .try_fold(0i64, |sum, value| sum.checked_add(*value)); + if count.is_none_or(|count| count > MAX_OBJECTS as i64) { + return Err(too_large("xref stream lists too many objects")); + } + if !predictor_row_ok(dictionary) { + return Err(too_large("xref stream predictor row too large")); + } + Ok(()) +} + +fn predictor_row_ok(dictionary: &Dictionary) -> bool { + let Some(Value::Dictionary(parameters)) = dictionary_get(dictionary, b"DecodeParms") else { + return true; + }; + let number = |key: &[u8], default: f64| match dictionary_get(parameters, key) { + Some(Value::Number { value, .. }) => *value, + _ => default, + }; + if !(10.0..=15.0).contains(&number(b"Predictor", 1.0)) { + return true; + } + let row = number(b"Columns", 1.0).max(1.0) + * number(b"Colors", 1.0).max(1.0) + * number(b"BitsPerComponent", 8.0).max(8.0) + / 8.0; + row.is_finite() && row <= XREF_PREDICTOR_ROW_MAX +} + +type Dictionary = Vec<(Vec, Value)>; + +#[derive(Debug)] +enum Value { + Number { value: f64, integer: bool }, + Name(Vec), + Array(Vec), + Dictionary(Dictionary), + Other, +} + +fn dictionary_get<'a>(dictionary: &'a Dictionary, key: &[u8]) -> Option<&'a Value> { + dictionary + .iter() + .rev() + .find(|(candidate, _)| candidate == key) + .map(|(_, value)| value) +} + +fn dictionary_uint(dictionary: &Dictionary, key: &[u8]) -> Option { + match dictionary_get(dictionary, key)? { + Value::Number { + value, + integer: true, + } if *value >= 0.0 && *value < 1e15 => Some(*value as usize), + _ => None, + } +} + +#[derive(Debug)] +enum Token { + Value(Value), + Word(bool), + DictionaryOpen, + DictionaryClose, + ArrayOpen, + ArrayClose, +} + +struct Reader<'a> { + bytes: &'a [u8], + position: usize, +} + +impl Reader<'_> { + fn next(&mut self) -> Result, ParseError> { + self.skip_space(); + let start = self.position; + let Some(byte) = self.bytes.get(start).copied() else { + return Ok(None); + }; + let token = match byte { + b'(' => { + self.literal_string()?; + Token::Value(Value::Other) + } + b'<' if self.bytes.get(start + 1) == Some(&b'<') => { + self.position += 2; + Token::DictionaryOpen + } + b'<' => { + self.hex_string()?; + Token::Value(Value::Other) + } + b'>' if self.bytes.get(start + 1) == Some(&b'>') => { + self.position += 2; + Token::DictionaryClose + } + b'>' | b')' | b'{' | b'}' => { + return Err(ParseError::Malformed(start, "stray delimiter")); + } + b'[' => { + self.position += 1; + Token::ArrayOpen + } + b']' => { + self.position += 1; + Token::ArrayClose + } + b'/' => Token::Value(Value::Name(self.name()?)), + _ => self.regular()?, + }; + Ok(Some(token)) + } + + fn skip_space(&mut self) { + loop { + match self.bytes.get(self.position) { + Some(byte) if is_whitespace(*byte) => self.position += 1, + Some(b'%') => { + self.position += self.bytes.get(self.position..).map_or(0, |rest| { + rest.iter() + .take_while(|byte| !matches!(byte, b'\r' | b'\n')) + .count() + }); + } + _ => break, + } + } + } + + fn regular(&mut self) -> Result { + let start = self.position; + while self + .bytes + .get(self.position) + .is_some_and(|byte| is_regular(*byte)) + { + self.position += 1; + } + let text = self.bytes.get(start..self.position).unwrap_or_default(); + if text.is_empty() { + return Err(ParseError::Malformed(start, "empty token")); + } + if text == b"true" || text == b"false" || text == b"null" { + return Ok(Token::Value(Value::Other)); + } + let numeric = text + .first() + .is_some_and(|byte| byte.is_ascii_digit() || matches!(byte, b'+' | b'-' | b'.')); + if numeric { + let value = parse_number(text).ok_or(ParseError::Malformed(start, "bad number"))?; + return Ok(Token::Value(Value::Number { + value, + integer: !text.contains(&b'.'), + })); + } + Ok(Token::Word(text == b"R")) + } + + fn name(&mut self) -> Result, ParseError> { + self.position += 1; + let mut output = Vec::new(); + while let Some(byte) = self + .bytes + .get(self.position) + .copied() + .filter(|byte| is_regular(*byte)) + { + let escaped = ( + self.bytes + .get(self.position + 1) + .and_then(|byte| hex_digit(*byte)), + self.bytes + .get(self.position + 2) + .and_then(|byte| hex_digit(*byte)), + ); + match (byte, escaped) { + (b'#', (Some(high), Some(low))) => { + output.push((high << 4) | low); + self.position += 3; + } + _ => { + output.push(byte); + self.position += 1; + } + } + if output.len() > STRING_BYTES_MAX { + return Err(ParseError::TooComplex("xref dictionary name")); + } + } + Ok(output) + } + + fn literal_string(&mut self) -> Result<(), ParseError> { + let start = self.position; + self.position += 1; + let mut depth = 1usize; + let mut output_bytes = 0usize; + loop { + let Some(byte) = self.bytes.get(self.position).copied() else { + return Err(ParseError::Malformed(start, "unterminated string")); + }; + self.position += 1; + match byte { + b'\\' => { + let Some(escaped) = self.bytes.get(self.position).copied() else { + return Err(ParseError::Malformed(start, "unterminated string")); + }; + self.position += 1; + match escaped { + b'0'..=b'7' => { + for _ in 0..2 { + if matches!(self.bytes.get(self.position), Some(b'0'..=b'7')) { + self.position += 1; + } else { + break; + } + } + output_bytes += 1; + } + b'\r' => { + if self.bytes.get(self.position) == Some(&b'\n') { + self.position += 1; + } + } + b'\n' => {} + _ => output_bytes += 1, + } + } + b'(' => { + depth += 1; + if depth > STRING_PAREN_NESTING_MAX { + return Err(ParseError::TooComplex("xref dictionary string nesting")); + } + output_bytes += 1; + } + b')' => { + depth -= 1; + if depth == 0 { + break; + } + output_bytes += 1; + } + b'\r' => { + if self.bytes.get(self.position) == Some(&b'\n') { + self.position += 1; + } + output_bytes += 1; + } + _ => output_bytes += 1, + } + if output_bytes > STRING_BYTES_MAX { + return Err(ParseError::TooComplex("xref dictionary string")); + } + } + Ok(()) + } + + fn hex_string(&mut self) -> Result<(), ParseError> { + let start = self.position; + self.position += 1; + let mut digits = 0usize; + loop { + let Some(byte) = self.bytes.get(self.position).copied() else { + return Err(ParseError::Malformed(start, "unterminated hex string")); + }; + self.position += 1; + if byte == b'>' { + return Ok(()); + } + if is_whitespace(byte) { + continue; + } + if hex_digit(byte).is_none() { + return Err(ParseError::Malformed(start, "bad hex string")); + } + digits += 1; + if digits.div_ceil(2) > STRING_BYTES_MAX { + return Err(ParseError::TooComplex("xref dictionary string")); + } + } + } +} + +struct Frame { + dictionary: bool, + values: Vec, +} + +fn scan_dictionary(bytes: &[u8], start: usize) -> Result<(Dictionary, usize), AppError> { + let mut reader = Reader { + bytes, + position: start, + }; + if !matches!( + reader.next().map_err(parse_error)?, + Some(Token::DictionaryOpen) + ) { + return Err(invalid_xref(&format!( + "dictionary expected at byte {start}" + ))); + } + let mut token_count = 1usize; + let mut frames = vec![Frame { + dictionary: true, + values: Vec::new(), + }]; + + loop { + let token = reader + .next() + .map_err(parse_error)? + .ok_or_else(|| invalid_xref("unterminated dictionary"))?; + token_count = token_count.saturating_add(1); + if token_count > DICT_TOKENS_MAX { + return Err(file_too_complex("xref dictionary tokens")); + } + + match token { + Token::DictionaryOpen | Token::ArrayOpen => { + if frames.len() >= DICT_NESTING_MAX { + return Err(file_too_complex("xref dictionary nesting")); + } + frames.push(Frame { + dictionary: matches!(token, Token::DictionaryOpen), + values: Vec::new(), + }); + } + Token::DictionaryClose | Token::ArrayClose => { + let expect_dictionary = matches!(token, Token::DictionaryClose); + let frame = frames + .pop() + .ok_or_else(|| invalid_xref("stray dictionary delimiter"))?; + if frame.dictionary != expect_dictionary { + return Err(invalid_xref("mismatched dictionary delimiter")); + } + let value = finish_frame(frame)?; + if let Some(parent) = frames.last_mut() { + parent.values.push(value); + } else { + return match value { + Value::Dictionary(dictionary) => Ok((dictionary, reader.position)), + _ => Err(invalid_xref("dictionary expected")), + }; + } + } + Token::Value(value) => frames + .last_mut() + .ok_or_else(|| invalid_xref("value outside dictionary"))? + .values + .push(value), + Token::Word(true) => { + let values = &mut frames + .last_mut() + .ok_or_else(|| invalid_xref("reference outside dictionary"))? + .values; + let generation = values.pop(); + let object = values.pop(); + if !matches!(generation, Some(Value::Number { integer: true, .. })) + || !matches!(object, Some(Value::Number { integer: true, .. })) + { + return Err(invalid_xref("bad object reference")); + } + values.push(Value::Other); + } + Token::Word(false) => frames + .last_mut() + .ok_or_else(|| invalid_xref("keyword outside dictionary"))? + .values + .push(Value::Other), + } + } +} + +fn finish_frame(frame: Frame) -> Result { + if !frame.dictionary { + return Ok(Value::Array(frame.values)); + } + let mut entries = Vec::with_capacity(frame.values.len() / 2); + let mut values = frame.values.into_iter(); + while let Some(key) = values.next() { + let Value::Name(key) = key else { + return Err(invalid_xref("xref dictionary key is not a name")); + }; + let value = values + .next() + .ok_or_else(|| invalid_xref("xref dictionary value is missing"))?; + entries.push((key, value)); + } + Ok(Value::Dictionary(entries)) +} + +#[derive(Debug)] +enum ParseError { + Malformed(usize, &'static str), + TooComplex(&'static str), +} + +fn parse_error(error: ParseError) -> AppError { + match error { + ParseError::Malformed(at, what) => invalid_xref(&format!("{what} at byte {at}")), + ParseError::TooComplex(what) => file_too_complex(what), + } +} + +fn parse_number(bytes: &[u8]) -> Option { + let (negative, body) = match bytes.first() { + Some(b'+') => (false, bytes.get(1..)?), + Some(b'-') => (true, bytes.get(1..)?), + _ => (false, bytes), + }; + let dot = body.iter().position(|byte| *byte == b'.'); + let (integer, fraction) = match dot { + Some(position) => (body.get(..position)?, body.get(position + 1..)?), + None => (body, &b""[..]), + }; + if (integer.is_empty() && fraction.is_empty()) + || !integer.iter().chain(fraction).all(u8::is_ascii_digit) + { + return None; + } + let text = format!( + "{}{}.{}", + if negative { "-" } else { "" }, + if integer.is_empty() { + "0" + } else { + std::str::from_utf8(integer).ok()? + }, + if fraction.is_empty() { + "0" + } else { + std::str::from_utf8(fraction).ok()? + } + ); + text.parse::().ok().filter(|value| value.is_finite()) +} + +fn parse_unsigned(bytes: &[u8], at: usize) -> Option<(u64, usize)> { + let digits = bytes + .get(at..)? + .iter() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + if digits == 0 || digits > 19 { + return None; + } + let text = std::str::from_utf8(bytes.get(at..at + digits)?).ok()?; + Some((text.parse().ok()?, at + digits)) +} + +fn find_from(haystack: &[u8], from: usize, needle: &[u8]) -> Option { + let first = *needle.first()?; + let mut index = from; + while let Some(relative) = haystack + .get(index..)? + .iter() + .position(|byte| *byte == first) + { + let position = index + relative; + if haystack.get(position..position.checked_add(needle.len())?) == Some(needle) { + return Some(position); + } + index = position + 1; + } + None +} + +fn skip_whitespace(bytes: &[u8], mut index: usize) -> usize { + while bytes.get(index).is_some_and(|byte| is_whitespace(*byte)) { + index += 1; + } + index +} + +fn starts_with(bytes: &[u8], at: usize, expected: &[u8]) -> bool { + bytes + .get(at..) + .is_some_and(|rest| rest.starts_with(expected)) +} + +fn is_whitespace(byte: u8) -> bool { + matches!(byte, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn is_delimiter(byte: u8) -> bool { + matches!( + byte, + b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' + ) +} + +fn is_regular(byte: u8) -> bool { + !is_whitespace(byte) && !is_delimiter(byte) +} + +fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn invalid_xref(detail: &str) -> AppError { + AppError::new( + "INVALID_PDF", + "The selected file is not a valid PDF", + "OffPDF could not read this PDF's cross-reference data.", + ) + .with_suggestion("Make sure the file is a real PDF and is not corrupted.") + .with_details(detail) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::fmt::Write as _; + use std::io::Write as _; + + const OBJECTS: [&str; 3] = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>", + ]; + + struct File { + bytes: Vec, + offsets: Vec, + } + + impl File { + fn new() -> Self { + let mut file = Self { + bytes: b"%PDF-1.7\n".to_vec(), + offsets: Vec::new(), + }; + for (index, body) in OBJECTS.iter().enumerate() { + file.offsets.push(file.bytes.len()); + file.bytes + .extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes()); + } + file + } + + fn xref_stream(&mut self, id: u32, before: &str, after: &str) -> usize { + let mut rows = vec![0, 0, 0, 0, 0, 0xff, 0xff]; + for offset in &self.offsets { + rows.push(1); + rows.extend_from_slice(&u32::try_from(*offset).unwrap().to_be_bytes()); + rows.extend_from_slice(&[0, 0]); + } + let at = self.bytes.len(); + self.bytes.extend_from_slice( + format!( + "{id} 0 obj\n<< {before} /Type /XRef /Size 4 /W [1 4 2] {after} /Length {} >>\nstream\n", + rows.len() + ) + .as_bytes(), + ); + self.bytes.extend_from_slice(&rows); + self.bytes.extend_from_slice(b"\nendstream\nendobj\n"); + at + } + + fn table(&mut self, after: &str) -> usize { + let at = self.bytes.len(); + let mut table = "xref\n0 4\n0000000000 65535 f \n".to_string(); + for offset in &self.offsets { + let _ = writeln!(table, "{offset:010} 00000 n "); + } + table.push_str(after); + self.bytes.extend_from_slice(table.as_bytes()); + at + } + + fn finish(mut self, start: usize) -> Vec { + self.bytes + .extend_from_slice(format!("startxref\n{start}\n%%EOF\n").as_bytes()); + self.bytes + } + } + + fn main_stream(before: &str, after: &str) -> Vec { + let mut file = File::new(); + let xref = file.xref_stream(9, before, &format!("/Root 1 0 R {after}")); + file.finish(xref) + } + + fn assert_error(bytes: &[u8], code: &str, detail: &str) { + let error = check(bytes).unwrap_err(); + assert_eq!(error.code, code, "{:?}", error.details); + assert!(error.details.unwrap_or_default().contains(detail)); + } + + #[test] + fn accepts_classic_table_and_xref_stream() { + let mut classic = File::new(); + let table = classic.table("% producer note\ntrailer\n<< /Size 4 /Root 1 0 R >>\n"); + let classic = classic.finish(table); + check(&classic).unwrap(); + assert_eq!( + lopdf::Document::load_mem(&classic) + .unwrap() + .get_pages() + .len(), + 1 + ); + + let stream = main_stream("", ""); + check(&stream).unwrap(); + assert_eq!( + lopdf::Document::load_mem(&stream) + .unwrap() + .get_pages() + .len(), + 1 + ); + } + + #[test] + fn accepts_bounded_flate_xref_payload() { + let mut file = File::new(); + let mut rows = vec![0, 0, 0, 0, 0, 0xff, 0xff]; + for offset in &file.offsets { + rows.push(1); + rows.extend_from_slice(&u32::try_from(*offset).unwrap().to_be_bytes()); + rows.extend_from_slice(&[0, 0]); + } + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&rows).unwrap(); + let compressed = encoder.finish().unwrap(); + let at = file.bytes.len(); + file.bytes.extend_from_slice( + format!( + "9 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 2] /Filter /FlateDecode /Length {} >>\nstream\n", + compressed.len() + ) + .as_bytes(), + ); + file.bytes.extend_from_slice(&compressed); + file.bytes.extend_from_slice(b"\nendstream\nendobj\n"); + let bytes = file.finish(at); + check(&bytes).unwrap(); + assert_eq!( + lopdf::Document::load_mem(&bytes).unwrap().get_pages().len(), + 1 + ); + } + + #[test] + fn duplicate_fields_use_the_last_value() { + assert_error( + &main_stream("", "/Size 3000000"), + "FILE_TOO_COMPLEX", + "too many objects", + ); + check(&main_stream("/Size 3000000", "")).unwrap(); + } + + #[test] + fn rejects_real_xref_array_fields() { + assert_error(&main_stream("", "/Index [0 4.0]"), "INVALID_PDF", "/Index"); + assert_error(&main_stream("", "/W [1 4.0 2]"), "INVALID_PDF", "/W"); + } + + #[test] + fn rejects_large_predictor_rows() { + assert_error( + &main_stream( + "", + "/DecodeParms << /Predictor 12 /Columns 2000 /Colors 2000 >>", + ), + "FILE_TOO_COMPLEX", + "predictor row", + ); + } + + #[test] + fn sums_xref_index_counts_without_overflow() { + let index = "0 999999999999999 ".repeat(10_000); + assert_error( + &main_stream("", &format!("/Index [{index}]")), + "FILE_TOO_COMPLEX", + "too many objects", + ); + } + + #[test] + fn follows_xrefstm_from_xref_streams() { + let mut file = File::new(); + let secondary = file.xref_stream( + 10, + "", + "/DecodeParms << /Predictor 12 /Columns 2000 /Colors 2000 >>", + ); + let main = file.xref_stream(11, "", &format!("/Root 1 0 R /XRefStm {secondary}")); + assert_error(&file.finish(main), "FILE_TOO_COMPLEX", "predictor row"); + } + + #[test] + fn rejects_overlong_xref_chains() { + let mut file = File::new(); + let mut previous = None; + for id in 10..10 + MAX_XREF_CHAIN as u32 + 1 { + let suffix = previous.map_or_else( + || "/Root 1 0 R".to_string(), + |offset| format!("/Prev {offset}"), + ); + previous = Some(file.xref_stream(id, "", &suffix)); + } + assert_error( + &file.finish(previous.unwrap()), + "FILE_TOO_COMPLEX", + "chain too long", + ); + } + + #[test] + fn ignores_trailer_text_inside_table_comments() { + let mut file = File::new(); + let bad = file.xref_stream( + 10, + "", + "/DecodeParms << /Predictor 12 /Columns 2000 /Colors 2000 >>", + ); + let table = file.table(&format!( + "%trailer << /Size 4 /Root 1 0 R >>\ntrailer\n<< /Size 4 /Root 1 0 R /Prev {bad} >>\n" + )); + assert_error(&file.finish(table), "FILE_TOO_COMPLEX", "predictor row"); + } + + #[test] + fn rejects_deep_xref_dictionaries_before_lopdf() { + let mut file = File::new(); + let nesting = format!("{}{}", "[".repeat(40), "]".repeat(40)); + let table = file.table(&format!( + "trailer\n<< /Size 4 /Root 1 0 R /Deep {nesting} >>\n" + )); + assert_error(&file.finish(table), "FILE_TOO_COMPLEX", "nesting"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3affb51..8b4395d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OffPDF", - "version": "0.3.2", + "version": "0.4.0", "identifier": "app.offpdf.desktop", "build": { "frontendDist": "../dist", diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 4a3fda2..f98ff0f 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -40,18 +40,20 @@ export function AppShell() { if (!isTauriRuntime()) return; let cancelled = false; let unlisten: (() => void) | undefined; + let intakeQueue = Promise.resolve(); - const apply = async (paths: string[]) => { - if (!paths.length || cancelled) return; - // Neutral workspace intake only — do not start a tool or change route. - const result = await useWorkspace.getState().addPaths(paths); - if (!cancelled) reportIntake(toast, result); + const apply = (paths: string[]) => { + intakeQueue = intakeQueue.then(async () => { + if (!paths.length || cancelled) return; + const result = await useWorkspace.getState().addPaths(paths, { dedupe: true }); + if (!cancelled) reportIntake(toast, result); + }); }; void (async () => { try { const stop = await onOpenedPaths((paths) => { - void apply(paths); + apply(paths); }); if (cancelled) { stop(); @@ -59,7 +61,8 @@ export function AppShell() { } unlisten = stop; const pending = await takeOpenedPaths(); - await apply(pending); + apply(pending); + await intakeQueue; } catch { // Browser / missing IPC — in-app picker still works. } diff --git a/src/state/workspaceStore.test.ts b/src/state/workspaceStore.test.ts index 0d34dcd..5f4d3f1 100644 --- a/src/state/workspaceStore.test.ts +++ b/src/state/workspaceStore.test.ts @@ -121,6 +121,29 @@ describe("workspace image imports", () => { }); describe("OS-open workspace intake", () => { + it("deduplicates repeated OS-open paths while preserving first-seen order", async () => { + const result = await useWorkspace + .getState() + .addPaths( + [ + "/tmp/second file.pdf", + "/tmp/first.pdf", + "/tmp/second file.pdf", + "/tmp/第三.pdf", + "/tmp/first.pdf", + ], + { dedupe: true }, + ); + + expect(result.added).toBe(3); + expect(useWorkspace.getState().files.map((file) => file.path)).toEqual([ + "/tmp/second file.pdf", + "/tmp/first.pdf", + "/tmp/第三.pdf", + ]); + expect(commands.getFileInfo).toHaveBeenCalledTimes(3); + }); + it("rejects OS-open of report.pdf.exe and .zip without adding them", async () => { const result = await useWorkspace .getState() diff --git a/src/state/workspaceStore.ts b/src/state/workspaceStore.ts index 05e8674..9ce453a 100644 --- a/src/state/workspaceStore.ts +++ b/src/state/workspaceStore.ts @@ -35,6 +35,10 @@ export interface AddResult { errors: string[]; } +export interface AddPathsOptions { + dedupe?: boolean; +} + function baseName(p: string): string { return p.split(/[\\/]/).pop() || p; } @@ -45,7 +49,7 @@ interface WorkspaceState { activeIndex: number; loading: boolean; - addPaths: (paths: string[]) => Promise; + addPaths: (paths: string[], options?: AddPathsOptions) => Promise; removeAt: (index: number) => void; clear: () => void; reorder: (from: number, to: number) => void; @@ -57,10 +61,10 @@ export const useWorkspace = create((set, get) => ({ activeIndex: 0, loading: false, - addPaths: async (paths) => { - const supported = paths.filter((p) => SUPPORTED_RE.test(p)); - // True if some dropped/picked items were not a supported type at all. - const notPdf = supported.length < paths.length; + addPaths: async (paths, options) => { + const inputPaths = options?.dedupe ? [...new Set(paths)] : paths; + const supported = inputPaths.filter((p) => SUPPORTED_RE.test(p)); + const notPdf = supported.length < inputPaths.length; if (supported.length === 0) return { added: 0, invalid: [], notPdf, errors: [] }; activeAddOperations += 1; @@ -77,6 +81,10 @@ export const useWorkspace = create((set, get) => ({ for (const p of supported) { try { if (isImagePath(p)) { + if (options?.dedupe && existing.has(p)) { + infos.push(null); + continue; + } let pdfPath = convertedImages.get(p); if (!pdfPath) { pdfPath = await imageToPdfSerial(p); @@ -84,6 +92,10 @@ export const useWorkspace = create((set, get) => ({ } infos.push(await getFileInfo(pdfPath)); } else if (isOfficePath(p)) { + if (options?.dedupe && existing.has(p)) { + infos.push(null); + continue; + } infos.push(await getFileInfo(await officeToPdf(p))); } else if (existing.has(p)) { infos.push(null); // already loaded