diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 0a37e8c..8cbc1f7 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -7,6 +7,10 @@ workflows: - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.2' + '.github/workflows/fmt.yml': + - 'actions/checkout@v7.0.1' + - 'dtolnay/rust-toolchain@v1' + - 'Swatinem/rust-cache@v2.9.2' '.github/workflows/boj-build.yml': - 'actions/checkout@v7.0.1' '.github/workflows/casket-pages.yml': @@ -50,7 +54,10 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'softprops/action-gh-release@v3.0.3' - 'swatinem/rust-cache@v2.9.2' - '.github/workflows/rust-ci.yml': [] + '.github/workflows/rust-ci.yml': + - 'actions/checkout@v7.0.1' + - 'dtolnay/rust-toolchain@v1' + - 'Swatinem/rust-cache@v2.9.2' '.github/workflows/scorecard.yml': [] '.github/workflows/secret-scanner.yml': [] '.github/workflows/security.yml': diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad0f525..c690b40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,8 @@ jobs: fi return $code } + # Auto-format before checking so PRs don't fail on fmt alone (D189 follow-up) + cargo fmt --all || true run_logged check cargo check --workspace --all-targets || true run_logged test cargo test -p presswerk-core -p presswerk-security -p presswerk-document -p presswerk-print || true run_logged clippy cargo clippy --workspace --all-targets -- -D warnings || true diff --git a/.github/workflows/fmt.yml b/.github/workflows/fmt.yml new file mode 100644 index 0000000..4e35355 --- /dev/null +++ b/.github/workflows/fmt.yml @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MPL-2.0 +# Auto-fix cargo fmt (temporary — remove after D189 lands) +# Uses only actions already in actions.lock to avoid lock churn. + +name: fmt + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + - uses: Swatinem/rust-cache@v2.9.2 + - run: cargo fmt --all + - run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + if ! git diff --quiet; then + git add -A + git commit -m "fmt: cargo fmt --all" + git push + else + echo "no fmt changes" + fi diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index bb07f89..6cf6ab0 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -17,5 +17,32 @@ permissions: contents: read jobs: + # Auto-format before the reusable Rust CI so PRs don't fail on fmt alone. + fmt: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + - uses: Swatinem/rust-cache@v2.9.2 + - run: cargo fmt --all + - run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + if ! git diff --quiet; then + git add -A + git commit -m "fmt: cargo fmt --all" + git push + else + echo "no fmt changes" + fi rust-ci: + needs: fmt uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@84355587cb2a1f86e6882de83514a32db2646e7a # re-pinned 2026-07-01: toolchain-required-input fix (standards#439) diff --git a/Justfile b/Justfile index cabaf08..c37d939 100644 --- a/Justfile +++ b/Justfile @@ -65,7 +65,8 @@ ffp-conformance: exit 1 fi cargo build --bin ffp-classify -p presswerk-document - FFP_DETECTOR="./target/debug/ffp-classify" bash "$STANDARDS/1-formats/sub-specs/form-fill-provenance/spec/conformance/run-conformance.sh" + TARGET_DIR="${CARGO_TARGET_DIR:-./target}" + FFP_DETECTOR="$TARGET_DIR/debug/ffp-classify" bash "$STANDARDS/1-formats/sub-specs/form-fill-provenance/spec/conformance/run-conformance.sh" # Full CI check (test + lint + fmt) ci: test-libs lint fmt-check diff --git a/crates/presswerk-app/src/services/app_services.rs b/crates/presswerk-app/src/services/app_services.rs index 089e9e0..1df39d3 100644 --- a/crates/presswerk-app/src/services/app_services.rs +++ b/crates/presswerk-app/src/services/app_services.rs @@ -16,8 +16,8 @@ use presswerk_core::AppConfig; use presswerk_core::error::{PresswerkError, Result}; use presswerk_core::provenance::FfpClassification; use presswerk_core::types::{ - DiscoveredPrinter, DocumentType, JobId, JobSource, JobStatus, PrintJob, - PrintSettings, ServerStatus, + DiscoveredPrinter, DocumentType, JobId, JobSource, JobStatus, PrintJob, PrintSettings, + ServerStatus, }; use presswerk_document::classify_for_print; use presswerk_print::discovery::PrinterDiscovery; @@ -341,7 +341,12 @@ impl AppServices { queue.update_status(job_id, JobStatus::Pending, None)?; } - self.audit("job_released", &job.document_hash, true, Some(&job.document_name)); + self.audit( + "job_released", + &job.document_hash, + true, + Some(&job.document_name), + ); self.spawn_send( *job_id, @@ -430,7 +435,10 @@ impl AppServices { /// (issue #118, ruling D189). pub fn machine_filled_jobs(&self) -> Result> { let queue = acquire_lock(&self.job_queue); - queue.get_jobs_with_form_origin(FfpClassification::MachineFilled) + let mut jobs = queue.get_jobs_with_form_origin(FfpClassification::MachineFilled)?; + jobs.extend(queue.get_jobs_with_form_origin(FfpClassification::MachineFilledSuspected)?); + jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(jobs) } /// Cancel a job. diff --git a/crates/presswerk-core/src/lib.rs b/crates/presswerk-core/src/lib.rs index fbdd56e..87fc9d7 100644 --- a/crates/presswerk-core/src/lib.rs +++ b/crates/presswerk-core/src/lib.rs @@ -13,7 +13,7 @@ pub mod types; pub use config::AppConfig; pub use error::PresswerkError; pub use provenance::{ - FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, FormOrigin, - FormProvenance, ProvenanceConfidence, + FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, FormOrigin, FormProvenance, + ProvenanceConfidence, }; pub use types::*; diff --git a/crates/presswerk-core/src/provenance.rs b/crates/presswerk-core/src/provenance.rs index df42aae..1016463 100644 --- a/crates/presswerk-core/src/provenance.rs +++ b/crates/presswerk-core/src/provenance.rs @@ -146,12 +146,21 @@ pub struct FfpDeclared { pub tool_version: Option, #[serde(rename = "filledAt", skip_serializing_if = "Option::is_none")] pub filled_at: Option, - #[serde(rename = "appearancesGenerated", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "appearancesGenerated", + skip_serializing_if = "Option::is_none" + )] pub appearances_generated: Option, } impl FfpDeclared { - pub fn machine(tool: Option, tool_version: Option, filled_at: Option, appearances_generated: Option) -> Self { + /// Create a machine-filled declaration with optional tool and appearance metadata. + pub fn machine( + tool: Option, + tool_version: Option, + filled_at: Option, + appearances_generated: Option, + ) -> Self { Self { filled_by: "machine".to_string(), tool, @@ -248,7 +257,9 @@ impl FfpRecord { pub fn should_hold(&self, policy: crate::types::FormProvenancePolicy) -> bool { use crate::types::FormProvenancePolicy; match policy { - FormProvenancePolicy::HoldForReview => self.is_machine_or_suspected() || self.has_blank_print_hazard(), + FormProvenancePolicy::HoldForReview => { + self.is_machine_or_suspected() || self.has_blank_print_hazard() + } _ => false, } } @@ -269,12 +280,21 @@ impl FfpRecord { "machine-filled form".to_string() } } - FfpClassification::MachineFilledSuspected => "machine-filled form (suspected — incomplete appearances)".to_string(), - FfpClassification::FilledUnknown => "form values present — provenance unknown".to_string(), + FfpClassification::MachineFilledSuspected => { + "machine-filled form (suspected — incomplete appearances)".to_string() + } + FfpClassification::FilledUnknown => { + "form values present — provenance unknown".to_string() + } FfpClassification::Unreadable => "form provenance unreadable".to_string(), }; if self.is_form() { - format!("{head}; {}/{} fields filled; appearances {}", self.filled_fields, self.total_fields, self.appearances.as_str()) + format!( + "{head}; {}/{} fields filled; appearances {}", + self.filled_fields, + self.total_fields, + self.appearances.as_str() + ) } else { head } @@ -324,7 +344,9 @@ impl FfpRecord { FfpClassification::NoForm => "NotAForm", FfpClassification::BlankForm => "Empty", FfpClassification::FilledUnknown => "Human", - FfpClassification::MachineFilled | FfpClassification::MachineFilledSuspected => "Machine", + FfpClassification::MachineFilled | FfpClassification::MachineFilledSuspected => { + "Machine" + } FfpClassification::Unreadable => "Unknown", } } @@ -388,7 +410,9 @@ impl std::fmt::Display for FormOrigin { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, +)] pub enum ProvenanceConfidence { #[default] None, @@ -459,6 +483,8 @@ pub type FormProvenance = FfpRecord; /// Convert legacy into new record (best-effort, for migration). impl From for FfpRecord { + /// Migrate legacy provenance to FFP, mapping unmarked machine origins to suspected. + /// Preserve field counts and translate legacy signals into evidence codes. fn from(legacy: FormProvenanceLegacy) -> Self { // Map legacy origin → new classification let classification = match legacy.origin { @@ -492,19 +518,25 @@ impl From for FfpRecord { } else { FfpAppearances::Generated }; - let declared = legacy.marker.map(|m| FfpDeclared { - filled_by: m, - tool: legacy.producer, - tool_version: None, - filled_at: None, - appearances_generated: None, - }).or_else(|| legacy.producer.map(|p| FfpDeclared { - filled_by: "machine".to_string(), - tool: Some(p), - tool_version: None, - filled_at: None, - appearances_generated: None, - })); + let declared = if let Some(m) = &legacy.marker { + Some(FfpDeclared { + filled_by: m.clone(), + tool: legacy.producer.clone(), + tool_version: None, + filled_at: None, + appearances_generated: None, + }) + } else if let Some(p) = &legacy.producer { + Some(FfpDeclared { + filled_by: "machine".to_string(), + tool: Some(p.clone()), + tool_version: None, + filled_at: None, + appearances_generated: None, + }) + } else { + None + }; let mut evidence = Vec::new(); if legacy.need_appearances { evidence.push("FFP-E-NEED-APPEARANCES".to_string()); @@ -515,7 +547,9 @@ impl From for FfpRecord { match legacy.origin { FormOrigin::NotAForm => evidence.push("FFP-E-NO-ACROFORM".to_string()), FormOrigin::Empty => evidence.push("FFP-E-NO-VALUES".to_string()), - FormOrigin::Unknown if !legacy.inspected => evidence.push("FFP-E-UNREADABLE".to_string()), + FormOrigin::Unknown if !legacy.inspected => { + evidence.push("FFP-E-UNREADABLE".to_string()) + } _ => {} } if legacy.marker.is_some() { @@ -562,6 +596,7 @@ mod tests { } } + /// Verify the conformance line includes field counts and sorted evidence codes. #[test] fn canonical_line_format() { let r = FfpRecord { @@ -572,7 +607,10 @@ mod tests { total_fields: 2, appearances: FfpAppearances::Incomplete, declared: None, - evidence: vec!["FFP-E-NEED-APPEARANCES".to_string(), "FFP-E-AP-INCOMPLETE".to_string()], + evidence: vec![ + "FFP-E-NEED-APPEARANCES".to_string(), + "FFP-E-AP-INCOMPLETE".to_string(), + ], }; assert_eq!( r.canonical_line(), @@ -581,6 +619,7 @@ mod tests { // evidence is sorted } + /// Verify audit JSON preserves the complete record and its declared metadata. #[test] fn json_roundtrip() { let r = FfpRecord { @@ -590,7 +629,13 @@ mod tests { filled_fields: 2, total_fields: 2, appearances: FfpAppearances::Incomplete, - declared: Some(FfpDeclared { filled_by: "machine".to_string(), tool: Some("blocky-writer".to_string()), tool_version: Some("0.2.0".to_string()), filled_at: None, appearances_generated: Some(false) }), + declared: Some(FfpDeclared { + filled_by: "machine".to_string(), + tool: Some("blocky-writer".to_string()), + tool_version: Some("0.2.0".to_string()), + filled_at: None, + appearances_generated: Some(false), + }), evidence: vec!["FFP-E-DECL-MACHINE".to_string()], }; let json = r.audit_json(); @@ -598,12 +643,17 @@ mod tests { assert_eq!(back, r); } + /// Verify unreadable records emit the canonical unknown-form representation. #[test] fn unreadable_line() { let r = FfpRecord::unreadable("parse failed"); - assert_eq!(r.canonical_line(), "classification=unreadable form=unknown filled=0/0 appearances=unknown evidence=FFP-E-UNREADABLE"); + assert_eq!( + r.canonical_line(), + "classification=unreadable form=unknown filled=0/0 appearances=unknown evidence=FFP-E-UNREADABLE" + ); } + /// Verify review holds cover machine origins and incomplete filled appearances. #[test] fn should_hold() { use crate::types::FormProvenancePolicy; @@ -614,7 +664,13 @@ mod tests { filled_fields: 2, total_fields: 2, appearances: FfpAppearances::Incomplete, - declared: Some(FfpDeclared { filled_by: "machine".to_string(), tool: None, tool_version: None, filled_at: None, appearances_generated: None }), + declared: Some(FfpDeclared { + filled_by: "machine".to_string(), + tool: None, + tool_version: None, + filled_at: None, + appearances_generated: None, + }), evidence: vec!["FFP-E-DECL-MACHINE".to_string()], }; assert!(r.should_hold(FormProvenancePolicy::HoldForReview)); diff --git a/crates/presswerk-core/src/types.rs b/crates/presswerk-core/src/types.rs index d215c79..9e344f1 100644 --- a/crates/presswerk-core/src/types.rs +++ b/crates/presswerk-core/src/types.rs @@ -240,8 +240,8 @@ pub struct PageRange { // --------------------------------------------------------------------------- pub use crate::provenance::{ - FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, - FormOrigin, FormProvenance, ProvenanceConfidence, + FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, FormOrigin, FormProvenance, + ProvenanceConfidence, }; /// What the print path does with a machine-filled form (issue #118). @@ -653,17 +653,22 @@ mod tests { assert!(!FormOrigin::NotAForm.is_machine()); } + /// Verify default provenance is unreadable with unknown form presence. #[test] fn test_form_provenance_default_is_not_inspected() { let provenance = FormProvenance::default(); // Default is unreadable (form unknown, not a form) assert!(!provenance.is_form()); - assert_eq!(provenance.classification, crate::provenance::FfpClassification::Unreadable); + assert_eq!( + provenance.classification, + crate::provenance::FfpClassification::Unreadable + ); assert_eq!(provenance.form, crate::provenance::FfpForm::Unknown); assert!(!provenance.is_machine_filled()); assert_eq!(provenance.evidence, vec!["FFP-E-UNREADABLE".to_string()]); } + /// Verify new print jobs start with unreadable provenance and unknown form presence. #[test] fn test_print_job_defaults_to_uninspected_provenance() { let job = PrintJob::new( @@ -672,8 +677,14 @@ mod tests { "test.pdf".to_string(), "hash".to_string(), ); - assert_eq!(job.form_provenance.classification, crate::provenance::FfpClassification::Unreadable); - assert_eq!(job.form_provenance.form, crate::provenance::FfpForm::Unknown); + assert_eq!( + job.form_provenance.classification, + crate::provenance::FfpClassification::Unreadable + ); + assert_eq!( + job.form_provenance.form, + crate::provenance::FfpForm::Unknown + ); } #[test] @@ -699,10 +710,14 @@ mod tests { assert_eq!(provenance.summary(), "not an interactive form"); } + /// Verify the declared tool reflects replacement of the declaration. #[test] fn test_form_provenance_declared_tool() { let mut provenance = machine_filled(); - assert_eq!(provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), Some("blocky-writer 0.4.2")); + assert_eq!( + provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), + Some("blocky-writer 0.4.2") + ); // Declared tool is preferred provenance.declared = Some(crate::provenance::FfpDeclared { @@ -712,20 +727,29 @@ mod tests { filled_at: None, appearances_generated: None, }); - assert_eq!(provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), Some("other-tool")); + assert_eq!( + provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), + Some("other-tool") + ); } + /// Verify audit JSON includes the FFP version and preserves provenance on decoding. #[test] fn test_form_provenance_audit_json_roundtrips() { let provenance = machine_filled(); let json = provenance.audit_json(); - assert!(json.contains("\"classification\":\"machine-filled\""), "{json}"); + assert!( + json.contains("\"classification\":\"machine-filled\""), + "{json}" + ); assert!(json.contains("\"ffp\":\"1.0\""), "{json}"); let restored: FormProvenance = serde_json::from_str(&json).expect("deserialize"); assert_eq!(restored, provenance); } + /// Verify review holds apply to machine origins and blank-print hazards, + /// while unreadable records and filled forms with generated appearances proceed. #[test] fn test_should_hold_only_for_positive_machine_determination() { let machine = machine_filled(); @@ -771,7 +795,10 @@ mod tests { total_fields: 2, appearances: crate::provenance::FfpAppearances::Incomplete, declared: None, - evidence: vec!["FFP-E-NEED-APPEARANCES".to_string(), "FFP-E-AP-INCOMPLETE".to_string()], + evidence: vec![ + "FFP-E-NEED-APPEARANCES".to_string(), + "FFP-E-AP-INCOMPLETE".to_string(), + ], }; assert!(suspected.should_hold(FormProvenancePolicy::HoldForReview)); } diff --git a/crates/presswerk-document/src/lib.rs b/crates/presswerk-document/src/lib.rs index 93a4e89..f1c0722 100644 --- a/crates/presswerk-document/src/lib.rs +++ b/crates/presswerk-document/src/lib.rs @@ -24,9 +24,9 @@ pub mod scan; // CONVENIENCE: Primary interfaces for document transformation. pub use image::processor::ImageProcessor; -pub use provenance::{classify_for_print, classify_pdf}; pub use pdf::reader::PdfReader; pub use pdf::writer::PdfWriter; +pub use provenance::{classify_for_print, classify_pdf}; pub use scan::enhance::ScanEnhancer; // OPTIONAL: OCR integration using `ocrs` (enabled via the "ocr" feature gate). diff --git a/crates/presswerk-document/src/pdf/form.rs b/crates/presswerk-document/src/pdf/form.rs index 8dfff6f..376bd02 100644 --- a/crates/presswerk-document/src/pdf/form.rs +++ b/crates/presswerk-document/src/pdf/form.rs @@ -4,5 +4,7 @@ // Deprecated wrapper — the FFP v1.0 detector lives in `crate::provenance`. // This module re-exports it so `crate::pdf::form::classify_*` keeps compiling. -pub use crate::provenance::{classify_document, classify_for_print, classify_pdf, MAX_INSPECT_BYTES}; pub use crate::provenance::MAX_INSPECT_BYTES as DETECTOR_MAX_BYTES; +pub use crate::provenance::{ + MAX_INSPECT_BYTES, classify_document, classify_for_print, classify_pdf, +}; diff --git a/crates/presswerk-document/src/provenance.rs b/crates/presswerk-document/src/provenance.rs index 6144b6e..1c2a8e9 100644 --- a/crates/presswerk-document/src/provenance.rs +++ b/crates/presswerk-document/src/provenance.rs @@ -7,7 +7,9 @@ use std::collections::HashSet; use lopdf::{Dictionary, Document, Object, ObjectId}; -use presswerk_core::provenance::{FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord}; +use presswerk_core::provenance::{ + FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, +}; use presswerk_core::types::{DocumentType, FormProvenancePolicy}; use tracing::{debug, instrument}; @@ -23,8 +25,14 @@ const FFP_NS: &[u8] = b"https://hyperpolymath.dev/ns/form-fill-provenance/1.0/"; // Public entry points // --------------------------------------------------------------------------- +/// Classify document provenance according to the print inspection policy. +/// Disabled inspection returns unreadable; otherwise non-PDF input returns no-form. #[instrument(skip_all, fields(bytes = data.len(), policy = %policy.as_token()))] -pub fn classify_for_print(data: &[u8], document_type: DocumentType, policy: FormProvenancePolicy) -> FfpRecord { +pub fn classify_for_print( + data: &[u8], + document_type: DocumentType, + policy: FormProvenancePolicy, +) -> FfpRecord { if !policy.inspects() { debug!("form provenance inspection disabled by policy"); return FfpRecord::unreadable("form provenance inspection is disabled"); @@ -60,6 +68,8 @@ pub fn classify_pdf(data: &[u8]) -> FfpRecord { classify_document(&document, data) } +/// Classify a parsed PDF using its XMP declaration and AcroForm field graph. +/// The document structure determines the result; raw bytes may be empty. #[instrument(skip_all, fields(raw_bytes = raw.len()))] pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { // Cheap pre-check: if %PDF- missing, unreadable (matches probe) @@ -91,7 +101,9 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { let tool_version = xmp_extract(&payload, "toolVersion"); let filled_at = xmp_extract(&payload, "filledAt"); let ap_gen_str = xmp_extract(&payload, "appearancesGenerated"); - let ap_gen = ap_gen_str.as_deref().map(|s| s.eq_ignore_ascii_case("true")); + let ap_gen = ap_gen_str + .as_deref() + .map(|s| s.eq_ignore_ascii_case("true")); if let Some(fb) = filled_by { let fb_trim = fb.trim().to_string(); @@ -146,7 +158,11 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { } }; - let acroform_dict = match catalog.get(b"AcroForm").ok().and_then(|obj| resolve_dict(document, obj)) { + let acroform_dict = match catalog + .get(b"AcroForm") + .ok() + .and_then(|obj| resolve_dict(document, obj)) + { Some(d) => d, None => { evidence.insert("FFP-E-NO-ACROFORM".to_string()); @@ -167,7 +183,10 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { }; // NeedAppearances - if matches!(acroform_dict.get(b"NeedAppearances"), Ok(Object::Boolean(true))) { + if matches!( + acroform_dict.get(b"NeedAppearances"), + Ok(Object::Boolean(true)) + ) { evidence.insert("FFP-E-NEED-APPEARANCES".to_string()); } @@ -180,7 +199,17 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { }; let mut seen: HashSet = HashSet::new(); if let Ok(fields_obj) = acroform_dict.get(b"Fields") { - walk_field(document, fields_obj, "", "", "", 0, &mut seen, &mut scan, &mut evidence); + walk_field( + document, + fields_obj, + "", + "", + "", + 0, + &mut seen, + &mut scan, + &mut evidence, + ); } let total = scan.total; @@ -218,12 +247,18 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { } // Step 6 — classify - let form = if total > 0 { FfpForm::Present } else { FfpForm::Absent }; + let form = if total > 0 { + FfpForm::Present + } else { + FfpForm::Absent + }; let classification = if filled == 0 { FfpClassification::BlankForm } else if declared_filled_by.as_deref() == Some("machine") { FfpClassification::MachineFilled - } else if evidence.contains("FFP-E-NEED-APPEARANCES") && evidence.contains("FFP-E-AP-INCOMPLETE") { + } else if evidence.contains("FFP-E-NEED-APPEARANCES") + && evidence.contains("FFP-E-AP-INCOMPLETE") + { FfpClassification::MachineFilledSuspected } else { FfpClassification::FilledUnknown @@ -257,6 +292,8 @@ struct FieldScan { visited: u32, } +/// Traverse fields with inherited type, flags, and values, updating scan counts. +/// Limit traversal by depth and visited-node count and skip seen references. fn walk_field( document: &Document, node: &Object, @@ -278,12 +315,32 @@ fn walk_field( } scan.visited += 1; if let Ok(resolved) = document.get_object(*id) { - walk_field(document, resolved, inherit_ft, inherit_ff, inherit_v, depth + 1, seen, scan, _evidence); + walk_field( + document, + resolved, + inherit_ft, + inherit_ff, + inherit_v, + depth + 1, + seen, + scan, + _evidence, + ); } } Object::Array(items) => { for item in items { - walk_field(document, item, inherit_ft, inherit_ff, inherit_v, depth + 1, seen, scan, _evidence); + walk_field( + document, + item, + inherit_ft, + inherit_ff, + inherit_v, + depth + 1, + seen, + scan, + _evidence, + ); } } Object::Dictionary(dict) => { @@ -291,33 +348,57 @@ fn walk_field( // Inherit let ft = get_string_value(dict, b"FT").unwrap_or_else(|| inherit_ft.to_string()); let ff = get_string_value(dict, b"Ff").unwrap_or_else(|| inherit_ff.to_string()); - let v_raw = dict.get(b"V").ok().map(|o| object_to_string(o)).unwrap_or_else(|| inherit_v.to_string()); + let v_raw = dict + .get(b"V") + .ok() + .map(|o| object_to_string(o)) + .unwrap_or_else(|| inherit_v.to_string()); // Check Kids if let Ok(kids_obj) = dict.get(b"Kids") { let kids_ids = get_array_ids(document, kids_obj); if !kids_ids.is_empty() { // Check first kid's Subtype - let first_is_widget = kids_ids.first().and_then(|id| document.get_object(*id).ok()).map(|obj| { - if let Object::Dictionary(d) = obj { - d.get(b"Subtype").ok().map(|s| format!("{:?}", s).contains("Widget")).unwrap_or(false) - } else if let Object::Stream(s) = obj { - s.dict.get(b"Subtype").ok().map(|v| format!("{:?}", v).contains("Widget")).unwrap_or(false) - } else { - false - } - }).unwrap_or(false); + let first_is_widget = kids_ids + .first() + .and_then(|id| document.get_object(*id).ok()) + .map(|obj| { + if let Object::Dictionary(d) = obj { + d.get(b"Subtype") + .ok() + .map(|s| format!("{:?}", s).contains("Widget")) + .unwrap_or(false) + } else if let Object::Stream(s) = obj { + s.dict + .get(b"Subtype") + .ok() + .map(|v| format!("{:?}", v).contains("Widget")) + .unwrap_or(false) + } else { + false + } + }) + .unwrap_or(false); // More robust: check via resolve let first_id = kids_ids[0]; - let first_is_widget2 = document.get_object(first_id).ok().and_then(|o| { - let dict_opt = match o { - Object::Dictionary(d) => Some(d), - Object::Stream(s) => Some(&s.dict), - _ => None, - }; - dict_opt.map(|d| d.get(b"Subtype").ok().map(|v| matches!(v, Object::Name(n) if n == b"Widget")).unwrap_or(false)) - }).unwrap_or(false); + let first_is_widget2 = document + .get_object(first_id) + .ok() + .and_then(|o| { + let dict_opt = match o { + Object::Dictionary(d) => Some(d), + Object::Stream(s) => Some(&s.dict), + _ => None, + }; + dict_opt.map(|d| { + d.get(b"Subtype") + .ok() + .map(|v| matches!(v, Object::Name(n) if n == b"Widget")) + .unwrap_or(false) + }) + }) + .unwrap_or(false); let is_widget = first_is_widget || first_is_widget2; if is_widget { @@ -327,7 +408,17 @@ fn walk_field( // Intermediate — recurse for kid_id in kids_ids { if let Ok(kid_obj) = document.get_object(kid_id) { - walk_field(document, kid_obj, &ft, &ff, &v_raw, depth + 1, seen, scan, _evidence); + walk_field( + document, + kid_obj, + &ft, + &ff, + &v_raw, + depth + 1, + seen, + scan, + _evidence, + ); } } } @@ -344,24 +435,49 @@ fn walk_field( let dict = &stream.dict; let ft = get_string_value(dict, b"FT").unwrap_or_else(|| inherit_ft.to_string()); let ff = get_string_value(dict, b"Ff").unwrap_or_else(|| inherit_ff.to_string()); - let v_raw = dict.get(b"V").ok().map(|o| object_to_string(o)).unwrap_or_else(|| inherit_v.to_string()); + let v_raw = dict + .get(b"V") + .ok() + .map(|o| object_to_string(o)) + .unwrap_or_else(|| inherit_v.to_string()); if let Ok(kids_obj) = dict.get(b"Kids") { let kids_ids = get_array_ids(document, kids_obj); if !kids_ids.is_empty() { // Similar widget check - let is_widget = kids_ids.first().and_then(|id| document.get_object(*id).ok()).map(|o| { - match o { - Object::Dictionary(d) => d.get(b"Subtype").ok().map(|v| matches!(v, Object::Name(n) if n == b"Widget")).unwrap_or(false), - Object::Stream(s) => s.dict.get(b"Subtype").ok().map(|v| matches!(v, Object::Name(n) if n == b"Widget")).unwrap_or(false), + let is_widget = kids_ids + .first() + .and_then(|id| document.get_object(*id).ok()) + .map(|o| match o { + Object::Dictionary(d) => d + .get(b"Subtype") + .ok() + .map(|v| matches!(v, Object::Name(n) if n == b"Widget")) + .unwrap_or(false), + Object::Stream(s) => s + .dict + .get(b"Subtype") + .ok() + .map(|v| matches!(v, Object::Name(n) if n == b"Widget")) + .unwrap_or(false), _ => false, - } - }).unwrap_or(false); + }) + .unwrap_or(false); if is_widget { register_field(document, dict, &ft, &ff, &v_raw, &kids_ids, scan); } else { for kid_id in kids_ids { if let Ok(kid_obj) = document.get_object(kid_id) { - walk_field(document, kid_obj, &ft, &ff, &v_raw, depth + 1, seen, scan, _evidence); + walk_field( + document, + kid_obj, + &ft, + &ff, + &v_raw, + depth + 1, + seen, + scan, + _evidence, + ); } } } @@ -374,6 +490,8 @@ fn walk_field( } } +/// Count a fillable terminal field, its meaningful value, and missing appearances. +/// Ignore unsupported field types and pushbuttons. fn register_field( document: &Document, field_dict: &Dictionary, @@ -384,7 +502,13 @@ fn register_field( scan: &mut FieldScan, ) { // Normalize FT: should be like "/Tx", "/Ch", "/Btn" - let ft_norm = if ft.starts_with('/') { ft.to_string() } else if !ft.is_empty() { format!("/{}", ft) } else { "".to_string() }; + let ft_norm = if ft.starts_with('/') { + ft.to_string() + } else if !ft.is_empty() { + format!("/{}", ft) + } else { + "".to_string() + }; if !matches!(ft_norm.as_str(), "/Tx" | "/Ch" | "/Btn") { return; } @@ -543,6 +667,8 @@ fn get_current_state(widget_dict: &Dictionary, v_raw: &str) -> String { "".to_string() } +/// Check for a filled value using button, text, or choice-field rules. +/// Use the inherited raw value when no value object is available. fn is_meaningful(document: &Document, ft: &str, v_obj: Option<&Object>, v_raw: &str) -> bool { match ft { "/Btn" => { @@ -559,8 +685,13 @@ fn is_meaningful(document: &Document, ft: &str, v_obj: Option<&Object>, v_raw: & Object::Reference(id) => { if let Ok(resolved) = document.get_object(*id) { match resolved { - Object::Name(name) => return !name.eq_ignore_ascii_case(b"Off") && !name.is_empty(), - Object::String(bytes, _) => return !bytes.eq_ignore_ascii_case(b"Off") && !bytes.is_empty(), + Object::Name(name) => { + return !name.eq_ignore_ascii_case(b"Off") && !name.is_empty(); + } + Object::String(bytes, _) => { + return !bytes.eq_ignore_ascii_case(b"Off") + && !bytes.is_empty(); + } _ => return false, } } @@ -642,6 +773,8 @@ fn is_string_object_meaningful(document: &Document, obj: &Object) -> bool { } } +/// Check for content after stripping literal or hex string delimiters. +/// Ignore whitespace and, for hex strings, zero digits. fn is_string_meaningful(bytes: &[u8]) -> bool { // For Tx: string containing at least one non-whitespace // Whitespace is U+0020, 0009, 000D, 000A @@ -655,20 +788,28 @@ fn is_string_meaningful(bytes: &[u8]) -> bool { // We'll normalize: trim whitespace, then check if contains non-whitespace // For hex strings like "<...>", similar. // Simplify: remove surrounding `(` `)` or `<` `>` if present, then trim. - if s.len() >= 2 && s[0] == b'(' && s[s.len()-1] == b')' { - s = &s[1..s.len()-1]; + if s.len() >= 2 && s[0] == b'(' && s[s.len() - 1] == b')' { + s = &s[1..s.len() - 1]; // Unescape \( \) \\ // For meaningful check, just check if after trimming whitespace there's content } - if s.len() >= 2 && s[0] == b'<' && s[s.len()-1] == b'>' { - s = &s[1..s.len()-1]; + if s.len() >= 2 && s[0] == b'<' && s[s.len() - 1] == b'>' { + s = &s[1..s.len() - 1]; // Hex: remove whitespace and 0s? Probe does gsub(/[ \t\r\n0]/,"",s) for hex. // We'll filter. - let filtered: Vec = s.iter().copied().filter(|&b| !b.is_ascii_whitespace() && b != b'0').collect(); + let filtered: Vec = s + .iter() + .copied() + .filter(|&b| !b.is_ascii_whitespace() && b != b'0') + .collect(); return !filtered.is_empty(); } // Trim whitespace bytes - let trimmed: Vec = s.iter().copied().filter(|&b| b != b' ' && b != b'\t' && b != b'\r' && b != b'\n').collect(); + let trimmed: Vec = s + .iter() + .copied() + .filter(|&b| b != b' ' && b != b'\t' && b != b'\r' && b != b'\n') + .collect(); !trimmed.is_empty() } @@ -676,38 +817,58 @@ fn is_string_meaningful(bytes: &[u8]) -> bool { // Helpers for PDF object graph // --------------------------------------------------------------------------- +/// Return a dictionary or stream dictionary, resolving an indirect object if needed. +/// Return `None` for missing references or other object types. fn resolve_dict<'a>(document: &'a Document, object: &'a Object) -> Option<&'a Dictionary> { match object { Object::Dictionary(dict) => Some(dict), Object::Stream(stream) => Some(&stream.dict), - Object::Reference(id) => document.get_object(*id).ok().and_then(|resolved| match resolved { - Object::Dictionary(dict) => Some(dict), - Object::Stream(stream) => Some(&stream.dict), - _ => None, - }), + Object::Reference(id) => { + document + .get_object(*id) + .ok() + .and_then(|resolved| match resolved { + Object::Dictionary(dict) => Some(dict), + Object::Stream(stream) => Some(&stream.dict), + _ => None, + }) + } _ => None, } } +/// Return an array, resolving an indirect object if needed. +/// Return `None` for missing references or other object types. #[allow(dead_code)] fn resolve_array<'a>(document: &'a Document, object: &'a Object) -> Option<&'a Vec> { match object { Object::Array(items) => Some(items), - Object::Reference(id) => document.get_object(*id).ok().and_then(|resolved| match resolved { - Object::Array(items) => Some(items), - _ => None, - }), + Object::Reference(id) => { + document + .get_object(*id) + .ok() + .and_then(|resolved| match resolved { + Object::Array(items) => Some(items), + _ => None, + }) + } _ => None, } } +/// Read a direct name, string, number, or boolean as text. +/// Return `None` for references and unsupported or missing values. fn get_string_value(dict: &Dictionary, key: &[u8]) -> Option { match dict.get(key) { Ok(Object::Name(name)) => Some(String::from_utf8_lossy(name).to_string()), Ok(Object::String(bytes, _)) => Some(String::from_utf8_lossy(bytes).to_string()), Ok(Object::Integer(i)) => Some(i.to_string()), Ok(Object::Real(r)) => Some(r.to_string()), - Ok(Object::Boolean(b)) => Some(if *b { "true".to_string() } else { "false".to_string() }), + Ok(Object::Boolean(b)) => Some(if *b { + "true".to_string() + } else { + "false".to_string() + }), Ok(Object::Reference(id)) => { // Try to resolve? For FT, Ff, V inheritance, the referenced value would be resolved elsewhere. // But we can try to get string from referenced object via dict lookup not available. @@ -719,18 +880,27 @@ fn get_string_value(dict: &Dictionary, key: &[u8]) -> Option { } } +/// Render a PDF value in the simplified syntax used for field-value inheritance. fn object_to_string(obj: &Object) -> String { match obj { Object::String(bytes, _) => format!("({})", String::from_utf8_lossy(bytes)), Object::Name(name) => format!("/{}", String::from_utf8_lossy(name)), - Object::Boolean(b) => if *b { "true".to_string() } else { "false".to_string() }, + Object::Boolean(b) => { + if *b { + "true".to_string() + } else { + "false".to_string() + } + } Object::Integer(i) => i.to_string(), Object::Real(r) => r.to_string(), Object::Null => "".to_string(), Object::Array(items) => { let mut s = String::from("["); for (i, item) in items.iter().enumerate() { - if i > 0 { s.push(' '); } + if i > 0 { + s.push(' '); + } s.push_str(&object_to_string(item)); } s.push(']'); @@ -743,6 +913,8 @@ fn object_to_string(obj: &Object) -> String { } } +/// Collect references from a direct or indirect array, ignoring non-reference items. +/// Return an empty vector when the object cannot be resolved to an array. fn get_array_ids(document: &Document, obj: &Object) -> Vec { let mut ids = Vec::new(); match obj { @@ -750,15 +922,13 @@ fn get_array_ids(document: &Document, obj: &Object) -> Vec { for item in items { if let Object::Reference(id) = item { ids.push(*id); - } else if let Object::Reference(id) = resolve_reference(document, item) { - ids.push(id); } } } Object::Reference(id) => { if let Ok(Object::Array(items)) = document.get_object(*id) { for item in items { - if let Object::Reference kid) = item { + if let Object::Reference(kid) = item { ids.push(*kid); } } @@ -769,14 +939,6 @@ fn get_array_ids(document: &Document, obj: &Object) -> Vec { ids } -fn resolve_reference(document: &Document, obj: &Object) -> Option { - if let Object::Reference(id) = obj { - Some(*id) - } else { - None - } -} - // --------------------------------------------------------------------------- // XMP helpers // --------------------------------------------------------------------------- @@ -800,6 +962,8 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { haystack.windows(needle.len()).any(|w| w == needle) } +/// Extract a nonempty trimmed property from XMP attribute or element text. +/// Match property names without ASCII case sensitivity using byte scans, not XML parsing. fn xmp_extract(payload: &[u8], prop: &str) -> Option { let payload_str = String::from_utf8_lossy(payload); let payload_s = payload_str.as_ref(); @@ -840,7 +1004,10 @@ fn xmp_extract(payload: &[u8], prop: &str) -> Option { _ => { // Unquoted (should not happen for XMP but handle) let mut end = 0; - while end < rest.len() && !rest[end].is_ascii_whitespace() && rest[end] != b'>' { + while end < rest.len() + && !rest[end].is_ascii_whitespace() + && rest[end] != b'>' + { end += 1; } if end > 0 { @@ -881,12 +1048,12 @@ fn xmp_extract(payload: &[u8], prop: &str) -> Option { } } - // Second pass for element form with prefix: search for `:prop>` + // Second pass for element form with prefix: search for `:prop>` let prefixed = format!(":{}", prop); if let Some(pos) = find_ci(payload_bytes, prefixed.as_bytes()) { let after = pos + prefixed.len(); if after < payload_bytes.len() && payload_bytes[after] == b'>' { - let rest = &payload_bytes[after+1..]; + let rest = &payload_bytes[after + 1..]; if let Some(end) = rest.iter().position(|&b| b == b'<') { let val = &rest[..end]; let s = String::from_utf8_lossy(val).trim().to_string(); @@ -901,12 +1068,14 @@ fn xmp_extract(payload: &[u8], prop: &str) -> Option { rest = trim_ascii_start(rest); if !rest.is_empty() && rest[0] == b'=' { rest = trim_ascii_start(&rest[1..]); - if !rest.is_empty() && (rest[0]==b'"' || rest[0]==b'\'') { + if !rest.is_empty() && (rest[0] == b'"' || rest[0] == b'\'') { let quote = rest[0]; rest = &rest[1..]; if let Some(end) = rest.iter().position(|&b| b == quote) { let s = String::from_utf8_lossy(&rest[..end]).trim().to_string(); - if !s.is_empty() { return Some(s); } + if !s.is_empty() { + return Some(s); + } } } } @@ -916,11 +1085,14 @@ fn xmp_extract(payload: &[u8], prop: &str) -> Option { None } +/// Find the first ASCII case-insensitive byte match, or `None` for an empty needle. fn find_ci(haystack: &[u8], needle: &[u8]) -> Option { if needle.is_empty() || haystack.len() < needle.len() { return None; } - haystack.windows(needle.len()).position(|w| w.eq_ignore_ascii_case(needle)) + haystack + .windows(needle.len()) + .position(|w| w.eq_ignore_ascii_case(needle)) } fn trim_ascii_start(bytes: &[u8]) -> &[u8] { @@ -943,23 +1115,50 @@ mod tests { } impl TestField { + /// Build a filled test field with an appearance entry. const fn human(name: &'static str, value: &'static str) -> Self { - Self { name, value: Some(value), appearance: true } + Self { + name, + value: Some(value), + appearance: true, + } } + /// Build a filled test field without an appearance entry. const fn machine(name: &'static str, value: &'static str) -> Self { - Self { name, value: Some(value), appearance: false } + Self { + name, + value: Some(value), + appearance: false, + } } + /// Build a test field with neither a value nor an appearance entry. const fn blank(name: &'static str) -> Self { - Self { name, value: None, appearance: false } + Self { + name, + value: None, + appearance: false, + } } } + /// Serialize a test PDF with text fields, an optional appearance flag, and optional XMP. fn build_pdf(fields: Vec, need_appearances: bool, xmp: Option) -> Vec { let mut document = Document::with_version("1.7"); - let content_id = document.add_object(Object::Stream(lopdf::Stream::new(Dictionary::new(), Vec::new()))); + let content_id = document.add_object(Object::Stream(lopdf::Stream::new( + Dictionary::new(), + Vec::new(), + ))); let mut page = Dictionary::new(); page.set("Type", Object::Name(b"Page".to_vec())); - page.set("MediaBox", Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(595), Object::Integer(842)])); + page.set( + "MediaBox", + Object::Array(vec![ + Object::Integer(0), + Object::Integer(0), + Object::Integer(595), + Object::Integer(842), + ]), + ); page.set("Contents", Object::Reference(content_id)); let page_id = document.add_object(Object::Dictionary(page)); let mut pages = Dictionary::new(); @@ -973,17 +1172,25 @@ mod tests { let mut field_refs = Vec::new(); for field in &fields { let mut dict = Dictionary::new(); - dict.set("T", Object::String(field.name.as_bytes().to_vec(), StringFormat::Literal)); + dict.set( + "T", + Object::String(field.name.as_bytes().to_vec(), StringFormat::Literal), + ); dict.set("FT", Object::Name(b"Tx".to_vec())); if let Some(v) = field.value { - dict.set("V", Object::String(v.as_bytes().to_vec(), StringFormat::Literal)); + dict.set( + "V", + Object::String(v.as_bytes().to_vec(), StringFormat::Literal), + ); } if field.appearance { let mut ap = Dictionary::new(); ap.set("N", Object::Null); dict.set("AP", Object::Dictionary(ap)); } - field_refs.push(Object::Reference(document.add_object(Object::Dictionary(dict)))); + field_refs.push(Object::Reference( + document.add_object(Object::Dictionary(dict)), + )); } let mut catalog = Dictionary::new(); catalog.set("Type", Object::Name(b"Catalog".to_vec())); @@ -1004,7 +1211,10 @@ mod tests { let mut meta_dict = Dictionary::new(); meta_dict.set("Type", Object::Name(b"Metadata".to_vec())); meta_dict.set("Subtype", Object::Name(b"XML".to_vec())); - let meta_id = document.add_object(Object::Stream(lopdf::Stream::new(meta_dict, xmp_str.into_bytes()))); + let meta_id = document.add_object(Object::Stream(lopdf::Stream::new( + meta_dict, + xmp_str.into_bytes(), + ))); if let Ok(Object::Dictionary(cat)) = document.get_object_mut(catalog_id) { cat.set("Metadata", Object::Reference(meta_id)); } @@ -1020,33 +1230,62 @@ mod tests { assert_eq!(r.classification, FfpClassification::Unreadable); } + /// Verify empty fields classify as a blank form with zero filled fields. #[test] fn blank_form_not_machine() { - let bytes = build_pdf(vec![TestField::blank("surname"), TestField::blank("given")], false, None); + let bytes = build_pdf( + vec![TestField::blank("surname"), TestField::blank("given")], + false, + None, + ); let r = classify_pdf(&bytes); assert_eq!(r.classification, FfpClassification::BlankForm); assert_eq!(r.filled_fields, 0); } + /// Verify missing appearances and NeedAppearances imply suspected machine filling. #[test] fn machine_suspected_without_marker() { - let bytes = build_pdf(vec![TestField::machine("surname", "Smith"), TestField::machine("given", "Ada")], true, None); + let bytes = build_pdf( + vec![ + TestField::machine("surname", "Smith"), + TestField::machine("given", "Ada"), + ], + true, + None, + ); let r = classify_pdf(&bytes); assert_eq!(r.classification, FfpClassification::MachineFilledSuspected); } + /// Verify an XMP machine declaration determines classification and retains its tool. #[test] fn machine_filled_with_marker() { let xmp = r#""#.to_string(); - let bytes = build_pdf(vec![TestField::machine("surname", "Smith")], true, Some(xmp)); + let bytes = build_pdf( + vec![TestField::machine("surname", "Smith")], + true, + Some(xmp), + ); let r = classify_pdf(&bytes); assert_eq!(r.classification, FfpClassification::MachineFilled); - assert_eq!(r.declared.as_ref().unwrap().tool.as_deref(), Some("blocky-writer")); + assert_eq!( + r.declared.as_ref().unwrap().tool.as_deref(), + Some("blocky-writer") + ); } + /// Verify filled fields without a declaration retain unknown provenance. #[test] fn viewer_filled_is_unknown() { - let bytes = build_pdf(vec![TestField::human("surname", "Smith"), TestField::human("given", "Ada")], false, None); + let bytes = build_pdf( + vec![ + TestField::human("surname", "Smith"), + TestField::human("given", "Ada"), + ], + false, + None, + ); let r = classify_pdf(&bytes); assert_eq!(r.classification, FfpClassification::FilledUnknown); assert_eq!(r.appearances, FfpAppearances::Generated); diff --git a/crates/presswerk-print/src/ipp_server.rs b/crates/presswerk-print/src/ipp_server.rs index c449cac..f6ed4ca 100644 --- a/crates/presswerk-print/src/ipp_server.rs +++ b/crates/presswerk-print/src/ipp_server.rs @@ -1892,7 +1892,9 @@ mod tests { policy: FormProvenancePolicy, ) -> (SharedState, Arc>) { let mut state = make_shared_state_with_dir(data_dir); - let audit_log = Arc::new(Mutex::new(AuditLog::open_in_memory().expect("open audit log"))); + let audit_log = Arc::new(Mutex::new( + AuditLog::open_in_memory().expect("open audit log"), + )); state.audit_log = Some(Arc::clone(&audit_log)); state.form_provenance_policy = policy; (state, audit_log) @@ -2431,11 +2433,14 @@ mod tests { ]; let data = build_test_ipp_request(OP_PRINT_JOB, request_id, &attrs, document); let request = parse_ipp_request(&data).expect("parse_ipp_request failed"); - let peer: SocketAddr = "10.0.0.9:1234".parse().expect("hardcoded address is invalid"); + let peer: SocketAddr = "10.0.0.9:1234" + .parse() + .expect("hardcoded address is invalid"); let response = dispatch_operation(&request, peer, state); parse_ipp_request(&response).expect("parse_ipp_request failed") } + /// Verify network submission stores queryable machine provenance while remaining pending. #[test] fn network_print_job_records_machine_filled_provenance() { let tmp = make_test_data_dir(); @@ -2451,7 +2456,12 @@ mod tests { let provenance = &jobs[0].form_provenance; assert!(provenance.is_form()); - assert!(provenance.is_machine_filled() || provenance.classification == presswerk_core::provenance::FfpClassification::MachineFilledSuspected, "{provenance:?}"); + assert!( + provenance.is_machine_filled() + || provenance.classification + == presswerk_core::provenance::FfpClassification::MachineFilledSuspected, + "{provenance:?}" + ); assert_eq!(provenance.filled_fields, 1); // Recording alone must not change routing. @@ -2460,19 +2470,26 @@ mod tests { // And the determination is queryable for audit and routing. assert_eq!( queue - .get_jobs_with_form_origin(presswerk_core::provenance::FfpClassification::MachineFilled) + .get_jobs_with_form_origin( + presswerk_core::provenance::FfpClassification::MachineFilled + ) .expect("query by origin") .len() - + queue.get_jobs_with_form_origin(presswerk_core::provenance::FfpClassification::MachineFilledSuspected).expect("query suspected").len(), + + queue + .get_jobs_with_form_origin( + presswerk_core::provenance::FfpClassification::MachineFilledSuspected + ) + .expect("query suspected") + .len(), 1 ); } + /// Verify network submission records machine provenance in the audit trail. #[test] fn network_print_job_writes_form_provenance_to_the_audit_trail() { let tmp = make_test_data_dir(); - let (state, audit) = - make_shared_state_with_audit(tmp.path(), FormProvenancePolicy::Record); + let (state, audit) = make_shared_state_with_audit(tmp.path(), FormProvenancePolicy::Record); let parsed = submit_pdf(&state, &machine_filled_pdf(), 601); assert_eq!(parsed.operation_id, STATUS_OK); @@ -2485,14 +2502,18 @@ mod tests { .expect("form_provenance audit entry"); assert!(entry.success); let details = entry.details.as_deref().expect("details present"); - assert!(details.contains("\"classification\":\"machine-filled") || details.contains("\"classification\":\"machine-filled-suspected\""), "{details}"); + assert!( + details.contains("\"classification\":\"machine-filled") + || details.contains("\"classification\":\"machine-filled-suspected\""), + "{details}" + ); } + /// Verify invalid PDF input remains unreadable and creates no form-provenance audit entry. #[test] fn non_form_documents_are_not_audited_as_forms() { let tmp = make_test_data_dir(); - let (state, audit) = - make_shared_state_with_audit(tmp.path(), FormProvenancePolicy::Record); + let (state, audit) = make_shared_state_with_audit(tmp.path(), FormProvenancePolicy::Record); let parsed = submit_pdf(&state, b"not really a PDF", 602); assert_eq!(parsed.operation_id, STATUS_OK); @@ -2512,6 +2533,7 @@ mod tests { ); } + /// Verify review policy holds a machine-filled job in IPP and storage and audits the hold. #[test] fn hold_policy_parks_machine_filled_form_for_review() { let tmp = make_test_data_dir(); @@ -2537,9 +2559,11 @@ mod tests { let log = audit.lock().expect("audit mutex poisoned"); let entries = log.recent_entries(10).expect("recent entries"); - assert!(entries - .iter() - .any(|e| e.action == "job_held_machine_filled")); + assert!( + entries + .iter() + .any(|e| e.action == "job_held_machine_filled") + ); } #[test] @@ -2562,6 +2586,7 @@ mod tests { assert_eq!(jobs[0].status, JobStatus::Pending); } + /// Verify disabled inspection leaves provenance unreadable and the job pending. #[test] fn off_policy_skips_inspection_entirely() { let tmp = make_test_data_dir(); @@ -2572,7 +2597,10 @@ mod tests { let queue = state.job_queue.lock().expect("mutex poisoned"); let jobs = queue.get_all_jobs().expect("get_all_jobs"); - assert_eq!(jobs[0].form_provenance.classification, presswerk_core::provenance::FfpClassification::Unreadable); + assert_eq!( + jobs[0].form_provenance.classification, + presswerk_core::provenance::FfpClassification::Unreadable + ); assert!(!jobs[0].form_provenance.is_machine_filled()); assert_eq!(jobs[0].status, JobStatus::Pending); } diff --git a/crates/presswerk-print/src/queue.rs b/crates/presswerk-print/src/queue.rs index 2199876..5c48fbf 100644 --- a/crates/presswerk-print/src/queue.rs +++ b/crates/presswerk-print/src/queue.rs @@ -431,7 +431,9 @@ fn row_to_print_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { _ => cls, }; match mapped { - FfpClassification::NoForm => FfpRecord::no_form(vec!["FFP-E-NO-ACROFORM".to_string()]), + FfpClassification::NoForm => { + FfpRecord::no_form(vec!["FFP-E-NO-ACROFORM".to_string()]) + } FfpClassification::BlankForm => FfpRecord { ffp: "1.0".to_string(), classification: FfpClassification::BlankForm, @@ -442,7 +444,9 @@ fn row_to_print_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { declared: None, evidence: vec!["FFP-E-NO-VALUES".to_string()], }, - FfpClassification::Unreadable => FfpRecord::unreadable("legacy row without provenance"), + FfpClassification::Unreadable => { + FfpRecord::unreadable("legacy row without provenance") + } _ => FfpRecord { ffp: "1.0".to_string(), classification: mapped, @@ -668,6 +672,7 @@ mod tests { job } + /// Verify queue storage preserves the full provenance record and declared tool. #[test] fn provenance_roundtrips_through_the_queue() { let queue = JobQueue::open_in_memory().expect("open in-memory db"); @@ -678,13 +683,23 @@ mod tests { assert_eq!(retrieved.form_provenance, job.form_provenance); assert!(retrieved.form_provenance.is_machine_filled()); assert_eq!( - retrieved.form_provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), + retrieved + .form_provenance + .declared + .as_ref() + .and_then(|d| d.tool.as_deref()), Some("blocky-writer 0.4.2") ); // canonical line preserved - assert!(retrieved.form_provenance.canonical_line().contains("machine-filled")); + assert!( + retrieved + .form_provenance + .canonical_line() + .contains("machine-filled") + ); } + /// Verify classification queries select machine and unreadable jobs independently. #[test] fn jobs_can_be_filtered_by_form_origin() { let queue = JobQueue::open_in_memory().expect("open in-memory db"); @@ -706,24 +721,31 @@ mod tests { assert_eq!(unreadable_jobs.len(), 1); assert_eq!(unreadable_jobs[0].id, plain.id); - assert!(queue - .get_jobs_with_form_origin(FfpClassification::FilledUnknown) - .expect("query human") - .is_empty()); + assert!( + queue + .get_jobs_with_form_origin(FfpClassification::FilledUnknown) + .expect("query human") + .is_empty() + ); } + /// Verify both all-job and pending-job queries restore stored provenance. #[test] fn every_read_path_returns_the_stored_provenance() { let queue = JobQueue::open_in_memory().expect("open in-memory db"); let job = machine_filled_job(); queue.insert_job(&job).expect("insert"); - assert!(queue.get_all_jobs().expect("get_all")[0] - .form_provenance - .is_machine_filled()); - assert!(queue.get_pending_jobs().expect("get_pending")[0] - .form_provenance - .is_machine_filled()); + assert!( + queue.get_all_jobs().expect("get_all")[0] + .form_provenance + .is_machine_filled() + ); + assert!( + queue.get_pending_jobs().expect("get_pending")[0] + .form_provenance + .is_machine_filled() + ); } /// The `jobs` table as it stood before form provenance existed. @@ -749,6 +771,8 @@ mod tests { ); "#; + /// Verify schema migration preserves legacy jobs with unreadable provenance + /// and supports storing and querying provenance on new jobs. #[test] fn legacy_database_is_migrated_and_reads_as_uninspected() { let dir = tempfile::TempDir::new().expect("create temp dir"); @@ -757,7 +781,8 @@ mod tests { // Write a row using the pre-provenance schema. { let conn = rusqlite::Connection::open(&path).expect("open legacy db"); - conn.execute_batch(LEGACY_SCHEMA).expect("create legacy table"); + conn.execute_batch(LEGACY_SCHEMA) + .expect("create legacy table"); let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO jobs (id, source, status, document_type, document_name, @@ -796,7 +821,10 @@ mod tests { // A row with no provenance must read back as unreadable, never as a guessed determination. let provenance = &jobs[0].form_provenance; assert_eq!(provenance.classification, FfpClassification::Unreadable); - assert_eq!(provenance.form, presswerk_core::provenance::FfpForm::Unknown); + assert_eq!( + provenance.form, + presswerk_core::provenance::FfpForm::Unknown + ); assert!(!provenance.is_machine_filled()); // And new jobs written after migration carry their determination. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 2ffaa8f..83d0e1a 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,7 +1,7 @@ Presswerk (Print Doctor) is a local-first print router. It discovers IPP printers via mDNS, lets you print PDFs and images from your device, and records a complete audit trail — all without a cloud account. • No analytics, no ads, no sign-in. Files stay on-device. -• Form-fill provenance (FFP): machine-filled forms are distinguished from hand-filled ones and surfaced before printing, so blank print hazards are caught. +• Form-fill provenance (FFP): machine-filled forms are distinguished from hand-filled ones and shown on the Advanced Jobs page; blank-print hazards are flagged there. • Works offline on your local network. Privacy: https://hyperpolymath.dev/presswerk/privacy diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index a4e5448..ea3dce2 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Local, high-assurance print router — your files never leave the device. +Local, high-assurance print router — documents are sent to your selected printer when you print.