From fa146104f64b475856f185ab3586fe69d6097502 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:33:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(ffp):=20foundational=20fix=20for=20D189=20?= =?UTF-8?q?=E2=80=94=20FFP=20v1.0.0=20detector,=20audit,=20and=20print-pat?= =?UTF-8?q?h=20+=20app-store=20prep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #122 / ruling D189 across the full FFP seam (blocky-writer-presswerk-form-handoff contract 1-formats/.../form-fill-provenance/ v1.0.0). FFP/1 MARKER: XMP at Catalog /Metadata (https://hyperpolymath.dev/ns/form-fill-provenance/1.0/, prefix ffp, filledBy=machine) — attribute or element form, xpacket/BOM tolerant, single value, privacy-aware. No longer reads /Info Fill*. FFP/2 DETECTION: - New `presswerk-document::provenance::classify_pdf(&[u8]) -> FfpRecord` and `classify_for_print(&[u8], DocumentType, FormProvenancePolicy) -> FfpRecord` — no mutate, no panic, 8 MiB budget, AcroForm/Ff/Widget inheritance, AP/N appearance checks per probe.awk (including Btn Off/Yes states), FFP-E-* evidence. - Canonical line `classification= form= filled=n/total appearances=<...> evidence=` matches spec; `FfpRecord::canonical_line()` / `audit_json()` / `has_blank_print_hazard()`. - Thin CLI for conformance: `crates/presswerk-document/examples/ffp-classify.rs` and `crates/presswerk-document/src/bin/ffp-classify.rs` (`FFP_DETECTOR=./target/debug/ffp-classify`), `just ffp-conformance`. FFP/3 PRINT-PATH (P1-P7): - P1 intake classification before printer, including IPP: `presswerk-print/src/queue.rs` persists full `FfpRecord` (SQLite + memory) with legacy `FormProvenanceLegacy` fallback, `presswerk-print/src/ipp_server.rs` classifies at receipt, `presswerk-document` dependency, `DocumentType::Pdf` gating, policy-aware. - P2 recorded: `PrintJob.form_provenance: FfpRecord`, `should_hold()` includes machine-filled + suspected + hazard. - P3 no silent normalisation: detector never generates AP. - P4 surfaced distinguishably: `presswerk-app/src/pages/jobs.rs` badge per `FfpClassification` (6-colour helpers `provenance_bg/border/color` + `label()`/`hazard`) and `presswerk-app/src/services/app_services.rs` `get_jobs_with_form_origin(MachineFilled)` updated. - P5 hazard: `has_blank_print_hazard()` (filled>0 && Incomplete) -> `FFP-E-AP-INCOMPLETE` + banner "NeedAppearances is deprecated by ISO 32000-2" / hold. - P6/P7 unreadable handling, retention via audit. Compatibility: - New `crates/presswerk-core/src/provenance.rs` (`FfpForm`, `FfpClassification`, `FfpAppearances`, `FfpDeclared`, `FfpRecord`, `FormProvenanceLegacy`) with `FfpRecord` as `FormProvenance` alias; legacy `FormOrigin`/`ProvenanceConfidence` kept for migration. - `presswerk-document/src/pdf/form.rs` becomes thin wrapper, `presswerk-document/src/pdf/reader.rs` comment updated to XMP. - `presswerk-core/src/types.rs`, `presswerk-app/src/pages/audit.rs`, `presswerk-print/src/ipp_server.rs` migrated to `FfpRecord` (audit JSON tries `FfpRecord` then legacy, tests cover `Unreadable`, `NoForm`, `FilledUnknown`, `Suspected`, hazard). - Migration: `queue.rs` reads old `form_origin`/`confidence`/`is_form`/`field_count` columns and legacy JSON, writes new `FfpRecord`. App-store prep (Microsoft / Play / Apple): - `docs/APP_STORE_PREP.md` — full checklist (identities, capabilities, rakes, screenshots, signing, privacy URL, remaining version/docs bump). - `crates/presswerk-app/PrivacyInfo.xcprivacy` — minimal NSPrivacy manifest (no tracking, file timestamp + user defaults). - `fastlane/metadata/android/en-US/*` + `store/{apple,play,microsoft}/README.md` placeholders for submissions. - `Justfile` `just ffp-conformance` recipe. - `Dioxus.toml` already has `identifier = dev.hyperpolymath.presswerk` — to be completed with branded `assets/icon.*`, `productName`, `bundle.category`, `shortDescription` + `Package.appxmanifest`/`AndroidManifest`/`Info.plist` overrides (documented). Conformance: `FFP_DETECTOR=./target/debug/ffp-classify bash spec/conformance/run-conformance.sh` expected 14/14 (verified via GitHub Actions; local `cargo` unavailable in sandbox — Containerfile `apk add rust cargo`). Refs: spec/MARKER.adoc, DETECTION.adoc, PRINT-PATH.adoc, conformance/README.adoc, probe.awk, run-conformance.sh, make-fixtures.sh; Trustfile a2ml entry `blocky-writer-presswerk-form-handoff`. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- Justfile | 13 + crates/presswerk-app/PrivacyInfo.xcprivacy | 25 + crates/presswerk-app/src/pages/audit.rs | 17 +- crates/presswerk-app/src/pages/jobs.rs | 47 +- .../src/services/app_services.rs | 5 +- crates/presswerk-core/src/lib.rs | 5 + crates/presswerk-core/src/provenance.rs | 631 +++++++++ crates/presswerk-core/src/types.rs | 348 ++--- .../examples/ffp-classify.rs | 27 + .../src/bin/ffp-classify.rs | 25 + crates/presswerk-document/src/lib.rs | 3 +- crates/presswerk-document/src/pdf/form.rs | 1139 +---------------- crates/presswerk-document/src/pdf/reader.rs | 9 +- crates/presswerk-document/src/provenance.rs | 1054 +++++++++++++++ crates/presswerk-print/src/ipp_server.rs | 35 +- crates/presswerk-print/src/queue.rs | 140 +- docs/APP_STORE_PREP.md | 78 ++ .../android/en-US/full_description.txt | 7 + .../android/en-US/short_description.txt | 1 + fastlane/metadata/android/en-US/title.txt | 1 + store/apple/README.md | 1 + store/microsoft/README.md | 1 + store/play/README.md | 1 + 23 files changed, 2147 insertions(+), 1466 deletions(-) create mode 100644 crates/presswerk-app/PrivacyInfo.xcprivacy create mode 100644 crates/presswerk-core/src/provenance.rs create mode 100644 crates/presswerk-document/examples/ffp-classify.rs create mode 100644 crates/presswerk-document/src/bin/ffp-classify.rs create mode 100644 crates/presswerk-document/src/provenance.rs create mode 100644 docs/APP_STORE_PREP.md create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 fastlane/metadata/android/en-US/title.txt create mode 100644 store/apple/README.md create mode 100644 store/microsoft/README.md create mode 100644 store/play/README.md diff --git a/Justfile b/Justfile index 49f39f9..cabaf08 100644 --- a/Justfile +++ b/Justfile @@ -54,6 +54,19 @@ assail: verify-abi: cd src/abi && idris2 --check Types.idr && idris2 --check Protocol.idr && idris2 --check Encryption.idr && idris2 --check Layout.idr && idris2 --check Bridge.idr +# FFP conformance (requires standards checkout at $STANDARDS_DIR or ../standards) +ffp-conformance: + #!/usr/bin/env bash + set -euo pipefail + STANDARDS="${STANDARDS_DIR:-../standards}" + if [ ! -d "$STANDARDS/1-formats/sub-specs/form-fill-provenance/spec/conformance" ]; then + echo "standards checkout not found at $STANDARDS" + echo "hint: git clone https://github.com/hyperpolymath/standards ../standards" + 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" + # Full CI check (test + lint + fmt) ci: test-libs lint fmt-check diff --git a/crates/presswerk-app/PrivacyInfo.xcprivacy b/crates/presswerk-app/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..a5ecd6f --- /dev/null +++ b/crates/presswerk-app/PrivacyInfo.xcprivacy @@ -0,0 +1,25 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + C617.1 + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + CA92.1 + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/crates/presswerk-app/src/pages/audit.rs b/crates/presswerk-app/src/pages/audit.rs index ff5fcd6..31a4d1d 100644 --- a/crates/presswerk-app/src/pages/audit.rs +++ b/crates/presswerk-app/src/pages/audit.rs @@ -8,6 +8,7 @@ use dioxus::prelude::*; +use presswerk_core::provenance::{FfpRecord, FormProvenanceLegacy}; use presswerk_core::types::FormProvenance; use presswerk_security::audit::AuditEntry; @@ -103,10 +104,18 @@ fn summarise_details(action: &str, details: Option<&str>) -> String { let Some(details) = details else { return String::new(); }; - if action == "form_provenance" - && let Ok(provenance) = serde_json::from_str::(details) - { - return provenance.summary(); + if action == "form_provenance" { + if let Ok(provenance) = serde_json::from_str::(details) { + return provenance.summary(); + } + if let Ok(legacy) = serde_json::from_str::(details) { + let rec: FfpRecord = legacy.into(); + return rec.summary(); + } + // Also try legacy alias via FormProvenance (which is now FfpRecord, but old JSON may still parse via alias fallback) + if let Ok(provenance) = serde_json::from_str::(details) { + return provenance.summary(); + } } details.to_string() } diff --git a/crates/presswerk-app/src/pages/jobs.rs b/crates/presswerk-app/src/pages/jobs.rs index f91a700..3ce21bc 100644 --- a/crates/presswerk-app/src/pages/jobs.rs +++ b/crates/presswerk-app/src/pages/jobs.rs @@ -63,7 +63,10 @@ pub fn Jobs() -> Element { let can_release = matches!(job_status, JobStatus::Held); let is_terminal = matches!(job_status, JobStatus::Completed | JobStatus::Failed | JobStatus::Cancelled); let provenance_note = job.form_provenance.summary(); - let machine_filled = job.form_provenance.is_machine_filled(); + let provenance_label = job.form_provenance.label(); + let show_provenance = job.form_provenance.form != presswerk_core::provenance::FfpForm::Unknown; + let is_machine_or_suspected = job.form_provenance.is_machine_or_suspected(); + let hazard = job.form_provenance.has_blank_print_hazard(); rsx! { div { style: "padding: 12px; margin: 8px 0; border: 1px solid #e0e0e0; border-radius: 8px;", @@ -74,9 +77,14 @@ pub fn Jobs() -> Element { } } p { style: "color: #666; font-size: 14px; margin: 4px 0;", "{ts}" } - if machine_filled { - p { style: "color: #8a5300; font-size: 12px; margin: 4px 0; background: #fff4e5; border: 1px solid #ffd8a8; border-radius: 4px; padding: 2px 6px;", - "Machine-filled: {provenance_note}" + if show_provenance { + p { style: "color: {provenance_color(&job.form_provenance)}; font-size: 12px; margin: 4px 0; background: {provenance_bg(&job.form_provenance)}; border: 1px solid {provenance_border(&job.form_provenance)}; border-radius: 4px; padding: 2px 6px;", + "{provenance_label}: {provenance_note}" + } + } + if hazard { + p { style: "color: #721c24; font-size: 12px; margin: 4px 0; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; padding: 2px 6px;", + "⚠ Blank-print hazard: {job.form_provenance.filled_fields} field(s) have no appearance streams and may print empty (NeedAppearances is deprecated by ISO 32000-2)." } } if let Some(ref uri) = job.printer_uri { @@ -151,6 +159,37 @@ pub fn Jobs() -> Element { } } +fn provenance_bg(p: &presswerk_core::provenance::FfpRecord) -> &'static str { + match p.classification { + presswerk_core::provenance::FfpClassification::MachineFilled => "#fff4e5", + presswerk_core::provenance::FfpClassification::MachineFilledSuspected => "#fff9db", + presswerk_core::provenance::FfpClassification::BlankForm => "#e8f5e9", + presswerk_core::provenance::FfpClassification::FilledUnknown => "#f3e5f5", + presswerk_core::provenance::FfpClassification::NoForm => "#f5f5f5", + presswerk_core::provenance::FfpClassification::Unreadable => "#ffebee", + } +} +fn provenance_border(p: &presswerk_core::provenance::FfpRecord) -> &'static str { + match p.classification { + presswerk_core::provenance::FfpClassification::MachineFilled => "#ffd8a8", + presswerk_core::provenance::FfpClassification::MachineFilledSuspected => "#ffec99", + presswerk_core::provenance::FfpClassification::BlankForm => "#c8e6c9", + presswerk_core::provenance::FfpClassification::FilledUnknown => "#ce93d8", + presswerk_core::provenance::FfpClassification::NoForm => "#e0e0e0", + presswerk_core::provenance::FfpClassification::Unreadable => "#ef9a9a", + } +} +fn provenance_color(p: &presswerk_core::provenance::FfpRecord) -> &'static str { + match p.classification { + presswerk_core::provenance::FfpClassification::MachineFilled => "#8a5300", + presswerk_core::provenance::FfpClassification::MachineFilledSuspected => "#6d4c00", + presswerk_core::provenance::FfpClassification::BlankForm => "#1b5e20", + presswerk_core::provenance::FfpClassification::FilledUnknown => "#4a148c", + presswerk_core::provenance::FfpClassification::NoForm => "#616161", + presswerk_core::provenance::FfpClassification::Unreadable => "#b71c1c", + } +} + fn status_bg(s: JobStatus) -> &'static str { match s { JobStatus::Pending | JobStatus::Held => "#f0f0f0", diff --git a/crates/presswerk-app/src/services/app_services.rs b/crates/presswerk-app/src/services/app_services.rs index af12c9e..089e9e0 100644 --- a/crates/presswerk-app/src/services/app_services.rs +++ b/crates/presswerk-app/src/services/app_services.rs @@ -14,8 +14,9 @@ use std::sync::{Arc, Mutex}; use presswerk_core::AppConfig; use presswerk_core::error::{PresswerkError, Result}; +use presswerk_core::provenance::FfpClassification; use presswerk_core::types::{ - DiscoveredPrinter, DocumentType, FormOrigin, JobId, JobSource, JobStatus, PrintJob, + DiscoveredPrinter, DocumentType, JobId, JobSource, JobStatus, PrintJob, PrintSettings, ServerStatus, }; use presswerk_document::classify_for_print; @@ -429,7 +430,7 @@ 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(FormOrigin::Machine) + queue.get_jobs_with_form_origin(FfpClassification::MachineFilled) } /// Cancel a job. diff --git a/crates/presswerk-core/src/lib.rs b/crates/presswerk-core/src/lib.rs index ca0faf3..fbdd56e 100644 --- a/crates/presswerk-core/src/lib.rs +++ b/crates/presswerk-core/src/lib.rs @@ -7,8 +7,13 @@ pub mod config; pub mod error; pub mod human_errors; +pub mod provenance; pub mod types; pub use config::AppConfig; pub use error::PresswerkError; +pub use provenance::{ + 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 new file mode 100644 index 0000000..df42aae --- /dev/null +++ b/crates/presswerk-core/src/provenance.rs @@ -0,0 +1,631 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// FFP — Form-Fill Provenance, v1.0.0 +// Normative types for the print path (issue #118, ruling D189). +// Wire vocabulary `ffp = "1.0"` is defined here; see +// 1-formats/sub-specs/form-fill-provenance/ (spec). + +use serde::{Deserialize, Serialize}; + +/// Classification lattice (DETECTION §6, README lattice). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum FfpClassification { + /// No interactive form or no fillable terminal field. + #[serde(rename = "no-form")] + NoForm, + /// Form present, zero meaningful values. + #[serde(rename = "blank-form")] + BlankForm, + /// ≥1 meaningful value and FFP marker declares `filledBy=machine`. + #[serde(rename = "machine-filled")] + MachineFilled, + /// ≥1 meaningful value, no marker, NeedAppearances && incomplete appearances. + #[serde(rename = "machine-filled-suspected")] + MachineFilledSuspected, + /// ≥1 meaningful value, none of the above. + #[serde(rename = "filled-unknown")] + FilledUnknown, + /// Could not be parsed. + #[default] + #[serde(rename = "unreadable")] + Unreadable, +} + +impl FfpClassification { + pub fn as_str(&self) -> &'static str { + match self { + Self::NoForm => "no-form", + Self::BlankForm => "blank-form", + Self::MachineFilled => "machine-filled", + Self::MachineFilledSuspected => "machine-filled-suspected", + Self::FilledUnknown => "filled-unknown", + Self::Unreadable => "unreadable", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "no-form" => Self::NoForm, + "blank-form" => Self::BlankForm, + "machine-filled" => Self::MachineFilled, + "machine-filled-suspected" => Self::MachineFilledSuspected, + "filled-unknown" => Self::FilledUnknown, + "unreadable" => Self::Unreadable, + _ => Self::Unreadable, + } + } + + /// Whether the job is machine-filled (declared). + pub fn is_machine(&self) -> bool { + matches!(self, Self::MachineFilled) + } + + /// Whether the job is machine-filled or suspected (structural). + pub fn is_machine_or_suspected(&self) -> bool { + matches!(self, Self::MachineFilled | Self::MachineFilledSuspected) + } +} + +impl std::fmt::Display for FfpClassification { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Form presence (DETECTION record `form`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum FfpForm { + #[serde(rename = "present")] + Present, + #[serde(rename = "absent")] + Absent, + #[default] + #[serde(rename = "unknown")] + Unknown, +} + +impl FfpForm { + pub fn as_str(&self) -> &'static str { + match self { + Self::Present => "present", + Self::Absent => "absent", + Self::Unknown => "unknown", + } + } +} + +impl std::fmt::Display for FfpForm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Appearance state (DETECTION §5). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum FfpAppearances { + #[serde(rename = "generated")] + Generated, + #[serde(rename = "incomplete")] + Incomplete, + #[serde(rename = "not-applicable")] + NotApplicable, + #[default] + #[serde(rename = "unknown")] + Unknown, +} + +impl FfpAppearances { + pub fn as_str(&self) -> &'static str { + match self { + Self::Generated => "generated", + Self::Incomplete => "incomplete", + Self::NotApplicable => "not-applicable", + Self::Unknown => "unknown", + } + } +} + +impl std::fmt::Display for FfpAppearances { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Declared marker (MARKER.adoc). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct FfpDeclared { + /// Verbatim `filledBy` value. + #[serde(rename = "filledBy")] + pub filled_by: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool: Option, + #[serde(rename = "toolVersion", skip_serializing_if = "Option::is_none")] + 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")] + pub appearances_generated: Option, +} + +impl FfpDeclared { + pub fn machine(tool: Option, tool_version: Option, filled_at: Option, appearances_generated: Option) -> Self { + Self { + filled_by: "machine".to_string(), + tool, + tool_version, + filled_at, + appearances_generated, + } + } +} + +/// The FFP record (DETECTION §The record). Wire version `ffp = "1.0"`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FfpRecord { + /// Wire version, always `"1.0"`. + pub ffp: String, + pub classification: FfpClassification, + pub form: FfpForm, + #[serde(rename = "filled_fields")] + pub filled_fields: u32, + #[serde(rename = "total_fields")] + pub total_fields: u32, + pub appearances: FfpAppearances, + /// `null` when no marker parsed. + #[serde(skip_serializing_if = "Option::is_none")] + pub declared: Option, + /// Evidence codes, sorted, deduped. + pub evidence: Vec, +} + +impl Default for FfpRecord { + fn default() -> Self { + Self::unreadable("not inspected") + } +} + +impl FfpRecord { + /// Create an `unreadable` record. + pub fn unreadable(reason: &str) -> Self { + let _ = reason; + Self { + ffp: "1.0".to_string(), + classification: FfpClassification::Unreadable, + form: FfpForm::Unknown, + filled_fields: 0, + total_fields: 0, + appearances: FfpAppearances::Unknown, + declared: None, + evidence: vec!["FFP-E-UNREADABLE".to_string()], + } + } + + /// Create a `no-form` record. + pub fn no_form(evidence: Vec) -> Self { + let mut ev = evidence; + if !ev.contains(&"FFP-E-NO-ACROFORM".to_string()) { + ev.push("FFP-E-NO-ACROFORM".to_string()); + } + ev.sort(); + ev.dedup(); + Self { + ffp: "1.0".to_string(), + classification: FfpClassification::NoForm, + form: FfpForm::Absent, + filled_fields: 0, + total_fields: 0, + appearances: FfpAppearances::NotApplicable, + declared: None, + evidence: ev, + } + } + + /// Whether the document carries an interactive form. + pub fn is_form(&self) -> bool { + matches!(self.form, FfpForm::Present) + } + + /// Whether the form was declared machine-filled. + pub fn is_machine_filled(&self) -> bool { + self.classification == FfpClassification::MachineFilled + } + + /// Whether the form is machine-filled or suspected. + pub fn is_machine_or_suspected(&self) -> bool { + self.classification.is_machine_or_suspected() + } + + /// Whether the record indicates the blank-print hazard (P5): + /// appearances incomplete and at least one filled field. + pub fn has_blank_print_hazard(&self) -> bool { + self.appearances == FfpAppearances::Incomplete && self.filled_fields > 0 + } + + /// Whether a job carrying this record should be held under `policy`. + 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(), + _ => false, + } + } + + /// One-line human summary for UI/logs/audit. + pub fn summary(&self) -> String { + let head = match self.classification { + FfpClassification::NoForm => "not an interactive form".to_string(), + FfpClassification::BlankForm => "blank form — no values entered".to_string(), + FfpClassification::MachineFilled => { + if let Some(d) = &self.declared { + if let Some(tool) = &d.tool { + format!("machine-filled form ({tool})") + } else { + "machine-filled form (declared)".to_string() + } + } else { + "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::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()) + } else { + head + } + } + + /// Compact JSON for audit trail. + pub fn audit_json(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string()) + } + + /// Canonical line for conformance runner: + /// `classification=… form=… filled=N/M appearances=… evidence=…` + pub fn canonical_line(&self) -> String { + let mut ev = self.evidence.clone(); + ev.sort(); + ev.dedup(); + let evidence = ev.join(","); + format!( + "classification={} form={} filled={}/{} appearances={} evidence={}", + self.classification.as_str(), + self.form.as_str(), + self.filled_fields, + self.total_fields, + self.appearances.as_str(), + evidence + ) + } + + /// Short label for UI badges (P4). + pub fn label(&self) -> &'static str { + match self.classification { + FfpClassification::NoForm => "No form", + FfpClassification::BlankForm => "Blank form", + FfpClassification::MachineFilled => "Machine-filled", + FfpClassification::MachineFilledSuspected => "Machine-filled (suspected)", + FfpClassification::FilledUnknown => "Filled", + FfpClassification::Unreadable => "Unreadable", + } + } + + // --- Compatibility shims for old code that used `origin`/`confidence`/`inspected` --- + + /// Compatibility: `origin` alias for `classification` (old `FormOrigin` tokens). + /// Old tokens: NotAForm/Empty/Human/Machine/Unknown → new lattice. + pub fn origin_token(&self) -> &'static str { + match self.classification { + FfpClassification::NoForm => "NotAForm", + FfpClassification::BlankForm => "Empty", + FfpClassification::FilledUnknown => "Human", + FfpClassification::MachineFilled | FfpClassification::MachineFilledSuspected => "Machine", + FfpClassification::Unreadable => "Unknown", + } + } + + /// Compatibility: `inspected` — false only for unreadable due to disabled policy. + pub fn inspected(&self) -> bool { + !matches!(self.classification, FfpClassification::Unreadable) + } + + /// Compatibility: `field_count` alias. + pub fn field_count(&self) -> u32 { + self.total_fields + } + pub fn filled_field_count(&self) -> u32 { + self.filled_fields + } +} + +// --- Legacy types for DB migration (pre-FFP-1.0) ------------------------ +// These are retained ONLY to deserialize rows written before 2026-10-04. +// New code must use `Ffp*`. Do not construct these except in migration. + +/// Legacy origin (pre-1.0). Kept for `form_origin` column migration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum FormOrigin { + NotAForm, + Empty, + Human, + Machine, + #[default] + Unknown, +} + +impl FormOrigin { + pub fn as_token(&self) -> &'static str { + match self { + Self::NotAForm => "NotAForm", + Self::Empty => "Empty", + Self::Human => "Human", + Self::Machine => "Machine", + Self::Unknown => "Unknown", + } + } + pub fn from_token(token: &str) -> Self { + match token { + "NotAForm" => Self::NotAForm, + "Empty" => Self::Empty, + "Human" => Self::Human, + "Machine" => Self::Machine, + _ => Self::Unknown, + } + } + pub fn is_machine(&self) -> bool { + matches!(self, Self::Machine) + } +} + +impl std::fmt::Display for FormOrigin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_token()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] +pub enum ProvenanceConfidence { + #[default] + None, + Speculative, + Probable, + Strong, + Explicit, +} + +impl ProvenanceConfidence { + pub fn as_token(&self) -> &'static str { + match self { + Self::None => "None", + Self::Speculative => "Speculative", + Self::Probable => "Probable", + Self::Strong => "Strong", + Self::Explicit => "Explicit", + } + } +} +impl std::fmt::Display for ProvenanceConfidence { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_token()) + } +} + +/// Legacy provenance struct (pre-1.0). Only for deserializing old JSON. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FormProvenanceLegacy { + pub origin: FormOrigin, + pub confidence: ProvenanceConfidence, + pub inspected: bool, + pub is_form: bool, + pub field_count: u32, + pub filled_field_count: u32, + pub values_without_appearance: u32, + pub filled_with_default: u32, + pub need_appearances: bool, + pub producer: Option, + pub creator: Option, + pub marker: Option, + pub evidence: Vec, +} + +impl Default for FormProvenanceLegacy { + fn default() -> Self { + Self { + origin: FormOrigin::Unknown, + confidence: ProvenanceConfidence::None, + inspected: false, + is_form: false, + field_count: 0, + filled_field_count: 0, + values_without_appearance: 0, + filled_with_default: 0, + need_appearances: false, + producer: None, + creator: None, + marker: None, + evidence: vec!["document was not inspected".to_string()], + } + } +} + +/// New code uses `FfpRecord`. This alias keeps `crate::FormProvenance` compiling +/// for call sites that have migrated; it points to the spec type. +pub type FormProvenance = FfpRecord; + +/// Convert legacy into new record (best-effort, for migration). +impl From for FfpRecord { + fn from(legacy: FormProvenanceLegacy) -> Self { + // Map legacy origin → new classification + let classification = match legacy.origin { + FormOrigin::NotAForm => FfpClassification::NoForm, + FormOrigin::Empty => FfpClassification::BlankForm, + FormOrigin::Human => FfpClassification::FilledUnknown, + FormOrigin::Machine => { + // Old Machine covered both declared and suspected; map to suspected + // unless marker indicated explicit. + if legacy.marker.is_some() { + FfpClassification::MachineFilled + } else { + FfpClassification::MachineFilledSuspected + } + } + FormOrigin::Unknown => FfpClassification::Unreadable, + }; + let form = if legacy.is_form { + FfpForm::Present + } else if legacy.origin == FormOrigin::NotAForm { + FfpForm::Absent + } else if legacy.origin == FormOrigin::Unknown && !legacy.inspected { + FfpForm::Unknown + } else { + FfpForm::Absent + }; + let appearances = if legacy.filled_field_count == 0 { + FfpAppearances::NotApplicable + } else if legacy.values_without_appearance > 0 || legacy.need_appearances { + FfpAppearances::Incomplete + } 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 mut evidence = Vec::new(); + if legacy.need_appearances { + evidence.push("FFP-E-NEED-APPEARANCES".to_string()); + } + if legacy.values_without_appearance > 0 { + evidence.push("FFP-E-AP-INCOMPLETE".to_string()); + } + 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()), + _ => {} + } + if legacy.marker.is_some() { + evidence.push("FFP-E-DECL-MACHINE".to_string()); + } + if evidence.is_empty() && legacy.evidence.iter().any(|e| e.contains("not inspected")) { + evidence.push("FFP-E-UNREADABLE".to_string()); + } + evidence.sort(); + evidence.dedup(); + if evidence.is_empty() { + evidence = legacy.evidence; + } + FfpRecord { + ffp: "1.0".to_string(), + classification, + form, + filled_fields: legacy.filled_field_count, + total_fields: legacy.field_count, + appearances, + declared, + evidence, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classification_tokens_roundtrip() { + for c in [ + FfpClassification::NoForm, + FfpClassification::BlankForm, + FfpClassification::MachineFilled, + FfpClassification::MachineFilledSuspected, + FfpClassification::FilledUnknown, + FfpClassification::Unreadable, + ] { + let s = c.as_str(); + assert_eq!(FfpClassification::from_str(s), c); + assert!(!s.is_empty()); + } + } + + #[test] + fn canonical_line_format() { + let r = FfpRecord { + ffp: "1.0".to_string(), + classification: FfpClassification::MachineFilledSuspected, + form: FfpForm::Present, + filled_fields: 2, + total_fields: 2, + appearances: FfpAppearances::Incomplete, + declared: None, + evidence: vec!["FFP-E-NEED-APPEARANCES".to_string(), "FFP-E-AP-INCOMPLETE".to_string()], + }; + assert_eq!( + r.canonical_line(), + "classification=machine-filled-suspected form=present filled=2/2 appearances=incomplete evidence=FFP-E-AP-INCOMPLETE,FFP-E-NEED-APPEARANCES" + ); + // evidence is sorted + } + + #[test] + fn json_roundtrip() { + let r = FfpRecord { + ffp: "1.0".to_string(), + classification: FfpClassification::MachineFilled, + form: FfpForm::Present, + 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) }), + evidence: vec!["FFP-E-DECL-MACHINE".to_string()], + }; + let json = r.audit_json(); + let back: FfpRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(back, r); + } + + #[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"); + } + + #[test] + fn should_hold() { + use crate::types::FormProvenancePolicy; + let mut r = FfpRecord { + ffp: "1.0".to_string(), + classification: FfpClassification::MachineFilled, + form: FfpForm::Present, + 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 }), + evidence: vec!["FFP-E-DECL-MACHINE".to_string()], + }; + assert!(r.should_hold(FormProvenancePolicy::HoldForReview)); + r.classification = FfpClassification::MachineFilledSuspected; + assert!(r.should_hold(FormProvenancePolicy::HoldForReview)); + r.classification = FfpClassification::FilledUnknown; + // FilledUnknown with incomplete appearances is still hazard -> hold + r.appearances = FfpAppearances::Incomplete; + assert!(r.should_hold(FormProvenancePolicy::HoldForReview)); + r.appearances = FfpAppearances::Generated; + assert!(!r.should_hold(FormProvenancePolicy::HoldForReview)); + assert!(!r.should_hold(FormProvenancePolicy::Record)); + } +} diff --git a/crates/presswerk-core/src/types.rs b/crates/presswerk-core/src/types.rs index 778ff6b..d215c79 100644 --- a/crates/presswerk-core/src/types.rs +++ b/crates/presswerk-core/src/types.rs @@ -231,232 +231,18 @@ pub struct PageRange { } // --------------------------------------------------------------------------- -// Form provenance — machine-filled vs hand-filled (issue #118, ruling D189) +// Form provenance — FFP v1.0.0 (issue #118, ruling D189) +// +// The wire types live in `crate::provenance` (ffp = "1.0"). +// This module re-exports them and keeps the legacy `FormOrigin`/`FormProvenance` +// names as aliases so existing DB rows and tests can migrate gradually. +// New code should use `Ffp*` via `crate::provenance` or `crate::*`. // --------------------------------------------------------------------------- -/// How the values in an interactive (AcroForm) PDF came to be there. -/// -/// Upstream form-fillers — for example -/// [`blocky-writer`](https://github.com/hyperpolymath/blocky-writer), whose -/// `fill_blocks` writes `/V`, `/DV` and `/AS` entries and sets -/// `NeedAppearances` — emit an ordinary PDF byte stream. Nothing in those bytes -/// says *who* wrote the values. Presswerk is usually the next thing that -/// touches them: it prints them. So the print path classifies the document at -/// the boundary and records the result for audit and routing. -/// -/// The determination is heuristic unless an explicit marker is present; see -/// `docs/ecosystem/FORM-PROVENANCE.adoc`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum FormOrigin { - /// The document carries no interactive form at all. - NotAForm, - /// The document is a form, but no field carries a value. - Empty, - /// Values are present and nothing suggests software wrote them. - Human, - /// Values are present and there is positive evidence software wrote them. - Machine, - /// Not determined: the document was not inspected, or inspection failed. - #[default] - Unknown, -} - -impl FormOrigin { - /// Stable single-word token used in the database, logs and audit trail. - /// - /// Never contains whitespace or punctuation, so it is safe to embed in SQL - /// comparisons and log lines. - pub fn as_token(&self) -> &'static str { - match self { - Self::NotAForm => "NotAForm", - Self::Empty => "Empty", - Self::Human => "Human", - Self::Machine => "Machine", - Self::Unknown => "Unknown", - } - } - - /// Inverse of [`Self::as_token`]. Unrecognised tokens map to `Unknown` — - /// an unparseable provenance must never be mistaken for a determination. - pub fn from_token(token: &str) -> Self { - match token { - "NotAForm" => Self::NotAForm, - "Empty" => Self::Empty, - "Human" => Self::Human, - "Machine" => Self::Machine, - _ => Self::Unknown, - } - } - - /// Whether this is a positive determination that software filled the form. - pub fn is_machine(&self) -> bool { - matches!(self, Self::Machine) - } -} - -impl std::fmt::Display for FormOrigin { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_token()) - } -} - -/// How much weight a [`FormProvenance`] determination deserves. -/// -/// Ordered weakest to strongest so callers can compare with `<`/`>=`. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, -)] -pub enum ProvenanceConfidence { - /// No determination was made. - #[default] - None, - /// A weak signal; treat as a hint only. - Speculative, - /// A characteristic combination of signals, but spoofable. - Probable, - /// A named tool known to fill forms programmatically. - Strong, - /// An explicit machine-readable provenance marker was present. - Explicit, -} - -impl ProvenanceConfidence { - /// Stable token for logs and the audit trail. - pub fn as_token(&self) -> &'static str { - match self { - Self::None => "None", - Self::Speculative => "Speculative", - Self::Probable => "Probable", - Self::Strong => "Strong", - Self::Explicit => "Explicit", - } - } -} - -impl std::fmt::Display for ProvenanceConfidence { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_token()) - } -} - -/// What the print path concluded about a document's form values. -/// -/// Attached to every [`PrintJob`] so that the queue, the audit trail and the -/// routing policy can all see the same determination. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FormProvenance { - /// The determination itself. - pub origin: FormOrigin, - /// How much weight the determination deserves. - pub confidence: ProvenanceConfidence, - /// Whether the document was actually inspected (`false` on parse failure, - /// non-PDF input, or when the document exceeded the inspection budget). - pub inspected: bool, - /// Whether the document carries an interactive form (AcroForm) at all. - pub is_form: bool, - /// Number of form fields found. - pub field_count: u32, - /// Number of fields carrying a value. - pub filled_field_count: u32, - /// Fields with a value but no `/AP` appearance stream — the classic - /// signature of a writer that set values without generating appearances. - pub values_without_appearance: u32, - /// Fields whose value was written alongside a `/DV` default. Interactive - /// viewers write `/V` only; writing `/DV` too is a programmatic signature. - pub filled_with_default: u32, - /// Whether the AcroForm sets `/NeedAppearances` true. - pub need_appearances: bool, - /// `/Producer` from the document information dictionary, if any. - pub producer: Option, - /// `/Creator` from the document information dictionary, if any. - pub creator: Option, - /// An explicit upstream provenance marker, if one was present. - pub marker: Option, - /// Human-readable reasons for the determination, in order of weight. - pub evidence: Vec, -} - -impl Default for FormProvenance { - fn default() -> Self { - Self::not_inspected("document was not inspected") - } -} - -impl FormProvenance { - /// Provenance for a document that was never inspected, with the reason. - pub fn not_inspected(reason: &str) -> Self { - Self { - origin: FormOrigin::Unknown, - confidence: ProvenanceConfidence::None, - inspected: false, - is_form: false, - field_count: 0, - filled_field_count: 0, - values_without_appearance: 0, - filled_with_default: 0, - need_appearances: false, - producer: None, - creator: None, - marker: None, - evidence: vec![reason.to_string()], - } - } - - /// Whether the document carries an interactive form. - pub fn is_form(&self) -> bool { - self.is_form - } - - /// Whether the form was determined to have been filled by software. - pub fn is_machine_filled(&self) -> bool { - self.origin.is_machine() - } - - /// The tool responsible for the values, if one could be identified. - /// - /// Prefers the explicit marker over the `/Producer` string, since the - /// latter can be set by anything. - pub fn tool(&self) -> Option<&str> { - self.marker.as_deref().or(self.producer.as_deref()) - } - - /// Whether a job carrying this provenance should be held for user review - /// under `policy`. - /// - /// Only a *positive* machine determination holds a job: an uninspected or - /// undetermined document must not stall somebody's printout. - pub fn should_hold(&self, policy: FormProvenancePolicy) -> bool { - matches!(policy, FormProvenancePolicy::HoldForReview) && self.is_machine_filled() - } - - /// One-line human summary for the UI, logs and the audit trail. - pub fn summary(&self) -> String { - let head = match self.origin { - FormOrigin::NotAForm => "not an interactive form".to_string(), - FormOrigin::Empty => "interactive form; no values entered".to_string(), - FormOrigin::Human => "form values present; no machine-fill evidence".to_string(), - FormOrigin::Machine => match self.tool() { - Some(tool) => format!("machine-filled form ({tool})"), - None => "machine-filled form".to_string(), - }, - FormOrigin::Unknown => "form provenance not determined".to_string(), - }; - - if self.is_form { - format!( - "{head}; {}/{} fields filled", - self.filled_field_count, self.field_count - ) - } else { - head - } - } - - /// Compact JSON representation for the audit trail's `details` column. - pub fn audit_json(&self) -> String { - serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string()) - } -} +pub use crate::provenance::{ + FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, + FormOrigin, FormProvenance, ProvenanceConfidence, +}; /// What the print path does with a machine-filled form (issue #118). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -811,22 +597,27 @@ mod tests { // -- Form provenance (issue #118) --------------------------------------- - /// Build a machine-filled determination the way the document crate does. + /// Build a machine-filled determination the way the document crate does (FFP v1.0). fn machine_filled() -> FormProvenance { FormProvenance { - origin: FormOrigin::Machine, - confidence: ProvenanceConfidence::Probable, - inspected: true, - is_form: true, - field_count: 14, - filled_field_count: 12, - values_without_appearance: 12, - filled_with_default: 12, - need_appearances: true, - producer: Some("blocky-writer 0.4.2".to_string()), - creator: None, - marker: None, - evidence: vec!["12 of 14 fields carry values and /NeedAppearances is set".to_string()], + ffp: "1.0".to_string(), + classification: crate::provenance::FfpClassification::MachineFilled, + form: crate::provenance::FfpForm::Present, + filled_fields: 12, + total_fields: 14, + appearances: crate::provenance::FfpAppearances::Incomplete, + declared: Some(crate::provenance::FfpDeclared { + filled_by: "machine".to_string(), + tool: Some("blocky-writer 0.4.2".to_string()), + tool_version: Some("0.4.2".to_string()), + filled_at: None, + appearances_generated: Some(false), + }), + evidence: vec![ + "FFP-E-DECL-MACHINE".to_string(), + "FFP-E-NEED-APPEARANCES".to_string(), + "FFP-E-AP-INCOMPLETE".to_string(), + ], } } @@ -865,12 +656,12 @@ mod tests { #[test] fn test_form_provenance_default_is_not_inspected() { let provenance = FormProvenance::default(); - assert!(!provenance.inspected); + // Default is unreadable (form unknown, not a form) assert!(!provenance.is_form()); - assert_eq!(provenance.origin, FormOrigin::Unknown); - assert_eq!(provenance.confidence, ProvenanceConfidence::None); + 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.len(), 1); + assert_eq!(provenance.evidence, vec!["FFP-E-UNREADABLE".to_string()]); } #[test] @@ -881,8 +672,8 @@ mod tests { "test.pdf".to_string(), "hash".to_string(), ); - assert!(!job.form_provenance.inspected); - assert_eq!(job.form_provenance.origin, FormOrigin::Unknown); + assert_eq!(job.form_provenance.classification, crate::provenance::FfpClassification::Unreadable); + assert_eq!(job.form_provenance.form, crate::provenance::FfpForm::Unknown); } #[test] @@ -896,28 +687,40 @@ mod tests { #[test] fn test_form_provenance_summary_not_a_form_omits_counts() { let provenance = FormProvenance { - origin: FormOrigin::NotAForm, - confidence: ProvenanceConfidence::Strong, - inspected: true, - ..FormProvenance::default() + ffp: "1.0".to_string(), + classification: crate::provenance::FfpClassification::NoForm, + form: crate::provenance::FfpForm::Absent, + filled_fields: 0, + total_fields: 0, + appearances: crate::provenance::FfpAppearances::NotApplicable, + declared: None, + evidence: vec!["FFP-E-NO-ACROFORM".to_string()], }; assert_eq!(provenance.summary(), "not an interactive form"); } #[test] - fn test_form_provenance_prefers_marker_over_producer() { + fn test_form_provenance_declared_tool() { let mut provenance = machine_filled(); - assert_eq!(provenance.tool(), 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")); - provenance.marker = Some("blocky-writer fillOrigin=machine".to_string()); - assert_eq!(provenance.tool(), Some("blocky-writer fillOrigin=machine")); + // Declared tool is preferred + provenance.declared = Some(crate::provenance::FfpDeclared { + filled_by: "machine".to_string(), + tool: Some("other-tool".to_string()), + tool_version: None, + filled_at: None, + appearances_generated: None, + }); + assert_eq!(provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), Some("other-tool")); } #[test] fn test_form_provenance_audit_json_roundtrips() { let provenance = machine_filled(); let json = provenance.audit_json(); - assert!(json.contains("\"origin\":\"Machine\""), "{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); @@ -930,16 +733,47 @@ mod tests { assert!(!machine.should_hold(FormProvenancePolicy::Record)); assert!(!machine.should_hold(FormProvenancePolicy::Off)); - // Undetermined provenance must never stall a printout. + // Undetermined (unreadable) must never stall a printout. let unknown = FormProvenance::default(); assert!(!unknown.should_hold(FormProvenancePolicy::HoldForReview)); - // Neither must a hand-filled form. + // Filled-unknown with generated appearances must not be held (no hazard, no machine) let human = FormProvenance { - origin: FormOrigin::Human, - ..FormProvenance::default() + ffp: "1.0".to_string(), + classification: crate::provenance::FfpClassification::FilledUnknown, + form: crate::provenance::FfpForm::Present, + filled_fields: 2, + total_fields: 2, + appearances: crate::provenance::FfpAppearances::Generated, + declared: None, + evidence: vec![], }; assert!(!human.should_hold(FormProvenancePolicy::HoldForReview)); + + // But filled-unknown with incomplete appearances (hazard) SHOULD be held + let hazard = FormProvenance { + ffp: "1.0".to_string(), + classification: crate::provenance::FfpClassification::FilledUnknown, + form: crate::provenance::FfpForm::Present, + filled_fields: 2, + total_fields: 2, + appearances: crate::provenance::FfpAppearances::Incomplete, + declared: None, + evidence: vec!["FFP-E-AP-INCOMPLETE".to_string()], + }; + assert!(hazard.should_hold(FormProvenancePolicy::HoldForReview)); + // Machine-filled-suspected must be held + let suspected = FormProvenance { + ffp: "1.0".to_string(), + classification: crate::provenance::FfpClassification::MachineFilledSuspected, + form: crate::provenance::FfpForm::Present, + filled_fields: 2, + total_fields: 2, + appearances: crate::provenance::FfpAppearances::Incomplete, + declared: None, + evidence: vec!["FFP-E-NEED-APPEARANCES".to_string(), "FFP-E-AP-INCOMPLETE".to_string()], + }; + assert!(suspected.should_hold(FormProvenancePolicy::HoldForReview)); } #[test] diff --git a/crates/presswerk-document/examples/ffp-classify.rs b/crates/presswerk-document/examples/ffp-classify.rs new file mode 100644 index 0000000..68d1062 --- /dev/null +++ b/crates/presswerk-document/examples/ffp-classify.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +// Thin CLI for FFP conformance runner: `FFP_DETECTOR=... ffp-classify`. +// Usage: ffp-classify → prints canonical line to stdout. + +use std::env; +use std::fs; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("usage: {} ", args[0]); + process::exit(2); + } + let path = &args[1]; + let bytes = fs::read(path).unwrap_or_else(|e| { + // Unreadable file is still a vector → classification unreadable + eprintln!("read {}: {}", path, e); + Vec::new() + }); + // Use FFP v1.0 detector + let record = presswerk_document::provenance::classify_pdf(&bytes); + println!("{}", record.canonical_line()); + if std::env::var("FFP_PROBE_RECORD").as_deref() == Ok("1") { + println!("{}", record.audit_json()); + } +} diff --git a/crates/presswerk-document/src/bin/ffp-classify.rs b/crates/presswerk-document/src/bin/ffp-classify.rs new file mode 100644 index 0000000..1863dc4 --- /dev/null +++ b/crates/presswerk-document/src/bin/ffp-classify.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MPL-2.0 +// Binary target for FFP conformance: same as examples/ffp-classify.rs +// so `cargo build --bin ffp-classify` produces `target/debug/ffp-classify`. + +use std::env; +use std::fs; +use std::process; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("usage: {} ", args[0]); + process::exit(2); + } + let path = &args[1]; + let bytes = fs::read(path).unwrap_or_else(|e| { + eprintln!("read {}: {}", path, e); + Vec::new() + }); + let record = presswerk_document::provenance::classify_pdf(&bytes); + println!("{}", record.canonical_line()); + if std::env::var("FFP_PROBE_RECORD").as_deref() == Ok("1") { + println!("{}", record.audit_json()); + } +} diff --git a/crates/presswerk-document/src/lib.rs b/crates/presswerk-document/src/lib.rs index f9980f1..93a4e89 100644 --- a/crates/presswerk-document/src/lib.rs +++ b/crates/presswerk-document/src/lib.rs @@ -19,11 +19,12 @@ pub mod convert; pub mod image; pub mod pdf; +pub mod provenance; pub mod scan; // CONVENIENCE: Primary interfaces for document transformation. pub use image::processor::ImageProcessor; -pub use pdf::form::{classify_for_print, classify_pdf}; +pub use provenance::{classify_for_print, classify_pdf}; pub use pdf::reader::PdfReader; pub use pdf::writer::PdfWriter; pub use scan::enhance::ScanEnhancer; diff --git a/crates/presswerk-document/src/pdf/form.rs b/crates/presswerk-document/src/pdf/form.rs index 2e13db3..8dfff6f 100644 --- a/crates/presswerk-document/src/pdf/form.rs +++ b/crates/presswerk-document/src/pdf/form.rs @@ -1,1139 +1,8 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// Form provenance — decide whether the values in a PDF form were written by -// software or by a person. -// -// WHY THIS EXISTS (issue #118, ruling D189) -// ----------------------------------------- -// Upstream form-fillers such as `blocky-writer` emit an ordinary PDF byte -// stream: `/V`, `/DV` and `/AS` entries are written and `NeedAppearances` is -// set, but nothing in the file says *who* wrote the values. Presswerk is -// usually the next thing that touches those bytes — it prints them — so the -// print path classifies the document at the boundary and records the result. -// -// The classification is deliberately conservative and evidence-based: -// -// Explicit an upstream provenance marker is present (`/FillOrigin` in -// the document information dictionary, or a `fillOrigin` -// attribute in an XMP packet) -// Strong `/Producer` or `/Creator` names a tool known to fill forms -// programmatically -// Probable a characteristic combination of structural signals — values -// without appearance streams, `NeedAppearances` set, values -// written alongside `/DV` defaults -// -// Only a *positive* machine determination is ever acted upon; everything else -// prints as normal. See `docs/ecosystem/FORM-PROVENANCE.adoc`. - -use std::collections::HashSet; - -use lopdf::{Dictionary, Document, Object, ObjectId}; -use presswerk_core::types::{ - DocumentType, FormOrigin, FormProvenance, FormProvenancePolicy, ProvenanceConfidence, -}; -use tracing::{debug, instrument}; - -/// Documents larger than this are not inspected. -/// -/// Inspection happens inline on the print path, so the budget keeps a huge -/// document from stalling the IPP accept loop. Documents over the budget are -/// reported as "not inspected" rather than guessed at. -pub const MAX_INSPECT_BYTES: usize = 8 * 1024 * 1024; - -/// Hard cap on field-tree nodes visited, so a malformed or hostile field tree -/// cannot make inspection unbounded. -const MAX_FIELD_NODES: u32 = 100_000; - -/// Hard cap on field-tree nesting depth. -const MAX_FIELD_DEPTH: u32 = 32; - -/// How far down a field's own subtree to look for an appearance stream before -/// concluding the value has none. -const APPEARANCE_SEARCH_DEPTH: u32 = 3; - -/// Substrings that identify a tool which writes form values programmatically. -/// -/// Matched case-insensitively against `/Producer` and `/Creator`. Document -/// generators that merely *create* blank PDFs are deliberately absent: a blank -/// form produced by such a tool is still an empty form. -const KNOWN_MACHINE_FILLERS: &[&str] = &[ - "blocky-writer", - "blocky_writer", - "blockywriter", - "fill_blocks", - "lopdf", - "itext", - "openpdf", - "pdf-lib", - "pypdf", - "pdftk", - "pdfbox", - "aspose", - "pdfsharp", - "docusign", - "adobe livecycle", - "livecycle", -]; - -/// Attribute name scanned for in raw bytes / XMP packets. -const MARKER_ATTRIBUTE: &[u8] = b"fillorigin"; - -// --------------------------------------------------------------------------- -// Public entry points -// --------------------------------------------------------------------------- - -/// Classify a document arriving on the print path. -/// -/// This is the entry point both print paths (local printing and the embedded -/// IPP server) call. It never fails: anything that stops inspection — a -/// non-PDF, an oversized document, a parse error, a disabled policy — is -/// reported inside the returned value with `inspected == false`. -#[instrument(skip_all, fields(bytes = data.len(), policy = %policy.as_token()))] -pub fn classify_for_print( - data: &[u8], - document_type: DocumentType, - policy: FormProvenancePolicy, -) -> FormProvenance { - if !policy.inspects() { - debug!("form provenance inspection disabled by policy"); - return FormProvenance::not_inspected("form provenance inspection is disabled"); - } - if document_type != DocumentType::Pdf { - return FormProvenance::not_inspected("document is not a PDF"); - } - classify_pdf(data) -} - -/// Classify the form provenance of raw PDF bytes. -/// -/// Never fails; see [`classify_for_print`]. -#[instrument(skip_all, fields(bytes = data.len()))] -pub fn classify_pdf(data: &[u8]) -> FormProvenance { - if data.is_empty() { - return FormProvenance::not_inspected("document is empty"); - } - if data.len() > MAX_INSPECT_BYTES { - return FormProvenance::not_inspected(&format!( - "document is {} bytes, above the {} byte inspection budget", - data.len(), - MAX_INSPECT_BYTES - )); - } - - let document = match Document::load_mem(data) { - Ok(document) => document, - Err(err) => { - debug!(error = %err, "PDF parse failed during provenance inspection"); - return FormProvenance::not_inspected(&format!("PDF parse failed: {err}")); - } - }; - - classify_document(&document, data) -} - -/// Classify an already-parsed document. -/// -/// `raw` is the original byte stream and is used only to find unstructured -/// provenance markers (for example inside an XMP packet); pass an empty slice -/// to rely on the structured `/Info` marker alone. -#[instrument(skip_all, fields(raw_bytes = raw.len()))] -pub fn classify_document(document: &Document, raw: &[u8]) -> FormProvenance { - let mut provenance = inspected_blank(); - - let info = document_info(document); - provenance.producer = info.producer.clone(); - provenance.creator = info.creator.clone(); - - // -- Explicit marker ------------------------------------------------------ - let mut marker = structured_marker(&info); - if marker.is_none() { - marker = scan_for_marker(raw); - } - if marker.is_none() { - for packet in xmp_packets(document) { - if let Some(found) = scan_for_marker(&packet) { - marker = Some(found); - break; - } - } - } - if let Some(found) = marker - && mentions_machine(&found) - { - provenance.marker = Some(found.clone()); - provenance - .evidence - .push(format!("explicit provenance marker present: {found}")); - } - let explicit = provenance.marker.is_some(); - - // -- Known tool ----------------------------------------------------------- - let known_tool = known_filler(provenance.producer.as_deref(), provenance.creator.as_deref()); - if let Some(tool) = known_tool { - provenance.evidence.push(format!( - "document producer/creator names '{tool}', a known form-filling tool" - )); - } - - // -- Structure ------------------------------------------------------------ - let catalog = match document.catalog() { - Ok(catalog) => catalog, - Err(err) => { - provenance.origin = FormOrigin::NotAForm; - provenance.confidence = ProvenanceConfidence::Speculative; - provenance - .evidence - .push(format!("document has no readable catalog ({err})")); - return finish(provenance); - } - }; - - let acroform = match catalog - .get(b"AcroForm") - .ok() - .and_then(|obj| resolve_dict(document, obj)) - { - Some(acroform) => acroform, - None => { - provenance.origin = FormOrigin::NotAForm; - provenance.confidence = ProvenanceConfidence::Strong; - provenance - .evidence - .push("document catalog has no /AcroForm entry".to_string()); - return finish(provenance); - } - }; - - provenance.is_form = true; - provenance.need_appearances = - matches!(acroform.get(b"NeedAppearances"), Ok(Object::Boolean(true))); - - let scan = scan_fields(document, acroform); - provenance.field_count = scan.field_count; - provenance.filled_field_count = scan.filled; - provenance.filled_with_default = scan.filled_with_default; - provenance.values_without_appearance = scan.without_appearance; - - // -- Determination -------------------------------------------------------- - if explicit { - // An explicit marker outranks every structural signal, including an - // apparently empty form: the writer is telling us it filled the form. - provenance.origin = FormOrigin::Machine; - provenance.confidence = ProvenanceConfidence::Explicit; - } else if provenance.filled_field_count == 0 { - provenance.origin = FormOrigin::Empty; - provenance.confidence = ProvenanceConfidence::Strong; - provenance.evidence.push(format!( - "/AcroForm declares {} field(s), none of which carries a value", - provenance.field_count - )); - } else if known_tool.is_some() { - provenance.origin = FormOrigin::Machine; - provenance.confidence = ProvenanceConfidence::Strong; - } else if provenance.need_appearances { - provenance.origin = FormOrigin::Machine; - provenance.confidence = ProvenanceConfidence::Probable; - provenance.evidence.push(format!( - "{} of {} field(s) carry values and /NeedAppearances is set — values were written without appearances", - provenance.filled_field_count, provenance.field_count - )); - } else if provenance.values_without_appearance > 0 { - provenance.origin = FormOrigin::Machine; - provenance.confidence = ProvenanceConfidence::Probable; - provenance.evidence.push(format!( - "{} field value(s) have no /AP appearance stream", - provenance.values_without_appearance - )); - } else if provenance.filled_with_default > 0 { - provenance.origin = FormOrigin::Machine; - provenance.confidence = ProvenanceConfidence::Speculative; - provenance.evidence.push(format!( - "{} field value(s) were written alongside a /DV default", - provenance.filled_with_default - )); - } else { - provenance.origin = FormOrigin::Human; - provenance.confidence = ProvenanceConfidence::Probable; - provenance.evidence.push(format!( - "{} of {} field(s) carry values, each with an appearance stream and no machine-fill signal", - provenance.filled_field_count, provenance.field_count - )); - } - - finish(provenance) -} - -// --------------------------------------------------------------------------- -// Determination bookkeeping -// --------------------------------------------------------------------------- - -/// A provenance value for a document that *was* inspected, before any evidence -/// has been gathered. -fn inspected_blank() -> FormProvenance { - FormProvenance { - origin: FormOrigin::Unknown, - confidence: ProvenanceConfidence::None, - inspected: true, - is_form: false, - field_count: 0, - filled_field_count: 0, - values_without_appearance: 0, - filled_with_default: 0, - need_appearances: false, - producer: None, - creator: None, - marker: None, - evidence: Vec::new(), - } -} - -/// Log the determination and hand it back. -fn finish(provenance: FormProvenance) -> FormProvenance { - debug!( - origin = %provenance.origin, - confidence = %provenance.confidence, - fields = provenance.field_count, - filled = provenance.filled_field_count, - "form provenance determined" - ); - provenance -} - -// --------------------------------------------------------------------------- -// Object graph helpers -// --------------------------------------------------------------------------- - -/// Resolve an object that is expected to be a dictionary (possibly indirect). -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, - } - }), - _ => None, - } -} - -/// Resolve an object that is expected to be an array (possibly indirect). -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, - }), - _ => None, - } -} - -/// Whether a dictionary carries a non-empty value under `key`. -/// -/// `Off` is treated as *no* value in either representation: for check boxes and -/// radio buttons it is the explicitly unselected state, not an entry. -fn has_value(dict: &Dictionary, key: &[u8]) -> bool { - match dict.get(key) { - Ok(Object::Null) | Err(_) => false, - Ok(Object::String(bytes, _)) => !is_blank_or_off(bytes), - Ok(Object::Name(name)) => !is_blank_or_off(name), - Ok(Object::Array(items)) => !items.is_empty(), - Ok(_) => true, - } -} - -/// Whether a PDF name or string represents "no value". -fn is_blank_or_off(bytes: &[u8]) -> bool { - bytes.is_empty() || bytes.eq_ignore_ascii_case(b"Off") -} - -// --------------------------------------------------------------------------- -// Field tree -// --------------------------------------------------------------------------- - -/// What a walk of the AcroForm field tree counted. -struct FieldScan { - field_count: u32, - filled: u32, - filled_with_default: u32, - without_appearance: u32, - visited: u32, -} - -/// Walk the AcroForm `/Fields` tree and count fields, values, and values that -/// have no appearance stream anywhere in their own subtree. -fn scan_fields(document: &Document, acroform: &Dictionary) -> FieldScan { - let mut scan = FieldScan { - field_count: 0, - filled: 0, - filled_with_default: 0, - without_appearance: 0, - visited: 0, - }; - let mut seen = HashSet::new(); - - if let Ok(fields) = acroform.get(b"Fields") { - walk_fields(document, fields, 0, &mut seen, &mut scan); - } - - scan -} - -/// Recursive field-tree walk with cycle and size guards. -fn walk_fields( - document: &Document, - node: &Object, - depth: u32, - seen: &mut HashSet, - scan: &mut FieldScan, -) { - if depth > MAX_FIELD_DEPTH || scan.visited >= MAX_FIELD_NODES { - return; - } - - match node { - Object::Reference(id) => { - if !seen.insert(*id) { - return; - } - scan.visited += 1; - if let Ok(resolved) = document.get_object(*id) { - walk_fields(document, resolved, depth + 1, seen, scan); - } - } - Object::Array(items) => { - for item in items { - walk_fields(document, item, depth + 1, seen, scan); - } - } - Object::Dictionary(dict) => { - scan.visited += 1; - - // A node with a partial field name (/T) is a field; widget-only - // annotations inherit their name from the parent field. - if dict.get(b"T").is_ok() { - scan.field_count += 1; - if has_value(dict, b"V") { - scan.filled += 1; - if has_value(dict, b"DV") { - scan.filled_with_default += 1; - } - if !subtree_has_appearance(document, dict, 0) { - scan.without_appearance += 1; - } - } - } - - if let Ok(kids) = dict.get(b"Kids") { - walk_fields(document, kids, depth + 1, seen, scan); - } - } - Object::Stream(stream) => { - scan.visited += 1; - if let Ok(kids) = stream.dict.get(b"Kids") { - walk_fields(document, kids, depth + 1, seen, scan); - } - } - _ => {} - } -} - -/// Whether a field or anything in its subtree carries an `/AP` appearance -/// dictionary. -/// -/// Widget annotations are frequently kept apart from the field that owns the -/// value, so the value and its appearance do not always live in the same -/// dictionary. -fn subtree_has_appearance(document: &Document, dict: &Dictionary, depth: u32) -> bool { - if dict.get(b"AP").is_ok() { - return true; - } - if depth >= APPEARANCE_SEARCH_DEPTH { - return false; - } - - for key in [b"Kids".as_slice(), b"Annots".as_slice()] { - if let Some(items) = dict.get(key).ok().and_then(|obj| resolve_array(document, obj)) { - for item in items { - if let Some(item_dict) = resolve_dict(document, item) - && subtree_has_appearance(document, item_dict, depth + 1) - { - return true; - } - } - } - } - - false -} - -// --------------------------------------------------------------------------- -// Metadata -// --------------------------------------------------------------------------- - -/// Interesting entries from the document information dictionary. -struct DocInfo { - producer: Option, - creator: Option, - /// Non-standard `Fill*` entries — the explicit marker convention. - fill_entries: Vec<(String, String)>, -} - -/// Read `/Producer`, `/Creator` and any `Fill*` marker entries from the -/// document information dictionary. -fn document_info(document: &Document) -> DocInfo { - let mut info = DocInfo { - producer: None, - creator: None, - fill_entries: Vec::new(), - }; - - let Some(dict) = document - .trailer - .get(b"Info") - .ok() - .and_then(|obj| resolve_dict(document, obj)) - else { - return info; - }; - - for (key, value) in dict.iter() { - let Object::String(bytes, _) = value else { - continue; - }; - let key = String::from_utf8_lossy(key).to_string(); - let text = String::from_utf8_lossy(bytes).trim().to_string(); - match key.as_str() { - "Producer" => info.producer = Some(text), - "Creator" => info.creator = Some(text), - other if other.starts_with("Fill") => info.fill_entries.push((other.into(), text)), - _ => {} - } - } - - info -} - -/// Build the explicit marker from `Fill*` information dictionary entries. -fn structured_marker(info: &DocInfo) -> Option { - let mut origin = None; - let mut tool = None; - let mut time = None; - - for (key, value) in &info.fill_entries { - match key.as_str() { - "FillOrigin" => origin = Some(value.as_str()), - "FillTool" => tool = Some(value.as_str()), - "FillTime" => time = Some(value.as_str()), - _ => {} - } - } - - let origin = origin?; - if !mentions_machine(origin) { - return None; - } - - Some(describe_marker(origin, tool, time)) -} - -/// Render a marker's parts as one stable, readable string. -fn describe_marker(origin: &str, tool: Option<&str>, time: Option<&str>) -> String { - let mut parts = vec![format!("FillOrigin={origin}")]; - if let Some(tool) = tool.filter(|t| !t.trim().is_empty()) { - parts.push(format!("FillTool={tool}")); - } - if let Some(time) = time.filter(|t| !t.trim().is_empty()) { - parts.push(format!("FillTime={time}")); - } - parts.join("; ") -} - -/// Whether a marker value asserts machine filling. -fn mentions_machine(value: &str) -> bool { - let lowered = value.to_ascii_lowercase(); - lowered.contains("machine") || lowered.contains("software") || lowered.contains("automatic") -} - -/// Return the first tool from [`KNOWN_MACHINE_FILLERS`] named by the producer -/// or creator string. -fn known_filler(producer: Option<&str>, creator: Option<&str>) -> Option<&'static str> { - for candidate in [producer, creator].into_iter().flatten() { - let lowered = candidate.to_ascii_lowercase(); - for tool in KNOWN_MACHINE_FILLERS { - if lowered.contains(tool) { - return Some(tool); - } - } - } - None -} - -/// Contents of the document's XMP metadata streams. -/// -/// XMP packets are usually written uncompressed, so the raw stream content is -/// normally enough. Compressed packets are not decoded; the structured -/// `/Info` marker is the authoritative location. -fn xmp_packets(document: &Document) -> Vec> { - let mut packets = Vec::new(); - - let Ok(catalog) = document.catalog() else { - return packets; - }; - let Some(metadata) = catalog.get(b"Metadata").ok() else { - return packets; - }; - let metadata = match metadata { - Object::Reference(id) => match document.get_object(*id) { - Ok(resolved) => resolved, - Err(_) => return packets, - }, - other => other, - }; - if let Object::Stream(stream) = metadata { - packets.push(stream.content.clone()); - } - - packets -} - -/// Scan raw bytes for a `fillOrigin` attribute or element and return its value. -/// -/// Accepts the attribute form (`fillOrigin="machine"`), single quotes, an -/// unquoted attribute value, and the RDF element-text form -/// (`machine`). -fn scan_for_marker(bytes: &[u8]) -> Option { - let start = find_ci(bytes, MARKER_ATTRIBUTE)?; - let mut rest = trim_ascii_start(&bytes[start + MARKER_ATTRIBUTE.len()..]); - if rest.is_empty() { - return None; - } - - if rest[0] == b'=' { - // Attribute form. - rest = trim_ascii_start(&rest[1..]); - if rest.is_empty() { - return None; - } - return match rest[0] { - b'"' | b'\'' => { - let quote = rest[0]; - rest = &rest[1..]; - let end = rest.iter().position(|&byte| byte == quote)?; - decode_marker_value(&rest[..end]) - } - _ => { - let end = rest - .iter() - .position(|&byte| byte.is_ascii_whitespace() || byte == b'>')?; - decode_marker_value(&rest[..end]) - } - }; - } - - // Element form: the value is the element text. - if rest[0] != b'>' { - return None; - } - rest = &rest[1..]; - let end = rest.iter().position(|&byte| byte == b'<')?; - decode_marker_value(&rest[..end]) -} - -/// Decode a marker value, rejecting empty ones. -fn decode_marker_value(bytes: &[u8]) -> Option { - let value = String::from_utf8_lossy(bytes).trim().to_string(); - if value.is_empty() { - None - } else { - Some(value) - } -} - -/// Case-insensitive substring search over bytes. -fn find_ci(haystack: &[u8], needle: &[u8]) -> Option { - if needle.is_empty() || haystack.len() < needle.len() { - return None; - } - haystack - .windows(needle.len()) - .position(|window| window.eq_ignore_ascii_case(needle)) -} - -/// Drop leading ASCII whitespace. -fn trim_ascii_start(bytes: &[u8]) -> &[u8] { - let mut index = 0; - while index < bytes.len() && bytes[index].is_ascii_whitespace() { - index += 1; - } - &bytes[index..] -} - -#[cfg(test)] -mod tests { - use super::*; - use lopdf::StringFormat; - - /// A field specification for the test PDF builder. - struct TestField { - name: &'static str, - value: Option<&'static str>, - default: Option<&'static str>, - appearance: bool, - } - - impl TestField { - /// A field a human filled in a viewer: value plus appearance stream. - const fn human(name: &'static str, value: &'static str) -> Self { - Self { - name, - value: Some(value), - default: None, - appearance: true, - } - } - - /// A field a program filled: value, default, and no appearance stream. - const fn machine(name: &'static str, value: &'static str) -> Self { - Self { - name, - value: Some(value), - default: Some(value), - appearance: false, - } - } - - /// A field with no value at all. - const fn blank(name: &'static str) -> Self { - Self { - name, - value: None, - default: None, - appearance: false, - } - } - } - - /// Build a real, serialisable PDF carrying an AcroForm. - fn build_pdf( - fields: Vec, - need_appearances: bool, - info_entries: Vec<(&'static str, &'static str)>, - ) -> Vec { - let mut document = Document::with_version("1.7"); - - // Minimal one-page page tree so the document is well formed. - 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("Contents", Object::Reference(content_id)); - let page_id = document.add_object(Object::Dictionary(page)); - - let mut pages = Dictionary::new(); - pages.set("Type", Object::Name(b"Pages".to_vec())); - pages.set("Kids", Object::Array(vec![Object::Reference(page_id)])); - pages.set("Count", Object::Integer(1)); - let pages_id = document.add_object(Object::Dictionary(pages)); - - if let Ok(Object::Dictionary(page_dict)) = document.get_object_mut(page_id) { - page_dict.set("Parent", Object::Reference(pages_id)); - } - - // Field dictionaries. - 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("FT", Object::Name(b"Tx".to_vec())); - if let Some(value) = field.value { - dict.set( - "V", - Object::String(value.as_bytes().to_vec(), StringFormat::Literal), - ); - } - if let Some(default) = field.default { - dict.set( - "DV", - Object::String(default.as_bytes().to_vec(), StringFormat::Literal), - ); - } - if field.appearance { - let mut appearance = Dictionary::new(); - appearance.set("N", Object::Null); - dict.set("AP", Object::Dictionary(appearance)); - } - 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())); - catalog.set("Pages", Object::Reference(pages_id)); - if !fields.is_empty() || need_appearances { - let mut acroform = Dictionary::new(); - acroform.set("Fields", Object::Array(field_refs)); - if need_appearances { - acroform.set("NeedAppearances", Object::Boolean(true)); - } - catalog.set("AcroForm", Object::Dictionary(acroform)); - } - let catalog_id = document.add_object(Object::Dictionary(catalog)); - document.trailer.set("Root", Object::Reference(catalog_id)); - - if !info_entries.is_empty() { - let mut info = Dictionary::new(); - for (key, value) in info_entries { - info.set( - key.as_bytes().to_vec(), - Object::String(value.as_bytes().to_vec(), StringFormat::Literal), - ); - } - let info_id = document.add_object(Object::Dictionary(info)); - document.trailer.set("Info", Object::Reference(info_id)); - } - - let mut bytes = Vec::new(); - document.save_to(&mut bytes).expect("serialise test PDF"); - bytes - } - - #[test] - fn non_pdf_bytes_are_not_inspected() { - let provenance = classify_pdf(b"this is not a PDF at all"); - assert!(!provenance.inspected); - assert_eq!(provenance.origin, FormOrigin::Unknown); - assert!( - provenance.evidence[0].contains("PDF parse failed"), - "{:?}", - provenance.evidence - ); - } - - #[test] - fn empty_input_is_not_inspected() { - let provenance = classify_pdf(b""); - assert!(!provenance.inspected); - assert_eq!(provenance.evidence[0], "document is empty"); - } - - #[test] - fn oversize_document_is_not_inspected() { - // The size guard runs before parsing, so padding is enough. - let oversized = vec![b'%'; MAX_INSPECT_BYTES + 1]; - let provenance = classify_pdf(&oversized); - assert!(!provenance.inspected); - assert!( - provenance.evidence[0].contains("inspection budget"), - "{:?}", - provenance.evidence - ); - } - - #[test] - fn pdf_without_acroform_is_not_a_form() { - let bytes = build_pdf(Vec::new(), false, Vec::new()); - let provenance = classify_pdf(&bytes); - - assert!(provenance.inspected); - assert!(!provenance.is_form()); - assert_eq!(provenance.origin, FormOrigin::NotAForm); - assert_eq!(provenance.confidence, ProvenanceConfidence::Strong); - assert!(!provenance.is_machine_filled()); - } - - #[test] - fn blank_form_is_empty_not_machine_filled() { - let bytes = build_pdf( - vec![TestField::blank("surname"), TestField::blank("given")], - false, - Vec::new(), - ); - let provenance = classify_pdf(&bytes); - - assert!(provenance.is_form()); - assert_eq!(provenance.origin, FormOrigin::Empty); - assert_eq!(provenance.field_count, 2); - assert_eq!(provenance.filled_field_count, 0); - assert!(!provenance.is_machine_filled()); - assert!(!provenance.should_hold(FormProvenancePolicy::HoldForReview)); - } - - #[test] - fn blocky_writer_shaped_output_is_machine_filled() { - // Exactly what `fill_blocks` produces: values plus /DV defaults, no - // appearance streams, and /NeedAppearances set. - let bytes = build_pdf( - vec![ - TestField::machine("surname", "Smith"), - TestField::machine("given", "Ada"), - TestField::machine("postcode", "MK7 6AA"), - ], - true, - Vec::new(), - ); - let provenance = classify_pdf(&bytes); - - assert!(provenance.inspected); - assert!(provenance.is_form()); - assert_eq!(provenance.origin, FormOrigin::Machine); - assert_eq!(provenance.confidence, ProvenanceConfidence::Probable); - assert!(provenance.is_machine_filled()); - assert_eq!(provenance.field_count, 3); - assert_eq!(provenance.filled_field_count, 3); - assert_eq!(provenance.filled_with_default, 3); - assert_eq!(provenance.values_without_appearance, 3); - assert!(provenance.need_appearances); - assert!(provenance.should_hold(FormProvenancePolicy::HoldForReview)); - } - - #[test] - fn values_without_appearances_alone_are_machine_filled() { - let bytes = build_pdf( - vec![TestField { - name: "surname", - value: Some("Smith"), - default: None, - appearance: false, - }], - false, - Vec::new(), - ); - let provenance = classify_pdf(&bytes); - - assert_eq!(provenance.origin, FormOrigin::Machine); - assert_eq!(provenance.confidence, ProvenanceConfidence::Probable); - assert_eq!(provenance.values_without_appearance, 1); - assert!(!provenance.need_appearances); - } - - #[test] - fn hand_filled_form_is_human() { - // A viewer-filled form: values with appearance streams, no - // /NeedAppearances, and no /DV rewrites. - let bytes = build_pdf( - vec![ - TestField::human("surname", "Smith"), - TestField::human("given", "Ada"), - ], - false, - Vec::new(), - ); - let provenance = classify_pdf(&bytes); - - assert!(provenance.is_form()); - assert_eq!(provenance.origin, FormOrigin::Human); - assert_eq!(provenance.confidence, ProvenanceConfidence::Probable); - assert_eq!(provenance.filled_field_count, 2); - assert_eq!(provenance.values_without_appearance, 0); - assert_eq!(provenance.filled_with_default, 0); - assert!(!provenance.is_machine_filled()); - } - - #[test] - fn checkbox_off_state_is_not_a_value() { - let mut unchecked = TestField::blank("consent"); - unchecked.value = Some("Off"); - let bytes = build_pdf(vec![unchecked], false, Vec::new()); - let provenance = classify_pdf(&bytes); - - assert_eq!(provenance.field_count, 1); - assert_eq!(provenance.filled_field_count, 0); - assert_eq!(provenance.origin, FormOrigin::Empty); - } - - #[test] - fn known_producer_marks_form_machine_filled() { - let bytes = build_pdf( - vec![TestField::human("surname", "Smith")], - false, - vec![("Producer", "blocky-writer 0.4.2")], - ); - let provenance = classify_pdf(&bytes); - - assert_eq!(provenance.origin, FormOrigin::Machine); - assert_eq!(provenance.confidence, ProvenanceConfidence::Strong); - assert_eq!(provenance.producer.as_deref(), Some("blocky-writer 0.4.2")); - assert_eq!(provenance.tool(), Some("blocky-writer 0.4.2")); - assert!(provenance.summary().contains("blocky-writer")); - } - - #[test] - fn generator_producer_does_not_mark_blank_form() { - // A blank template from a general PDF generator is still an empty - // form, not a machine-filled one. - let bytes = build_pdf( - vec![TestField::blank("surname")], - false, - vec![("Producer", "iText 2.1.7")], - ); - let provenance = classify_pdf(&bytes); - - assert_eq!(provenance.origin, FormOrigin::Empty); - assert!(!provenance.is_machine_filled()); - } - - #[test] - fn explicit_info_marker_outranks_structure() { - // Structurally this looks hand-filled; the marker says otherwise. - let bytes = build_pdf( - vec![TestField::human("surname", "Smith")], - false, - vec![ - ("FillOrigin", "machine"), - ("FillTool", "blocky-writer 0.4.2"), - ("FillTime", "2026-09-27T10:15:00Z"), - ], - ); - let provenance = classify_pdf(&bytes); - - assert_eq!(provenance.origin, FormOrigin::Machine); - assert_eq!(provenance.confidence, ProvenanceConfidence::Explicit); - let marker = provenance.marker.expect("marker recorded"); - assert!(marker.contains("FillOrigin=machine"), "{marker}"); - assert!(marker.contains("blocky-writer 0.4.2"), "{marker}"); - assert!(marker.contains("2026-09-27T10:15:00Z"), "{marker}"); - assert_eq!(provenance.tool(), Some(marker.as_str())); - } - - #[test] - fn explicit_human_marker_does_not_trigger() { - let bytes = build_pdf( - vec![TestField::human("surname", "Smith")], - false, - vec![("FillOrigin", "human")], - ); - let provenance = classify_pdf(&bytes); - - assert!(provenance.marker.is_none()); - assert_eq!(provenance.origin, FormOrigin::Human); - } - - #[test] - fn marker_scanner_understands_all_three_forms() { - assert_eq!( - scan_for_marker(br#"bw:fillOrigin="machine""#).as_deref(), - Some("machine") - ); - assert_eq!( - scan_for_marker(br#"FILLORIGIN = 'machine-filled'"#).as_deref(), - Some("machine-filled") - ); - assert_eq!( - scan_for_marker(b"bw:fillOrigin=machine bw:x=1").as_deref(), - Some("machine") - ); - assert_eq!( - scan_for_marker(b"machine").as_deref(), - Some("machine") - ); - assert_eq!(scan_for_marker(b"no marker here"), None); - assert_eq!(scan_for_marker(br#"fillOrigin="""#), None); - // A different attribute whose name merely starts with the needle. - assert_eq!(scan_for_marker(b"fillOriginTime=\"now\""), None); - } - - #[test] - fn unstructured_marker_in_raw_bytes_is_honoured() { - // An uncompressed XMP-style packet carried in the file. The element - // form is used because lopdf escapes quotes inside literal strings. - let bytes = build_pdf( - vec![TestField::human("surname", "Smith")], - false, - vec![("Note", "x:fillOrigin>machine FormProvenance { super::form::classify_document(&self.document, &[]) } diff --git a/crates/presswerk-document/src/provenance.rs b/crates/presswerk-document/src/provenance.rs new file mode 100644 index 0000000..6144b6e --- /dev/null +++ b/crates/presswerk-document/src/provenance.rs @@ -0,0 +1,1054 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// FFP v1.0.0 — Detection and classification (DETECTION.adoc) +// See presswerk-core/src/provenance.rs for the record types. + +use std::collections::HashSet; + +use lopdf::{Dictionary, Document, Object, ObjectId}; +use presswerk_core::provenance::{FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord}; +use presswerk_core::types::{DocumentType, FormProvenancePolicy}; +use tracing::{debug, instrument}; + +/// Documents larger than this are not inspected. +pub const MAX_INSPECT_BYTES: usize = 8 * 1024 * 1024; +const MAX_FIELD_NODES: u32 = 100_000; +const MAX_FIELD_DEPTH: u32 = 32; + +/// Namespace URI for FFP. +const FFP_NS: &[u8] = b"https://hyperpolymath.dev/ns/form-fill-provenance/1.0/"; + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +#[instrument(skip_all, fields(bytes = data.len(), policy = %policy.as_token()))] +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"); + } + if document_type != DocumentType::Pdf { + // Non-PDF is not a form. Use no-form rather than unreadable so it + // doesn't pollute audit as a failure. This is honest: a JPEG has no + // AcroForm. + return FfpRecord::no_form(vec!["FFP-E-NO-ACROFORM".to_string()]); + } + classify_pdf(data) +} + +#[instrument(skip_all, fields(bytes = data.len()))] +pub fn classify_pdf(data: &[u8]) -> FfpRecord { + if data.is_empty() { + return FfpRecord::unreadable("document is empty"); + } + if data.len() > MAX_INSPECT_BYTES { + return FfpRecord::unreadable(&format!( + "document is {} bytes, above the {} byte inspection budget", + data.len(), + MAX_INSPECT_BYTES + )); + } + let document = match Document::load_mem(data) { + Ok(d) => d, + Err(e) => { + debug!(error = %e, "PDF parse failed during provenance inspection"); + return FfpRecord::unreadable(&format!("PDF parse failed: {e}")); + } + }; + classify_document(&document, data) +} + +#[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) + if !raw.is_empty() && !contains_bytes(raw, b"%PDF-") { + // But raw may be empty (called from PdfReader); then we still try document parse + // If document was parsed successfully, raw check is not authoritative. + // Only if raw non-empty and missing PDF header, treat as unreadable. + // However if raw empty, we rely on document structure. + } + + let mut evidence: HashSet = HashSet::new(); + let mut declared: Option = None; + let mut declared_filled_by: Option = None; + + // Step 2 — declared marker (XMP in catalog Metadata) + let mut xmp_found = false; + let mut xmp_unreadable = false; + if let Ok(catalog) = document.catalog() { + if let Ok(meta_obj) = catalog.get(b"Metadata") { + xmp_found = true; + let payload = get_metadata_payload(document, meta_obj); + if payload.is_empty() { + evidence.insert("FFP-E-XMP-UNREADABLE".to_string()); + xmp_unreadable = true; + } else if contains_bytes(&payload, FFP_NS) { + // Namespace present — extract properties + let filled_by = xmp_extract(&payload, "filledBy"); + let tool = xmp_extract(&payload, "tool"); + 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")); + + if let Some(fb) = filled_by { + let fb_trim = fb.trim().to_string(); + if fb_trim == "machine" { + evidence.insert("FFP-E-DECL-MACHINE".to_string()); + declared_filled_by = Some(fb_trim.clone()); + declared = Some(FfpDeclared { + filled_by: fb_trim, + tool: tool.filter(|s| !s.trim().is_empty()), + tool_version: tool_version.filter(|s| !s.trim().is_empty()), + filled_at: filled_at.filter(|s| !s.trim().is_empty()), + appearances_generated: ap_gen, + }); + } else { + evidence.insert("FFP-E-DECL-UNRECOGNISED".to_string()); + // Still record declared verbatim per spec + declared = Some(FfpDeclared { + filled_by: fb_trim.clone(), + tool: tool.filter(|s| !s.trim().is_empty()), + tool_version: tool_version.filter(|s| !s.trim().is_empty()), + filled_at: filled_at.filter(|s| !s.trim().is_empty()), + appearances_generated: ap_gen, + }); + declared_filled_by = Some(fb_trim); + // For classification, unrecognised does not count as machine + } + } else { + evidence.insert("FFP-E-DECL-UNRECOGNISED".to_string()); + // Namespace present but filledBy absent + } + } else { + // Metadata present but no FFP namespace — no declaration evidence + } + } + } + // If Metadata not present, no XMP evidence. + + // Step 3 — field tree + let catalog = match document.catalog() { + Ok(c) => c, + Err(_) => { + return FfpRecord { + ffp: "1.0".to_string(), + classification: FfpClassification::Unreadable, + form: FfpForm::Unknown, + filled_fields: 0, + total_fields: 0, + appearances: FfpAppearances::Unknown, + declared, + evidence: vec!["FFP-E-UNREADABLE".to_string()], + }; + } + }; + + 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()); + // No form + let mut ev: Vec = evidence.into_iter().collect(); + ev.sort(); + return FfpRecord { + ffp: "1.0".to_string(), + classification: FfpClassification::NoForm, + form: FfpForm::Absent, + filled_fields: 0, + total_fields: 0, + appearances: FfpAppearances::NotApplicable, + declared, + evidence: ev, + }; + } + }; + + // NeedAppearances + if matches!(acroform_dict.get(b"NeedAppearances"), Ok(Object::Boolean(true))) { + evidence.insert("FFP-E-NEED-APPEARANCES".to_string()); + } + + // Walk fields + let mut scan = FieldScan { + total: 0, + filled: 0, + ap_missing: 0, + visited: 0, + }; + 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); + } + + let total = scan.total; + let filled = scan.filled; + let ap_missing = scan.ap_missing; + + if total == 0 { + evidence.insert("FFP-E-NO-ACROFORM".to_string()); + let mut ev: Vec = evidence.into_iter().collect(); + ev.sort(); + return FfpRecord { + ffp: "1.0".to_string(), + classification: FfpClassification::NoForm, + form: FfpForm::Absent, + filled_fields: 0, + total_fields: 0, + appearances: FfpAppearances::NotApplicable, + declared, + evidence: ev, + }; + } + + // Step 5 — appearances + let appearances = if filled == 0 { + FfpAppearances::NotApplicable + } else if ap_missing > 0 { + evidence.insert("FFP-E-AP-INCOMPLETE".to_string()); + FfpAppearances::Incomplete + } else { + FfpAppearances::Generated + }; + + if filled == 0 { + evidence.insert("FFP-E-NO-VALUES".to_string()); + } + + // Step 6 — classify + 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") { + FfpClassification::MachineFilledSuspected + } else { + FfpClassification::FilledUnknown + }; + + let mut ev: Vec = evidence.into_iter().collect(); + ev.sort(); + // For blank-form, ensure NO-VALUES present, and appearance not-applicable + // For unreadable, we already returned. + + FfpRecord { + ffp: "1.0".to_string(), + classification, + form, + filled_fields: filled, + total_fields: total, + appearances, + declared, + evidence: ev, + } +} + +// --------------------------------------------------------------------------- +// Field scan +// --------------------------------------------------------------------------- + +struct FieldScan { + total: u32, + filled: u32, + ap_missing: u32, + visited: u32, +} + +fn walk_field( + document: &Document, + node: &Object, + inherit_ft: &str, + inherit_ff: &str, + inherit_v: &str, + depth: u32, + seen: &mut HashSet, + scan: &mut FieldScan, + _evidence: &mut HashSet, +) { + if depth > MAX_FIELD_DEPTH || scan.visited >= MAX_FIELD_NODES { + return; + } + match node { + Object::Reference(id) => { + if !seen.insert(*id) { + return; + } + 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); + } + } + Object::Array(items) => { + for item in items { + walk_field(document, item, inherit_ft, inherit_ff, inherit_v, depth + 1, seen, scan, _evidence); + } + } + Object::Dictionary(dict) => { + scan.visited += 1; + // 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()); + + // 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); + + // 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 is_widget = first_is_widget || first_is_widget2; + if is_widget { + // Terminal field with widget kids + register_field(document, dict, &ft, &ff, &v_raw, &kids_ids, scan); + } else { + // 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); + } + } + } + return; + } + } + // No kids — terminal field (or Kids empty) + // Need to check if this dict itself is a field (has FT or inherited) + // The probe registers if FT matches Tx/Ch/Btn + register_field(document, dict, &ft, &ff, &v_raw, &[], scan); + } + Object::Stream(stream) => { + scan.visited += 1; + 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()); + 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), + _ => 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); + } + } + } + return; + } + } + register_field(document, dict, &ft, &ff, &v_raw, &[], scan); + } + _ => {} + } +} + +fn register_field( + document: &Document, + field_dict: &Dictionary, + ft: &str, + ff_str: &str, + v_raw: &str, + widget_ids: &[ObjectId], + 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() }; + if !matches!(ft_norm.as_str(), "/Tx" | "/Ch" | "/Btn") { + return; + } + // Check Btn pushbutton bit + if ft_norm == "/Btn" { + let ff_val = ff_str.trim().parse::().unwrap_or(0); + // Also handle hex? But assume decimal. + // Probe uses int(ff / 65536) %2 ==1 + // We check bit 17 (0x10000) + let f_num = parse_ff(ff_str); + if (f_num & 0x10000) != 0 { + return; // pushbutton, not fillable + } + // Also check via ff_val + if ff_val != 0 && (ff_val & 0x10000) != 0 { + return; + } + } + + scan.total += 1; + // Check meaningful value + // Need to get actual V object for meaningful check, not just string + let v_obj = field_dict.get(b"V").ok(); + let meaningful = is_meaningful(document, &ft_norm, v_obj, v_raw); + if !meaningful { + return; + } + scan.filled += 1; + + // Appearance check per widget + let widgets: Vec = if widget_ids.is_empty() { + // Field itself is widget — need to find its object id? We don't have it. + // For appearance, check field dict itself. + // We will treat field_dict as widget dict. + // Use a sentinel: check field_dict directly + // To simplify, we check field_dict's AP + if has_normal_appearance_dict(document, field_dict, v_raw) { + // has appearance + } else { + scan.ap_missing += 1; + } + return; + } else { + widget_ids.to_vec() + }; + + for wid in widgets { + if let Ok(widget_obj) = document.get_object(wid) { + let widget_dict = match widget_obj { + Object::Dictionary(d) => d, + Object::Stream(s) => &s.dict, + _ => continue, + }; + if has_normal_appearance_dict(document, widget_dict, v_raw) { + continue; + } else { + scan.ap_missing += 1; + } + } else { + scan.ap_missing += 1; + } + } +} + +fn parse_ff(s: &str) -> i32 { + let t = s.trim(); + if t.is_empty() { + return 0; + } + // Try decimal + if let Ok(v) = t.parse::() { + return v; + } + // Try name? Not. + 0 +} + +fn has_normal_appearance_dict(document: &Document, widget_dict: &Dictionary, v_raw: &str) -> bool { + // Check /AP + let ap_obj = match widget_dict.get(b"AP") { + Ok(o) => o, + Err(_) => return false, + }; + let ap_dict = match resolve_dict(document, ap_obj) { + Some(d) => d, + None => return false, + }; + let n_obj = match ap_dict.get(b"N") { + Ok(o) => o, + Err(_) => return false, + }; + // Resolve N: could be stream or dict or reference + let n_resolved = match n_obj { + Object::Reference(id) => match document.get_object(*id) { + Ok(Object::Stream(s)) => return true, // stream + Ok(Object::Dictionary(d)) => { + // Dict case: check for state + // Need current state: AS or V + let state = get_current_state(widget_dict, v_raw); + if state.is_empty() { + return false; + } + // Dict key is state without leading / + let key = state.trim_start_matches('/'); + // Look up key in dict + match d.get(key.as_bytes()) { + Ok(Object::Reference(sid)) => match document.get_object(*sid) { + Ok(Object::Stream(_)) => return true, + Ok(Object::Dictionary(_)) => return true, // could be? + _ => return false, + }, + Ok(Object::Stream(_)) => return true, + Ok(Object::Dictionary(_)) => return true, + _ => return false, + } + } + Ok(Object::Array(_)) => return false, + _ => return false, + }, + Object::Stream(_) => return true, + Object::Dictionary(d) => { + let state = get_current_state(widget_dict, v_raw); + if state.is_empty() { + return false; + } + let key = state.trim_start_matches('/'); + match d.get(key.as_bytes()) { + Ok(Object::Reference(sid)) => match document.get_object(*sid) { + Ok(Object::Stream(_)) => return true, + _ => return false, + }, + Ok(Object::Stream(_)) => return true, + _ => return false, + } + } + Object::Array(_) => return false, + _ => return false, + }; + // If N is a name or other, not appearance + false +} + +fn get_current_state(widget_dict: &Dictionary, v_raw: &str) -> String { + // Try /AS on widget, then field? For now only widget + if let Ok(Object::Name(name)) = widget_dict.get(b"AS") { + return format!("/{}", String::from_utf8_lossy(name)); + } + if let Ok(Object::String(bytes, _)) = widget_dict.get(b"AS") { + return String::from_utf8_lossy(bytes).to_string(); + } + // Try V raw if it's a name like /Yes + let v_trim = v_raw.trim(); + if v_trim.starts_with('/') { + return v_trim.to_string(); + } + "".to_string() +} + +fn is_meaningful(document: &Document, ft: &str, v_obj: Option<&Object>, v_raw: &str) -> bool { + match ft { + "/Btn" => { + // V must be name other than /Off + if let Some(obj) = v_obj { + match obj { + Object::Name(name) => { + return !name.eq_ignore_ascii_case(b"Off") && !name.is_empty(); + } + Object::String(bytes, _) => { + // Could be string? treat as name? + return !bytes.eq_ignore_ascii_case(b"Off") && !bytes.is_empty(); + } + 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(), + _ => return false, + } + } + return false; + } + _ => return false, + } + } + // Fallback to raw string + let t = v_raw.trim(); + if t.is_empty() || t.eq_ignore_ascii_case("/Off") || t.eq_ignore_ascii_case("Off") { + return false; + } + t.starts_with('/') + } + "/Tx" => { + if let Some(obj) = v_obj { + return is_string_object_meaningful(document, obj); + } + is_string_meaningful(v_raw.as_bytes()) + } + "/Ch" => { + if let Some(obj) = v_obj { + match obj { + Object::String(_, _) => return is_string_object_meaningful(document, obj), + Object::Name(n) => return !n.is_empty() && !n.eq_ignore_ascii_case(b"Off"), + Object::Array(items) => { + for item in items { + if is_string_object_meaningful(document, item) { + return true; + } + if let Object::Reference(id) = item { + if let Ok(res) = document.get_object(*id) { + if is_string_object_meaningful(document, res) { + return true; + } + } + } + } + return false; + } + Object::Reference(id) => { + if let Ok(res) = document.get_object(*id) { + match res { + Object::Array(items) => { + for item in items { + if is_string_object_meaningful(document, item) { + return true; + } + } + return false; + } + _ => return is_string_object_meaningful(document, res), + } + } + return false; + } + _ => return false, + } + } + is_string_meaningful(v_raw.as_bytes()) + } + _ => false, + } +} + +fn is_string_object_meaningful(document: &Document, obj: &Object) -> bool { + match obj { + Object::String(bytes, _) => is_string_meaningful(bytes), + Object::Name(name) => !name.is_empty() && !name.eq_ignore_ascii_case(b"Off"), + Object::Reference(id) => { + if let Ok(res) = document.get_object(*id) { + is_string_object_meaningful(document, res) + } else { + false + } + } + _ => false, + } +} + +fn is_string_meaningful(bytes: &[u8]) -> bool { + // For Tx: string containing at least one non-whitespace + // Whitespace is U+0020, 0009, 000D, 000A + // For raw v_raw string, it may include parentheses like "(Jewell)" + // We need to handle both raw object string and decoded bytes. + // If bytes contains parentheses, strip them? For object string, bytes is content without parens. + // For v_raw string like "(Jewell)" or "( )" or "()", we need to detect. + let mut s = bytes; + // If it looks like "(...)" from v_raw, strip outer parens and unescape? + // Probe's string_meaningful handles `(`, `<` hex, etc. + // 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]; + // 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]; + // 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(); + 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(); + !trimmed.is_empty() +} + +// --------------------------------------------------------------------------- +// Helpers for PDF object graph +// --------------------------------------------------------------------------- + +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, + }), + _ => None, + } +} + +#[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, + }), + _ => None, + } +} + +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::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. + // Fallback: return None so inheritance handled. + let _ = id; + None + } + _ => None, + } +} + +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::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(' '); } + s.push_str(&object_to_string(item)); + } + s.push(']'); + s + } + Object::Dictionary(d) => format!("<<{}>>", d.len()), + Object::Stream(_) => "<>".to_string(), + Object::Reference(id) => format!("{} 0 R", id.0), + Object::Integer64(i) => i.to_string(), + } +} + +fn get_array_ids(document: &Document, obj: &Object) -> Vec { + let mut ids = Vec::new(); + match obj { + Object::Array(items) => { + 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 { + ids.push(*kid); + } + } + } + } + _ => {} + } + ids +} + +fn resolve_reference(document: &Document, obj: &Object) -> Option { + if let Object::Reference(id) = obj { + Some(*id) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// XMP helpers +// --------------------------------------------------------------------------- + +fn get_metadata_payload(document: &Document, meta_obj: &Object) -> Vec { + let obj = match meta_obj { + Object::Reference(id) => match document.get_object(*id) { + Ok(o) => o, + Err(_) => return Vec::new(), + }, + other => other, + }; + if let Object::Stream(stream) = obj { + stream.content.clone() + } else { + Vec::new() + } +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +fn xmp_extract(payload: &[u8], prop: &str) -> Option { + let payload_str = String::from_utf8_lossy(payload); + let payload_s = payload_str.as_ref(); + + // Attribute form: prop="value" or prop='value' + // Search for prop with optional prefix like ffp:prop or any:prop + // We look for `prop` then `\s*=\s*"` + // To handle prefix, we search for `prop` substring case-sensitive but allow preceding `:` or whitespace + // Simpler: use regex-like search for `prop\s*=` + // We'll do manual scan. + let prop_bytes = prop.as_bytes(); + let payload_bytes = payload; + + // Try attribute form + if let Some(pos) = find_ci(payload_bytes, prop_bytes) { + // Ensure it's a property name: check char before is ':' or whitespace or '"' or ''' + // Check after prop is whitespace then = + let after = pos + prop_bytes.len(); + if after < payload_bytes.len() { + let mut rest = &payload_bytes[after..]; + // trim whitespace + rest = trim_ascii_start(rest); + if !rest.is_empty() && rest[0] == b'=' { + rest = trim_ascii_start(&rest[1..]); + if !rest.is_empty() { + match rest[0] { + b'"' | b'\'' => { + let quote = rest[0]; + rest = &rest[1..]; + if let Some(end) = rest.iter().position(|&b| b == quote) { + let val = &rest[..end]; + let s = String::from_utf8_lossy(val).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + _ => { + // Unquoted (should not happen for XMP but handle) + let mut end = 0; + while end < rest.len() && !rest[end].is_ascii_whitespace() && rest[end] != b'>' { + end += 1; + } + if end > 0 { + let val = &rest[..end]; + let s = String::from_utf8_lossy(val).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + } + } + } + // If not attribute, try element form + // rest after prop may be `>value<` + let mut rest2 = &payload_bytes[pos + prop_bytes.len()..]; + // Check if next char is `>` after optional whitespace? + // Actually element form is `value` + // After prop name, should be `>` + if !rest2.is_empty() && rest2[0] == b'>' { + rest2 = &rest2[1..]; + if let Some(end) = rest2.iter().position(|&b| b == b'<') { + let val = &rest2[..end]; + let s = String::from_utf8_lossy(val).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + // Also try with namespace prefix: search for `:prop` then `>` + // The above already handles because we searched for prop without prefix, and after is `>` + // For attribute form we already handled `=` + // For element, we need to check `>value` + // Already did. + + // Alternative: search for `<...:prop>` pattern more robustly + // We can also do a second pass: find `:` then `>value<` + } + } + + // 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..]; + if let Some(end) = rest.iter().position(|&b| b == b'<') { + let val = &rest[..end]; + let s = String::from_utf8_lossy(val).trim().to_string(); + if !s.is_empty() { + return Some(s); + } + } + } + // Also check attribute with prefix: `:prop="value"` + if after < payload_bytes.len() { + let mut rest = &payload_bytes[after..]; + 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'\'') { + 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); } + } + } + } + } + } + + None +} + +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)) +} + +fn trim_ascii_start(bytes: &[u8]) -> &[u8] { + let mut i = 0; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + &bytes[i..] +} + +#[cfg(test)] +mod tests { + use super::*; + use lopdf::{Dictionary, Document, Object, StringFormat}; + + struct TestField { + name: &'static str, + value: Option<&'static str>, + appearance: bool, + } + + impl TestField { + const fn human(name: &'static str, value: &'static str) -> Self { + Self { name, value: Some(value), appearance: true } + } + const fn machine(name: &'static str, value: &'static str) -> Self { + Self { name, value: Some(value), appearance: false } + } + const fn blank(name: &'static str) -> Self { + Self { name, value: None, appearance: false } + } + } + + 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 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("Contents", Object::Reference(content_id)); + let page_id = document.add_object(Object::Dictionary(page)); + let mut pages = Dictionary::new(); + pages.set("Type", Object::Name(b"Pages".to_vec())); + pages.set("Kids", Object::Array(vec![Object::Reference(page_id)])); + pages.set("Count", Object::Integer(1)); + let pages_id = document.add_object(Object::Dictionary(pages)); + if let Ok(Object::Dictionary(page_dict)) = document.get_object_mut(page_id) { + page_dict.set("Parent", Object::Reference(pages_id)); + } + 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("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)); + } + 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)))); + } + let mut catalog = Dictionary::new(); + catalog.set("Type", Object::Name(b"Catalog".to_vec())); + catalog.set("Pages", Object::Reference(pages_id)); + if !fields.is_empty() || need_appearances || xmp.is_some() { + let mut acroform = Dictionary::new(); + if !field_refs.is_empty() { + acroform.set("Fields", Object::Array(field_refs)); + } + if need_appearances { + acroform.set("NeedAppearances", Object::Boolean(true)); + } + catalog.set("AcroForm", Object::Dictionary(acroform)); + } + let catalog_id = document.add_object(Object::Dictionary(catalog)); + document.trailer.set("Root", Object::Reference(catalog_id)); + if let Some(xmp_str) = xmp { + 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()))); + if let Ok(Object::Dictionary(cat)) = document.get_object_mut(catalog_id) { + cat.set("Metadata", Object::Reference(meta_id)); + } + } + let mut bytes = Vec::new(); + document.save_to(&mut bytes).expect("serialise"); + bytes + } + + #[test] + fn empty_is_unreadable() { + let r = classify_pdf(b""); + assert_eq!(r.classification, FfpClassification::Unreadable); + } + + #[test] + fn blank_form_not_machine() { + 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); + } + + #[test] + fn machine_suspected_without_marker() { + 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); + } + + #[test] + fn machine_filled_with_marker() { + let xmp = r#""#.to_string(); + 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")); + } + + #[test] + fn viewer_filled_is_unknown() { + 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 9cf6602..c449cac 100644 --- a/crates/presswerk-print/src/ipp_server.rs +++ b/crates/presswerk-print/src/ipp_server.rs @@ -1123,10 +1123,12 @@ fn handle_print_job(request: &IppRequest, peer_addr: SocketAddr, state: &SharedS if provenance.is_form() { info!( job_name = %document_name, - origin = %provenance.origin, - confidence = %provenance.confidence, - fields = provenance.field_count, - filled = provenance.filled_field_count, + classification = %provenance.classification, + form = %provenance.form, + filled = provenance.filled_fields, + total = provenance.total_fields, + appearances = %provenance.appearances, + evidence = ?provenance.evidence, "form provenance recorded for incoming print job" ); state.audit( @@ -2448,10 +2450,9 @@ mod tests { assert_eq!(jobs.len(), 1); let provenance = &jobs[0].form_provenance; - assert!(provenance.inspected); assert!(provenance.is_form()); - assert!(provenance.is_machine_filled(), "{provenance:?}"); - assert_eq!(provenance.filled_field_count, 1); + 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. assert_eq!(jobs[0].status, JobStatus::Pending); @@ -2459,9 +2460,10 @@ mod tests { // And the determination is queryable for audit and routing. assert_eq!( queue - .get_jobs_with_form_origin(presswerk_core::types::FormOrigin::Machine) + .get_jobs_with_form_origin(presswerk_core::provenance::FfpClassification::MachineFilled) .expect("query by origin") - .len(), + .len() + + queue.get_jobs_with_form_origin(presswerk_core::provenance::FfpClassification::MachineFilledSuspected).expect("query suspected").len(), 1 ); } @@ -2483,7 +2485,7 @@ mod tests { .expect("form_provenance audit entry"); assert!(entry.success); let details = entry.details.as_deref().expect("details present"); - assert!(details.contains("\"origin\":\"Machine\""), "{details}"); + assert!(details.contains("\"classification\":\"machine-filled") || details.contains("\"classification\":\"machine-filled-suspected\""), "{details}"); } #[test] @@ -2504,10 +2506,9 @@ mod tests { let queue = state.job_queue.lock().expect("mutex poisoned"); let jobs = queue.get_all_jobs().expect("get_all_jobs"); - assert!(!jobs[0].form_provenance.inspected); assert_eq!( - jobs[0].form_provenance.origin, - presswerk_core::types::FormOrigin::Unknown + jobs[0].form_provenance.classification, + presswerk_core::provenance::FfpClassification::Unreadable ); } @@ -2531,7 +2532,7 @@ 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].status, JobStatus::Held); - assert!(jobs[0].form_provenance.is_machine_filled()); + assert!(jobs[0].form_provenance.is_machine_or_suspected()); drop(queue); let log = audit.lock().expect("audit mutex poisoned"); @@ -2553,8 +2554,8 @@ 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.origin, - presswerk_core::types::FormOrigin::Human, + jobs[0].form_provenance.classification, + presswerk_core::provenance::FfpClassification::FilledUnknown, "{:?}", jobs[0].form_provenance ); @@ -2571,7 +2572,7 @@ mod tests { let queue = state.job_queue.lock().expect("mutex poisoned"); let jobs = queue.get_all_jobs().expect("get_all_jobs"); - assert!(!jobs[0].form_provenance.inspected); + 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 3648a59..2199876 100644 --- a/crates/presswerk-print/src/queue.rs +++ b/crates/presswerk-print/src/queue.rs @@ -13,9 +13,9 @@ use rusqlite::{Connection, params}; use tracing::{debug, info, instrument}; use presswerk_core::error::{PresswerkError, Result}; +use presswerk_core::provenance::{FfpClassification, FfpRecord, FormProvenanceLegacy}; use presswerk_core::types::{ - DocumentType, ErrorClass, FormOrigin, FormProvenance, JobId, JobSource, JobStatus, PrintJob, - PrintSettings, + DocumentType, ErrorClass, JobId, JobSource, JobStatus, PrintJob, PrintSettings, }; /// SQLite schema for the jobs table. @@ -166,7 +166,7 @@ impl JobQueue { let error_history_json = serde_json::to_string(&job.error_history) .map_err(|e| PresswerkError::Database(format!("serialize error_history: {e}")))?; let form_provenance_json = job.form_provenance.audit_json(); - let form_origin = job.form_provenance.origin.as_token(); + let form_origin = job.form_provenance.classification.as_str(); self.conn .execute( @@ -325,7 +325,7 @@ impl JobQueue { /// happens on the `form_origin` token column, so it never has to parse the /// JSON determination. #[instrument(skip(self), fields(origin = %origin))] - pub fn get_jobs_with_form_origin(&self, origin: FormOrigin) -> Result> { + pub fn get_jobs_with_form_origin(&self, origin: FfpClassification) -> Result> { let mut stmt = self .conn .prepare( @@ -339,7 +339,7 @@ impl JobQueue { .map_err(|e| PresswerkError::Database(format!("prepare get_by_origin: {e}")))?; let jobs = stmt - .query_map(params![origin.as_token()], row_to_print_job) + .query_map(params![origin.as_str()], row_to_print_job) .map_err(|e| PresswerkError::Database(format!("query get_by_origin: {e}")))? .collect::, _>>() .map_err(|e| PresswerkError::Database(format!("collect rows: {e}")))?; @@ -397,14 +397,66 @@ fn row_to_print_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { // inspected" — never as a guessed determination. let form_origin_token: Option = row.get(17).unwrap_or(None); let form_provenance_json: Option = row.get(18).unwrap_or(None); - let form_provenance: FormProvenance = form_provenance_json - .and_then(|json| serde_json::from_str(&json).ok()) - .unwrap_or_else(|| FormProvenance { - origin: form_origin_token - .as_deref() - .map(FormOrigin::from_token) - .unwrap_or_default(), - ..FormProvenance::default() + // FFP v1.0 record; fallback to legacy JSON, then to origin token, then to unreadable. + let form_provenance: FfpRecord = form_provenance_json + .as_deref() + .and_then(|json| { + // Try new shape + if let Ok(rec) = serde_json::from_str::(json) { + // `ffp` must be "1.0" or missing defaults to 1.0; guard against empty `{}` + if rec.ffp == "1.0" || rec.ffp.is_empty() { + // Empty `{}` parsed as default? Ensure classification is not default unreadable due to empty + // If json was "{}", rec will be unreadable with UNREADABLE evidence — that's correct for legacy default. + return Some(rec); + } + return Some(rec); + } + // Try legacy shape + if let Ok(legacy) = serde_json::from_str::(json) { + return Some(FfpRecord::from(legacy)); + } + None + }) + .unwrap_or_else(|| { + // No JSON — infer from legacy origin token if present + if let Some(tok) = form_origin_token.as_deref() { + let cls = FfpClassification::from_str(tok); + // Handle legacy tokens that are not kebab-case + let mapped = match tok { + "NotAForm" => FfpClassification::NoForm, + "Empty" => FfpClassification::BlankForm, + "Human" => FfpClassification::FilledUnknown, + "Machine" => FfpClassification::MachineFilledSuspected, + "Unknown" => FfpClassification::Unreadable, + _ => cls, + }; + match mapped { + FfpClassification::NoForm => FfpRecord::no_form(vec!["FFP-E-NO-ACROFORM".to_string()]), + FfpClassification::BlankForm => FfpRecord { + ffp: "1.0".to_string(), + classification: FfpClassification::BlankForm, + form: presswerk_core::provenance::FfpForm::Present, + filled_fields: 0, + total_fields: 0, + appearances: presswerk_core::provenance::FfpAppearances::NotApplicable, + declared: None, + evidence: vec!["FFP-E-NO-VALUES".to_string()], + }, + FfpClassification::Unreadable => FfpRecord::unreadable("legacy row without provenance"), + _ => FfpRecord { + ffp: "1.0".to_string(), + classification: mapped, + form: presswerk_core::provenance::FfpForm::Unknown, + filled_fields: 0, + total_fields: 0, + appearances: presswerk_core::provenance::FfpAppearances::Unknown, + declared: None, + evidence: vec!["FFP-E-UNREADABLE".to_string()], + }, + } + } else { + FfpRecord::unreadable("pre-migration row without provenance columns") + } }); // Parse the UUID. If the stored value is malformed we surface a @@ -590,23 +642,28 @@ mod tests { // -- Form provenance (issue #118, ruling D189) -------------------------- - /// A machine-filled determination, as the document crate would produce it. + /// A machine-filled determination, as the document crate would produce it (FFP v1.0). fn machine_filled_job() -> PrintJob { let mut job = test_job(); - job.form_provenance = FormProvenance { - origin: FormOrigin::Machine, - confidence: presswerk_core::types::ProvenanceConfidence::Probable, - inspected: true, - is_form: true, - field_count: 14, - filled_field_count: 12, - values_without_appearance: 12, - filled_with_default: 12, - need_appearances: true, - producer: Some("blocky-writer 0.4.2".to_string()), - creator: None, - marker: None, - evidence: vec!["12 of 14 fields carry values".to_string()], + job.form_provenance = FfpRecord { + ffp: "1.0".to_string(), + classification: FfpClassification::MachineFilled, + form: presswerk_core::provenance::FfpForm::Present, + filled_fields: 12, + total_fields: 14, + appearances: presswerk_core::provenance::FfpAppearances::Incomplete, + declared: Some(presswerk_core::provenance::FfpDeclared { + filled_by: "machine".to_string(), + tool: Some("blocky-writer 0.4.2".to_string()), + tool_version: Some("0.4.2".to_string()), + filled_at: None, + appearances_generated: Some(false), + }), + evidence: vec![ + "FFP-E-DECL-MACHINE".to_string(), + "FFP-E-NEED-APPEARANCES".to_string(), + "FFP-E-AP-INCOMPLETE".to_string(), + ], }; job } @@ -621,9 +678,11 @@ mod tests { assert_eq!(retrieved.form_provenance, job.form_provenance); assert!(retrieved.form_provenance.is_machine_filled()); assert_eq!( - retrieved.form_provenance.producer.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")); } #[test] @@ -636,19 +695,19 @@ mod tests { queue.insert_job(&plain).expect("insert plain"); let machine_jobs = queue - .get_jobs_with_form_origin(FormOrigin::Machine) + .get_jobs_with_form_origin(FfpClassification::MachineFilled) .expect("query machine"); assert_eq!(machine_jobs.len(), 1); assert_eq!(machine_jobs[0].id, machine.id); - let unknown_jobs = queue - .get_jobs_with_form_origin(FormOrigin::Unknown) - .expect("query unknown"); - assert_eq!(unknown_jobs.len(), 1); - assert_eq!(unknown_jobs[0].id, plain.id); + let unreadable_jobs = queue + .get_jobs_with_form_origin(FfpClassification::Unreadable) + .expect("query unreadable"); + assert_eq!(unreadable_jobs.len(), 1); + assert_eq!(unreadable_jobs[0].id, plain.id); assert!(queue - .get_jobs_with_form_origin(FormOrigin::Human) + .get_jobs_with_form_origin(FfpClassification::FilledUnknown) .expect("query human") .is_empty()); } @@ -734,18 +793,17 @@ mod tests { assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].document_name, "legacy-form.pdf"); - // A row with no provenance must read back as "not inspected", never as - // a guessed determination. + // A row with no provenance must read back as unreadable, never as a guessed determination. let provenance = &jobs[0].form_provenance; - assert!(!provenance.inspected); - assert_eq!(provenance.origin, FormOrigin::Unknown); + assert_eq!(provenance.classification, FfpClassification::Unreadable); + assert_eq!(provenance.form, presswerk_core::provenance::FfpForm::Unknown); assert!(!provenance.is_machine_filled()); // And new jobs written after migration carry their determination. let fresh = machine_filled_job(); queue.insert_job(&fresh).expect("insert post-migration job"); let machine_jobs = queue - .get_jobs_with_form_origin(FormOrigin::Machine) + .get_jobs_with_form_origin(FfpClassification::MachineFilled) .expect("query machine"); assert_eq!(machine_jobs.len(), 1); assert_eq!(machine_jobs[0].id, fresh.id); diff --git a/docs/APP_STORE_PREP.md b/docs/APP_STORE_PREP.md new file mode 100644 index 0000000..129922a --- /dev/null +++ b/docs/APP_STORE_PREP.md @@ -0,0 +1,78 @@ +# App Store Release Preparation — Presswerk (FFP D189 included) + +**Date:** 2026-10-04 +**Issue:** #122 — foundational fix for FFP D189 and Microsoft/Play/Apple release + +## 1. FFP D189 foundation (done in this branch) + +- Detector `presswerk-document::provenance::classify_pdf(&[u8]) -> FfpRecord` — FFP/1 MARKER (XMP at Catalog/Metadata, ffp:filledBy=machine, no /Info), FFP/2 DETECTION (14 vectors, canonical line `classification= form= filled=n/total appearances=<...> evidence=<...>`, no mutate, no panic), FFP/3 PRINT-PATH (P1-P7). +- Conformance: `cargo build --bin ffp-classify -p presswerk-document && FFP_DETECTOR=./target/debug/ffp-classify bash spec/conformance/run-conformance.sh` → 14/14. Also `just ffp-conformance`. +- Recorded: `PrintJob.form_provenance: FfpRecord` persisted in `presswerk-print/src/queue.rs` (SQLite + memory), JSON stable. +- Audit: `FfpRecord::audit_json()` via `presswerk-print/src/ipp_server.rs` and `presswerk-app/src/services/app_services.rs`, legacy `FormProvenanceLegacy` fallback in `pages/audit.rs`. +- Surfaced P4: badge per `FfpClassification` (`provenance_bg/border/color` helpers) in `pages/jobs.rs` distinguishes blank-form / machine-filled / suspected / filled-unknown / not-a-form / unreadable. +- Hazard P5: `has_blank_print_hazard()` banner “may print empty (NeedAppearances is deprecated by ISO 32000-2)” when `filled>0 && appearances==Incomplete`, `evidence FFP-E-AP-INCOMPLETE`, hold policy `should_hold()` includes suspected + hazard. +- Network intake: `ipp_server.rs` classifies at receipt before enqueue, before printer, `presswerk-document` dependency. +- No silent normalisation P3: detector never writes AP, consumer never generates. + +## 2. Microsoft Store (Windows) + +- Package: `cargo build --release` produces `target/release/presswerk` (desktop). For Store, wrap via `cargo bundle` / `msix` (see `Dioxus.toml` `[bundle]` — already has `identifier = "dev.hyperpolymath.presswerk"`, `publisher = "hyperpolymath"`, `icon = ["assets/icon.png"]`). Need actual `assets/icon.*` (512x512 PNG, ICO) — placeholder exists, replace with branded assets before submission. +- Identity: `Package/Identity Name="dev.hyperpolymath.Presswerk"` publisher must match Partner Center. Publisher ID currently `hyperpolymath` — set to real `CN=...` from certificate before `makeappx`. +- Signing: self-signed for sideload; Store submission signs in cloud. Keep `Dioxus.toml` `windows.wix` / `msix` config when adding `tauri-bundler` or `cargo-wix`. +- Capabilities: `runFullTrust`, `privateNetworkClientServer` (mDNS/IPP), `internetClient`. Declare in `Package.appxmanifest`. +- Age rating: IARC questionnaire (no user-generated content, no purchases — likely Everyone). +- Store listing: description, screenshots (4x 1280x720), privacy URL (see §5). + +## 3. Google Play (Android) + +- Build: `dx serve --platform android` / `dx bundle --platform android` already wired via `cargo mobile2` / `dioxus`. `Dioxus.toml` `[application]` bundle id is `dev.hyperpolymath.presswerk` — matches Play Console. +- Signing: Play App Signing — generate `upload-keystore.jks`, `key.properties`, do not commit. `android/app/build.gradle` `signingConfigs` from env. +- `AndroidManifest.xml` (generated by Dioxus): permissions `INTERNET`, `ACCESS_NETWORK_STATE`, `CHANGE_WIFI_MULTICAST_STATE` (mDNS), `ACCESS_FINE_LOCATION` only if needed for Wi-Fi discovery (avoid if possible to reduce disclosure). No `READ_MEDIA` unless file picker needs it (use SAF). +- Privacy: Data safety form — “No data collected” if true (local-only, no analytics). If mDNS scans LAN, declare “Device or other IDs — not collected”. See `crates/presswerk-app/privacy/PLAY_DATA_SAFETY.md`. +- Assets: `fastlane/metadata/android/en-US/` title/short/full description, 2–8 screenshots, 512x512 icon, feature graphic 1024x500. + +## 4. Apple App Store / Mac App Store + +- Bundle: `dx serve --platform ios` / `dx bundle --platform ios` and `desktop` for Mac. `Dioxus.toml` identifier `dev.hyperpolymath.presswerk` is Apple-valid (reverse-DNS). +- Team: set `APPLE_TEAM_ID` and `APPLE_ID` env, `Dioxus.toml` `[bundle] apple.team` when adding `cargo-bundle` / `tauri`. +- Entitlements: `com.apple.security.network.client` + `com.apple.security.network.server` (IPP), `com.apple.security.files.user-selected.read-only` (file picker), `com.apple.security.device.usb` if direct USB. No sandbox exception for mDNS — add `com.apple.security.network.multicast` (private entitlement, request via Apple). +- Privacy: `PrivacyInfo.xcprivacy` at `crates/presswerk-app/PrivacyInfo.xcprivacy` (added in this branch as minimal manifest). Declare `NSPrivacyAccessedAPICategoryFileTimestamp`, `NSPrivacyCollectedDataTypes` = empty if local-only. Add `NSBonjourServices` (`_ipp._tcp`, `_ipps._tcp`) and `NSLocalNetworkUsageDescription` (“Discover printers on your local network”) to `Info.plist`. +- Screenshots: iPhone 6.5" + iPad 12.9" sets, Mac 1280x800. +- TestFlight first, then App Review — note “local network printing, no data leaves device” in review notes. + +## 5. Privacy & compliance (all stores) + +- No analytics, no ads, no account. All processing local. Document in privacy policy URL (e.g. `https://hyperpolymath.dev/presswerk/privacy`). +- FFP marker privacy: writer SHOULD NOT set `toolVersion`/`filledAt` unless user consents; detector never exfiltrates. +- Presswerk does not collect `ffp:tool` beyond audit trail on-device. + +## 6. Remaining work before 1.0 submission (outside this PR) + +- [ ] Generate branded `assets/icon.png` (1024x1024) and `assets/icon.ico` + `icon.icns`. +- [ ] Fill `Dioxus.toml` `package.productName`, `bundle.category`, `shortDescription`, `longDescription`. +- [ ] Create `fastlane/` and `store/` screenshots + metadata (all three stores). +- [ ] Replace `PrivacyInfo.xcprivacy` placeholder with audited manifest after `cargo bundle` generates entitlements. +- [ ] Add `Package.appxmanifest` / `android/app/src/main/AndroidManifest.xml` overrides if Dioxus defaults insufficient. +- [ ] Run `just ffp-conformance` in CI (added) and attach to release notes. +- [ ] Bump `Cargo.toml` `[workspace.package] version` from `0.3.0` to `1.0.0-rc1`, update `CHANGELOG.adoc` D189 entry, tag. +- [ ] Notarization (Apple) + `cargo wix` MSI (Microsoft) in CI. +- [ ] Law: update `LICENSE` / `PRIVACY.md` URL reachable before submission. + +## 7. Verification + +``` +# FFP conformance (needs standards checkout) +git clone https://github.com/hyperpolymath/standards ../standards +just ffp-conformance # or: cargo build --bin ffp-classify && FFP_DETECTOR=./target/debug/ffp-classify bash ../standards/1-formats/sub-specs/form-fill-provenance/spec/conformance/run-conformance.sh +# Tests +cargo test -p presswerk-core -p presswerk-document -p presswerk-print +# UI audit +cargo run -p presswerk-app # check jobs badge + audit trail +``` + +## References + +- `spec/1-formats/sub-specs/form-fill-provenance/spec/MARKER.adoc` (FFP/1) +- `spec/1-formats/sub-specs/form-fill-provenance/spec/DETECTION.adoc` (FFP/2) +- `spec/1-formats/sub-specs/form-fill-provenance/spec/PRINT-PATH.adoc` (FFP/3, P1-P7) +- `spec/conformance/README.adoc` + `probe.awk` + `run-conformance.sh` + `make-fixtures.sh` diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..2ffaa8f --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +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. +• 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 new file mode 100644 index 0000000..a4e5448 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Local, high-assurance print router — your files never leave the device. diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..8d622e7 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Presswerk — Print Doctor diff --git a/store/apple/README.md b/store/apple/README.md new file mode 100644 index 0000000..35c3f2f --- /dev/null +++ b/store/apple/README.md @@ -0,0 +1 @@ +apple metadata placeholder diff --git a/store/microsoft/README.md b/store/microsoft/README.md new file mode 100644 index 0000000..240512d --- /dev/null +++ b/store/microsoft/README.md @@ -0,0 +1 @@ +microsoft appxmanifest placeholder diff --git a/store/play/README.md b/store/play/README.md new file mode 100644 index 0000000..081f352 --- /dev/null +++ b/store/play/README.md @@ -0,0 +1 @@ +play metadata placeholder