diff --git a/CHANGELOG.md b/CHANGELOG.md index df3923e..ec39501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable project changes should be documented here. ## Unreleased +### Added + +- Edit PDF: staged output is checked with `qpdf --check` and reopened for page boxes and catalog data before the destination file is replaced. A failed check leaves the original and any existing destination untouched. + ## 0.3.0 - 2026-08-15 ### Added diff --git a/src-tauri/src/pdf_engine/edit_overlay.rs b/src-tauri/src/pdf_engine/edit_overlay.rs index 517dd6a..ffe6370 100644 --- a/src-tauri/src/pdf_engine/edit_overlay.rs +++ b/src-tauri/src/pdf_engine/edit_overlay.rs @@ -4,8 +4,11 @@ use crate::error::AppError; use crate::models::{JobHandle, PageGroup}; -use crate::pdf_engine::{crop, edit_image}; -use crate::utils::process::run_qpdf; +use crate::pdf_engine::validate_output::{ + catalog_flags_from_doc, validate_staged_pdf, OutputSnapshot, PageSnapshot, +}; +use crate::pdf_engine::{crop, edit_image, qpdf}; +use crate::utils::process::{run_qpdf, run_tracked}; use crate::utils::safe_output; use crate::utils::temp; use lopdf::{Document, Object}; @@ -1088,7 +1091,7 @@ pub(crate) fn build_edit_overlay_args( Ok(args) } -/// Build the overlay and run qpdf via `run`. Used by the Tauri command and tests. +/// Build the overlay and run qpdf via `run`. Used by tests (system/`"qpdf"`). pub(crate) fn export_edit_pdf_with_runner( groups: &[PageGroup], output: &str, @@ -1097,6 +1100,28 @@ pub(crate) fn export_edit_pdf_with_runner( work: &Path, unique: &str, cancel: Option<&AtomicBool>, + run: F, +) -> Result, AppError> +where + F: FnMut(&[String]) -> Result<(), AppError>, +{ + let exe = qpdf::resolve_qpdf_standalone(); + export_edit_pdf_with_check_exe( + groups, output, document, font_path, work, unique, cancel, &exe, None, run, + ) +} + +/// Same as [`export_edit_pdf_with_runner`], with an explicit `qpdf --check` binary. +fn export_edit_pdf_with_check_exe( + groups: &[PageGroup], + output: &str, + document: &EditDocumentIn, + font_path: &Path, + work: &Path, + unique: &str, + cancel: Option<&AtomicBool>, + qpdf_check: &Path, + handle: Option<&Arc>, mut run: F, ) -> Result, AppError> where @@ -1118,6 +1143,7 @@ where let tmp_str = tmp.to_string_lossy().to_string(); let overlay = work.join("overlay.pdf"); let overlay_str = overlay.to_string_lossy().to_string(); + let mut gate_passed = false; let result = (|| -> Result, AppError> { let (geoms, counts) = collect_source_pages(groups)?; if geoms.is_empty() { @@ -1138,17 +1164,70 @@ where run(&[tmp_str.clone(), cleaned_str.clone()])?; safe_output::replace_file(&cleaned, &tmp)?; } + let snapshot = output_snapshot_from_source(&geoms, Path::new(&groups[0].path))?; + validate_staged_pdf(&tmp, &snapshot, cancel, |args| { + run_qpdf_check_argv(qpdf_check, args, handle) + })?; + gate_passed = true; safe_output::replace_file(&tmp, dest)?; Ok(vec![output.to_string()]) })(); - // On success the temp was renamed away. On failure leave a dest-sibling - // tmp in place so a failed Windows replace still has a recoverable file. - if result.is_ok() && tmp.exists() { + // Keep tmp only if replace_file failed after a passed gate (Windows recover). + // Spawn/validate errors (and leftover success tmp) delete the sibling. + if !(gate_passed && result.is_err()) && tmp.exists() { let _ = std::fs::remove_file(&tmp); } result } +fn output_snapshot_from_source( + geoms: &[OverlayPageGeom], + primary: &Path, +) -> Result { + let doc = Document::load(primary) + .map_err(|e| AppError::engine_failed(format!("Could not read the PDF: {e}")))?; + Ok(OutputSnapshot { + pages: geoms + .iter() + .map(|g| PageSnapshot { + media_box: g.media, + crop_box: g.crop, + trim_box: g.trim, + rotate: g.rotate, + user_unit: g.user_unit, + }) + .collect(), + catalog: catalog_flags_from_doc(&doc), + }) +} + +fn run_qpdf_check_argv( + exe: &Path, + args: &[String], + handle: Option<&Arc>, +) -> Result<(i32, String), AppError> { + let mut cmd = std::process::Command::new(exe); + cmd.args(args); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + if let Some(h) = handle { + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::piped()); + let (status, stderr) = run_tracked(h, cmd)?; + return Ok((status.and_then(|s| s.code()).unwrap_or(1), stderr)); + } + let output = cmd + .output() + .map_err(|e| AppError::io("qpdf --check failed to start", e))?; + Ok(( + output.status.code().unwrap_or(1), + String::from_utf8_lossy(&output.stderr).into_owned(), + )) +} + pub fn edit_pdf_overlays( app: &tauri::AppHandle, handle: &Arc, @@ -1183,7 +1262,8 @@ pub fn edit_pdf_overlays( if handle.is_cancelled() { return Err(AppError::cancelled()); } - export_edit_pdf_with_runner( + let qpdf_exe = qpdf::resolve_qpdf(app); + export_edit_pdf_with_check_exe( groups, output, document, @@ -1191,6 +1271,8 @@ pub fn edit_pdf_overlays( &work, job_id, Some(&handle.cancelled), + &qpdf_exe, + Some(handle), |args| run_qpdf(app, handle, job_id, args, "Saving", None), ) })(); diff --git a/src-tauri/src/pdf_engine/mod.rs b/src-tauri/src/pdf_engine/mod.rs index 4331211..9831e2e 100644 --- a/src-tauri/src/pdf_engine/mod.rs +++ b/src-tauri/src/pdf_engine/mod.rs @@ -23,6 +23,7 @@ pub mod qpdf; pub mod render; pub mod stamp; pub mod textexport; +pub mod validate_output; use crate::error::AppError; use crate::models::{JobHandle, JobUpdate, PageGroup, PagePick, RotateGroup, SplitMode}; diff --git a/src-tauri/src/pdf_engine/qpdf.rs b/src-tauri/src/pdf_engine/qpdf.rs index 7c70227..8fc4983 100644 --- a/src-tauri/src/pdf_engine/qpdf.rs +++ b/src-tauri/src/pdf_engine/qpdf.rs @@ -14,20 +14,11 @@ fn exe_name() -> &'static str { } } -/// Locate the qpdf binary. Prefers a bundled copy under `binaries/`, falling -/// back to the system PATH (by returning the bare exe name). -pub fn resolve_qpdf(app: &tauri::AppHandle) -> PathBuf { +/// Locate qpdf without a Tauri handle (Edit PDF `--check`, tests). +pub fn resolve_qpdf_standalone() -> PathBuf { let exe = exe_name(); - // 1. Bundled next to app resources. - if let Ok(res) = app.path().resource_dir() { - let candidate = res.join("binaries").join(exe); - if candidate.exists() { - return candidate; - } - } - - // 2. Bundled next to the executable. + // Bundled next to the executable. if let Ok(cur) = std::env::current_exe() { if let Some(parent) = cur.parent() { let candidate = parent.join("binaries").join(exe); @@ -37,9 +28,9 @@ pub fn resolve_qpdf(app: &tauri::AppHandle) -> PathBuf { } } - // 3. Common absolute install locations. A Finder-launched .app does NOT - // inherit the shell PATH (so Homebrew/MacPorts dirs are missing), so we - // probe them explicitly before relying on PATH. + // Common absolute install locations. A Finder-launched .app does NOT + // inherit the shell PATH (so Homebrew/MacPorts dirs are missing), so we + // probe them explicitly before relying on PATH. #[cfg(not(windows))] { for candidate in [ @@ -55,10 +46,26 @@ pub fn resolve_qpdf(app: &tauri::AppHandle) -> PathBuf { } } - // 4. Fall back to PATH (works when launched from a terminal / dev). + // Fall back to PATH (works when launched from a terminal / dev). PathBuf::from(exe) } +/// Locate the qpdf binary. Prefers a bundled copy under `binaries/`, falling +/// back to the system PATH (by returning the bare exe name). +pub fn resolve_qpdf(app: &tauri::AppHandle) -> PathBuf { + let exe = exe_name(); + + // 1. Bundled next to app resources. + if let Ok(res) = app.path().resource_dir() { + let candidate = res.join("binaries").join(exe); + if candidate.exists() { + return candidate; + } + } + + resolve_qpdf_standalone() +} + /// Return the number of pages in `input` via `qpdf --show-npages`. /// Any failure (spawn, non-zero exit, unparseable output) -> `invalid_pdf`. pub fn npages(app: &tauri::AppHandle, input: &str) -> Result { diff --git a/src-tauri/src/pdf_engine/validate_output.rs b/src-tauri/src/pdf_engine/validate_output.rs new file mode 100644 index 0000000..ea382a8 --- /dev/null +++ b/src-tauri/src/pdf_engine/validate_output.rs @@ -0,0 +1,680 @@ +//! Fail-closed publish gate for a staged PDF, before `replace_file`. +//! +//! `qpdf --check` exit policy (V4): +//! - `0` → clean (`QpdfCheckClass::Ok`) +//! - `3` → warnings only (`QpdfCheckClass::Warning`); do not block publish; +//! keep stderr on [`ValidationResult::warnings`] +//! - `2` or any other nonzero → fatal (`QpdfCheckClass::Fatal`) +//! +//! On fatal validation: do not publish; delete the staged `.offpdf-*.pdf.tmp`; +//! leave the source PDF and any existing destination bytes untouched. + +use crate::error::AppError; +use crate::pdf_engine::crop; +use lopdf::Document; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Per-page geometry the gate compares to the reopened staged file. +#[derive(Debug, Clone, PartialEq)] +pub struct PageSnapshot { + pub media_box: [f64; 4], + pub crop_box: Option<[f64; 4]>, + pub trim_box: Option<[f64; 4]>, + pub rotate: i64, + pub user_unit: f64, +} + +/// Catalog / trailer structures the source had and the staged file must keep. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogFlags { + pub outlines: bool, + pub info: bool, + pub acro_form: bool, + pub annots: bool, +} + +/// Expected output after overlay: page order is `pages` order (count = `pages.len()`). +#[derive(Debug, Clone, PartialEq)] +pub struct OutputSnapshot { + pub pages: Vec, + pub catalog: CatalogFlags, +} + +/// Non-fatal findings from a passed gate (qpdf `--check` exit 3). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ValidationResult { + pub warnings: Vec, +} + +/// Classification of a `qpdf --check` process result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QpdfCheckClass { + Ok, + Warning, + Fatal, +} + +/// Classify `qpdf --check` from its exit code. `stderr` is recorded later on warnings. +pub fn classify_qpdf_check(exit: i32, stderr: &str) -> QpdfCheckClass { + let _ = stderr; + match exit { + 0 => QpdfCheckClass::Ok, + 3 => QpdfCheckClass::Warning, + _ => QpdfCheckClass::Fatal, + } +} + +fn boxes_near(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 0.5) +} + +fn opt_boxes_near(a: Option<[f64; 4]>, b: Option<[f64; 4]>) -> bool { + match (a, b) { + (None, None) => true, + (Some(x), Some(y)) => boxes_near(x, y), + _ => false, + } +} + +fn invalid_output(message: impl Into) -> AppError { + AppError::new( + "INVALID_OUTPUT", + "The edited PDF is not valid", + message, + ) + .with_suggestion("The original file was not changed. Try saving again.") +} + +fn fatal_staged(staged: &Path, message: impl Into) -> AppError { + let _ = std::fs::remove_file(staged); + invalid_output(message) +} + +fn abort_if_cancelled(staged: &Path, cancel: Option<&AtomicBool>) -> Result<(), AppError> { + if cancel.is_some_and(|c| c.load(Ordering::SeqCst)) { + let _ = std::fs::remove_file(staged); + return Err(AppError::cancelled()); + } + Ok(()) +} + +pub(crate) fn catalog_flags_from_doc(doc: &Document) -> CatalogFlags { + CatalogFlags { + outlines: has_catalog_key(doc, b"Outlines"), + info: doc.trailer.get(b"Info").is_ok(), + acro_form: has_catalog_key(doc, b"AcroForm"), + annots: has_any_annots(doc), + } +} + +fn catalog_dict(doc: &Document) -> Option<&lopdf::Dictionary> { + let root = doc.trailer.get(b"Root").ok()?.as_reference().ok()?; + doc.get_dictionary(root).ok() +} + +fn has_catalog_key(doc: &Document, key: &[u8]) -> bool { + catalog_dict(doc).and_then(|c| c.get(key).ok()).is_some() +} + +fn has_any_annots(doc: &Document) -> bool { + doc.get_pages().values().any(|id| { + doc.get_dictionary(*id) + .ok() + .and_then(|d| d.get(b"Annots").ok()) + .is_some() + }) +} + +/// Validate a dest-sibling staged PDF against `snapshot` using `run_check` for `qpdf --check`. +/// +/// `run_check` receives an argv array (no shell), typically `["--check", ]`, +/// and returns `(exit, stderr)`. +pub fn validate_staged_pdf( + staged: &Path, + snapshot: &OutputSnapshot, + cancel: Option<&AtomicBool>, + mut run_check: impl FnMut(&[String]) -> Result<(i32, String), AppError>, +) -> Result { + abort_if_cancelled(staged, cancel)?; + + let staged_arg = staged.to_string_lossy().into_owned(); + let args = ["--check".to_string(), staged_arg]; + let (exit, stderr) = match run_check(&args) { + Ok(v) => v, + Err(e) if e.code == "CANCELLED" => { + let _ = std::fs::remove_file(staged); + return Err(AppError::cancelled()); + } + Err(e) => return Err(e), + }; + abort_if_cancelled(staged, cancel)?; + + let mut warnings = Vec::new(); + match classify_qpdf_check(exit, &stderr) { + QpdfCheckClass::Fatal => { + return Err(fatal_staged( + staged, + if stderr.trim().is_empty() { + "qpdf --check reported errors in the edited PDF.".to_string() + } else { + format!("qpdf --check reported errors: {}", stderr.trim()) + }, + )); + } + QpdfCheckClass::Warning => warnings.push(stderr), + QpdfCheckClass::Ok => {} + } + + let doc = match Document::load(staged) { + Ok(d) => d, + Err(e) => { + return Err(fatal_staged( + staged, + format!("The edited PDF could not be reopened ({e})."), + )); + } + }; + + let page_map = doc.get_pages(); + if page_map.len() != snapshot.pages.len() { + return Err(fatal_staged( + staged, + format!( + "Page count changed: expected {}, found {}.", + snapshot.pages.len(), + page_map.len() + ), + )); + } + + for (i, expected) in snapshot.pages.iter().enumerate() { + let page_no = (i as u32) + 1; + let Some(&id) = page_map.get(&page_no) else { + return Err(fatal_staged( + staged, + format!("Page {page_no} is missing from the edited PDF."), + )); + }; + + if !boxes_near(crop::media_box(&doc, id), expected.media_box) { + return Err(fatal_staged( + staged, + format!("Page {page_no} MediaBox does not match the source."), + )); + } + if !opt_boxes_near(crop::crop_box(&doc, id), expected.crop_box) { + return Err(fatal_staged( + staged, + format!("Page {page_no} CropBox does not match the source."), + )); + } + if !opt_boxes_near(crop::page_trim_box(&doc, id), expected.trim_box) { + return Err(fatal_staged( + staged, + format!("Page {page_no} TrimBox does not match the source."), + )); + } + if crop::page_rotation(&doc, id) != expected.rotate { + return Err(fatal_staged( + staged, + format!("Page {page_no} /Rotate does not match the source."), + )); + } + if (crop::page_user_unit(&doc, id) - expected.user_unit).abs() > 0.0001 { + return Err(fatal_staged( + staged, + format!("Page {page_no} /UserUnit does not match the source."), + )); + } + } + + if snapshot.catalog.outlines && !has_catalog_key(&doc, b"Outlines") { + return Err(fatal_staged( + staged, + "Bookmarks (Outlines) are missing from the edited PDF.", + )); + } + if snapshot.catalog.info && doc.trailer.get(b"Info").is_err() { + return Err(fatal_staged( + staged, + "Document Info metadata is missing from the edited PDF.", + )); + } + if snapshot.catalog.acro_form && !has_catalog_key(&doc, b"AcroForm") { + return Err(fatal_staged( + staged, + "AcroForm is missing from the edited PDF.", + )); + } + if snapshot.catalog.annots && !has_any_annots(&doc) { + return Err(fatal_staged( + staged, + "Page annotations are missing from the edited PDF.", + )); + } + + abort_if_cancelled(staged, cancel)?; + Ok(ValidationResult { warnings }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lopdf::{Dictionary, Document, Object, Stream}; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct Scratch(PathBuf); + + impl Scratch { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!( + "offpdf-validate-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn box_obj(b: [i64; 4]) -> Object { + Object::Array(b.into_iter().map(Object::Integer).collect()) + } + + fn write_one_page_pdf(path: &Path, extras: &[(&[u8], Object)]) { + let mut doc = Document::with_version("1.5"); + let pages_id = doc.new_object_id(); + let content_id = doc.add_object(Object::Stream(Stream::new( + Dictionary::new(), + b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET".to_vec(), + ))); + 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); + for (k, v) in extras { + page.set(*k, v.clone()); + } + let page_id = doc.add_object(Object::Dictionary(page)); + + let mut pages = Dictionary::new(); + pages.set("Type", "Pages"); + pages.set("Kids", vec![page_id.into()]); + pages.set("Count", 1); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", "Catalog"); + catalog.set("Pages", pages_id); + let catalog_id = doc.add_object(Object::Dictionary(catalog)); + doc.trailer.set("Root", catalog_id); + doc.save(path).expect("write one-page fixture"); + } + + fn letter_page() -> PageSnapshot { + PageSnapshot { + media_box: [0.0, 0.0, 612.0, 792.0], + crop_box: None, + trim_box: None, + rotate: 0, + user_unit: 1.0, + } + } + + fn empty_catalog() -> CatalogFlags { + CatalogFlags { + outlines: false, + info: false, + acro_form: false, + annots: false, + } + } + + fn letter_snapshot() -> OutputSnapshot { + OutputSnapshot { + pages: vec![letter_page()], + catalog: empty_catalog(), + } + } + + fn assert_invalid_output(err: &AppError) { + assert_eq!(err.code, "INVALID_OUTPUT"); + assert!( + !err.title.trim().is_empty(), + "INVALID_OUTPUT must have a title" + ); + assert!( + !err.message.trim().is_empty(), + "INVALID_OUTPUT must have a message" + ); + assert!( + err.suggestion + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false), + "INVALID_OUTPUT must have a suggestion" + ); + } + + // --- V4 ----------------------------------------------------------------- + + #[test] + fn classify_qpdf_check_exit_0_is_ok() { + assert_eq!( + classify_qpdf_check(0, ""), + QpdfCheckClass::Ok, + "exit 0 must be Ok" + ); + } + + #[test] + fn classify_qpdf_check_exit_3_is_warning() { + assert_eq!( + classify_qpdf_check(3, "WARNING: linearized"), + QpdfCheckClass::Warning, + "exit 3 must be Warning" + ); + } + + #[test] + fn classify_qpdf_check_exit_2_is_fatal() { + assert_eq!( + classify_qpdf_check(2, "ERROR: damaged"), + QpdfCheckClass::Fatal, + "exit 2 must be Fatal" + ); + } + + #[test] + fn classify_qpdf_check_other_nonzero_is_fatal() { + assert_eq!( + classify_qpdf_check(1, "unexpected"), + QpdfCheckClass::Fatal, + "other nonzero must be Fatal" + ); + assert_eq!( + classify_qpdf_check(99, "other"), + QpdfCheckClass::Fatal, + "other nonzero must be Fatal" + ); + } + + #[test] + fn validate_qpdf_exit_3_records_warning() { + let scratch = Scratch::new("v4-warn"); + let staged = scratch.path().join("staged.pdf"); + write_one_page_pdf(&staged, &[]); + let snapshot = letter_snapshot(); + let result = validate_staged_pdf(&staged, &snapshot, None, |_args| { + Ok((3, "WARNING: file has warnings\n".into())) + }) + .expect("V4: qpdf --check exit 3 must not block"); + assert!( + result.warnings.iter().any(|w| w.contains("WARNING")), + "V4: exit 3 stderr must be recorded on ValidationResult.warnings; got {:?}", + result.warnings + ); + } + + // --- V1 / V8 ------------------------------------------------------------ + + #[test] + fn validate_staged_pdf_is_reachable_and_leaves_dest_untouched() { + // V8: public path crate::pdf_engine::validate_output::validate_staged_pdf + let scratch = Scratch::new("v1-v8"); + let staged = scratch.path().join("staged.pdf"); + let dest = scratch.path().join("out.pdf"); + write_one_page_pdf(&staged, &[]); + std::fs::write(&dest, b"OLD-DEST").unwrap(); + let dest_mtime = std::fs::metadata(&dest).unwrap().modified().unwrap(); + let snapshot = letter_snapshot(); + + let result = crate::pdf_engine::validate_output::validate_staged_pdf( + &staged, + &snapshot, + None, + |_| Ok((0, String::new())), + ); + + assert!( + result.is_ok(), + "V1: matching snapshot + check exit 0 is not fatal; {result:?}" + ); + assert_eq!( + std::fs::read(&dest).unwrap(), + b"OLD-DEST", + "V1: gate must not publish; dest bytes must stay OLD" + ); + assert_eq!( + std::fs::metadata(&dest).unwrap().modified().unwrap(), + dest_mtime, + "V1: dest mtime must stay unchanged" + ); + } + + #[test] + fn validate_err_means_caller_must_not_publish() { + // Caller contract around the gate (export runner is impl's job). + let scratch = Scratch::new("v1-no-publish"); + let dest = scratch.path().join("out.pdf"); + std::fs::write(&dest, b"OLD-DEST").unwrap(); + let gate: Result = Err(AppError::new( + "INVALID_OUTPUT", + "The edited PDF is not valid", + "Validation rejected the staged file.", + ) + .with_suggestion("Try saving again, or pick a different destination.")); + if gate.is_err() { + // do not replace dest + } else { + std::fs::write(&dest, b"NEW-PUBLISHED").unwrap(); + } + assert_eq!(std::fs::read(&dest).unwrap(), b"OLD-DEST"); + } + + // --- V2 ----------------------------------------------------------------- + + #[test] + fn validate_truncated_staged_pdf_is_invalid_output() { + let scratch = Scratch::new("v2-trunc"); + let staged = scratch.path().join("staged.pdf"); + let dest = scratch.path().join("out.pdf"); + std::fs::write(&staged, b"%PDF-1.4\n%% truncated").unwrap(); + std::fs::write(&dest, b"OLD-DEST").unwrap(); + let dest_mtime = std::fs::metadata(&dest).unwrap().modified().unwrap(); + let snapshot = letter_snapshot(); + + let result = validate_staged_pdf(&staged, &snapshot, None, |_args| { + Ok((2, "qpdf --check: file is damaged".into())) + }); + let err = result.expect_err("V2: truncated staged PDF must be INVALID_OUTPUT"); + assert_invalid_output(&err); + assert_eq!( + std::fs::read(&dest).unwrap(), + b"OLD-DEST", + "V2: dest bytes must stay OLD" + ); + assert_eq!( + std::fs::metadata(&dest).unwrap().modified().unwrap(), + dest_mtime, + "V2: dest mtime must stay unchanged" + ); + } + + #[test] + fn validate_truncated_does_not_create_dest() { + let scratch = Scratch::new("v2-nodest"); + let staged = scratch.path().join("staged.pdf"); + let dest = scratch.path().join("out.pdf"); + std::fs::write(&staged, b"%PDF-1.4\n%% truncated").unwrap(); + let snapshot = letter_snapshot(); + + let result = validate_staged_pdf(&staged, &snapshot, None, |_args| { + Ok((2, "qpdf --check: file is damaged".into())) + }); + let err = result.expect_err("V2: truncated staged PDF must be INVALID_OUTPUT"); + assert_invalid_output(&err); + assert!( + !dest.exists(), + "V2: dest must not be created when it did not exist" + ); + } + + // --- V3 ----------------------------------------------------------------- + + #[test] + fn validate_fatal_deletes_staging_leaves_source_and_dest() { + let scratch = Scratch::new("v3-cleanup"); + let source = scratch.path().join("source.pdf"); + let dest = scratch.path().join("out.pdf"); + let staged = scratch.path().join(".offpdf-job.pdf.tmp"); + std::fs::write(&source, b"SOURCE-BYTES").unwrap(); + std::fs::write(&dest, b"OLD-DEST").unwrap(); + std::fs::write(&staged, b"%PDF-1.4\n%% truncated").unwrap(); + let snapshot = letter_snapshot(); + + let result = validate_staged_pdf(&staged, &snapshot, None, |_args| { + Ok((2, "qpdf --check: file is damaged".into())) + }); + + assert!( + !staged.exists(), + "V3: fatal validate must delete staged .offpdf-*.pdf.tmp" + ); + assert_eq!( + std::fs::read(&source).unwrap(), + b"SOURCE-BYTES", + "V3: source bytes must be unchanged" + ); + assert_eq!( + std::fs::read(&dest).unwrap(), + b"OLD-DEST", + "V3: dest bytes must be unchanged" + ); + let err = result.expect_err("V3: truncated staging must be INVALID_OUTPUT"); + assert_invalid_output(&err); + } + + // --- V5 ----------------------------------------------------------------- + + #[test] + fn validate_page_count_mismatch_is_invalid_output() { + let scratch = Scratch::new("v5-pages"); + let staged = scratch.path().join("staged.pdf"); + let dest = scratch.path().join("out.pdf"); + write_one_page_pdf(&staged, &[]); + std::fs::write(&dest, b"OLD-DEST").unwrap(); + let snapshot = OutputSnapshot { + pages: vec![letter_page(), letter_page()], + catalog: empty_catalog(), + }; + + let result = validate_staged_pdf(&staged, &snapshot, None, |_args| Ok((0, String::new()))); + let err = result.expect_err("V5: page-count mismatch must be INVALID_OUTPUT"); + assert_invalid_output(&err); + assert_eq!(std::fs::read(&dest).unwrap(), b"OLD-DEST"); + } + + #[test] + fn validate_cropbox_mismatch_is_invalid_output() { + let scratch = Scratch::new("v5-crop"); + let staged = scratch.path().join("staged.pdf"); + let dest = scratch.path().join("out.pdf"); + write_one_page_pdf(&staged, &[(b"CropBox", box_obj([0, 0, 612, 792]))]); + std::fs::write(&dest, b"OLD-DEST").unwrap(); + let snapshot = OutputSnapshot { + pages: vec![PageSnapshot { + media_box: [0.0, 0.0, 612.0, 792.0], + crop_box: Some([72.0, 72.0, 540.0, 720.0]), + trim_box: None, + rotate: 0, + user_unit: 1.0, + }], + catalog: empty_catalog(), + }; + + let result = validate_staged_pdf(&staged, &snapshot, None, |_args| Ok((0, String::new()))); + let err = result.expect_err("V5: CropBox mismatch must be INVALID_OUTPUT"); + assert_invalid_output(&err); + assert_eq!(std::fs::read(&dest).unwrap(), b"OLD-DEST"); + } + + // --- V6 ----------------------------------------------------------------- + + #[test] + fn validate_missing_catalog_keys_is_invalid_output() { + let scratch = Scratch::new("v6-catalog"); + let staged = scratch.path().join("staged.pdf"); + let dest = scratch.path().join("out.pdf"); + // Valid one-page PDF: no Outlines, no Info, no AcroForm, no Annots. + write_one_page_pdf(&staged, &[]); + std::fs::write(&dest, b"OLD-DEST").unwrap(); + let snapshot = OutputSnapshot { + pages: vec![letter_page()], + catalog: CatalogFlags { + outlines: true, + info: true, + acro_form: true, + annots: true, + }, + }; + + let result = validate_staged_pdf(&staged, &snapshot, None, |_args| Ok((0, String::new()))); + let err = result.expect_err( + "V6: snapshot Outlines+Info+AcroForm+Annots missing on staged file must be INVALID_OUTPUT", + ); + assert_invalid_output(&err); + assert_eq!(std::fs::read(&dest).unwrap(), b"OLD-DEST"); + } + + // --- C1 ----------------------------------------------------------------- + + #[test] + fn validate_cancel_after_check_does_not_pass() { + let scratch = Scratch::new("c1-cancel"); + let dest = scratch.path().join("out.pdf"); + let staged = scratch.path().join(".offpdf-job.pdf.tmp"); + write_one_page_pdf(&staged, &[]); + std::fs::write(&dest, b"OLD-DEST").unwrap(); + let snapshot = letter_snapshot(); + let cancel = AtomicBool::new(false); + + let result = validate_staged_pdf(&staged, &snapshot, Some(&cancel), |_args| { + cancel.store(true, Ordering::SeqCst); + Ok((0, String::new())) + }); + + let err = result.expect_err("C1: cancel after successful --check must be CANCELLED"); + assert_eq!( + err.code, "CANCELLED", + "C1: cancel after check must be CANCELLED, not {}", + err.code + ); + assert_eq!( + std::fs::read(&dest).unwrap(), + b"OLD-DEST", + "C1: dest bytes must stay OLD-DEST" + ); + assert!( + !staged.exists(), + "C1: cancel must delete staged .offpdf-*.pdf.tmp" + ); + } +}