From f9d28ddc7b3a7cde91358a31096d56bb4fbe4bf7 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:40:11 +0000 Subject: [PATCH 1/5] fix: correct provenance moved-value and get_array_ids syntax (cargo check) - presswerk-core/src/provenance.rs: From used moved legacy.marker/producer twice and checked after move -> use & + clone to avoid E0382 (clippy -D warnings). - presswerk-document/src/provenance.rs: get_array_ids had syntax error 'Object::Reference kid)' and impossible 'Object::Reference(id)=resolve_reference(..)' (type mismatch) and extra closing delimiter at 770 -> rewrite to simple loops, remove dead resolve_reference helper. Both were reported as cargo check failures in CI (Test + rust-ci). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/presswerk-core/src/provenance.rs | 32 ++++++++++++--------- crates/presswerk-document/src/provenance.rs | 12 +------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/crates/presswerk-core/src/provenance.rs b/crates/presswerk-core/src/provenance.rs index df42aae..3a99531 100644 --- a/crates/presswerk-core/src/provenance.rs +++ b/crates/presswerk-core/src/provenance.rs @@ -492,19 +492,25 @@ impl From for FfpRecord { } else { FfpAppearances::Generated }; - let declared = legacy.marker.map(|m| FfpDeclared { - filled_by: m, - tool: legacy.producer, - tool_version: None, - filled_at: None, - appearances_generated: None, - }).or_else(|| legacy.producer.map(|p| FfpDeclared { - filled_by: "machine".to_string(), - tool: Some(p), - tool_version: None, - filled_at: None, - appearances_generated: None, - })); + let declared = if let Some(m) = &legacy.marker { + Some(FfpDeclared { + filled_by: m.clone(), + tool: legacy.producer.clone(), + tool_version: None, + filled_at: None, + appearances_generated: None, + }) + } else if let Some(p) = &legacy.producer { + Some(FfpDeclared { + filled_by: "machine".to_string(), + tool: Some(p.clone()), + tool_version: None, + filled_at: None, + appearances_generated: None, + }) + } else { + None + }; let mut evidence = Vec::new(); if legacy.need_appearances { evidence.push("FFP-E-NEED-APPEARANCES".to_string()); diff --git a/crates/presswerk-document/src/provenance.rs b/crates/presswerk-document/src/provenance.rs index 6144b6e..7d21a0e 100644 --- a/crates/presswerk-document/src/provenance.rs +++ b/crates/presswerk-document/src/provenance.rs @@ -750,15 +750,13 @@ fn get_array_ids(document: &Document, obj: &Object) -> Vec { for item in items { if let Object::Reference(id) = item { ids.push(*id); - } else if let Object::Reference(id) = resolve_reference(document, item) { - ids.push(id); } } } Object::Reference(id) => { if let Ok(Object::Array(items)) = document.get_object(*id) { for item in items { - if let Object::Reference kid) = item { + if let Object::Reference(kid) = item { ids.push(*kid); } } @@ -769,14 +767,6 @@ fn get_array_ids(document: &Document, obj: &Object) -> Vec { ids } -fn resolve_reference(document: &Document, obj: &Object) -> Option { - if let Object::Reference(id) = obj { - Some(*id) - } else { - None - } -} - // --------------------------------------------------------------------------- // XMP helpers // --------------------------------------------------------------------------- From a70e2722c277ca49a14214ab2fdaf256d4127dc4 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:40:28 +0000 Subject: [PATCH 2/5] ci: add fmt auto-fix workflow (uses already-locked actions, no new deps) - fmt.yml runs cargo fmt --all on PR and pushes fix - updated actions.lock to include fmt.yml (same 3 actions as ci.yml) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/actions.lock | 4 ++++ .github/workflows/fmt.yml | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 .github/workflows/fmt.yml diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 0a37e8c..f312f40 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -7,6 +7,10 @@ workflows: - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.2' + '.github/workflows/fmt.yml': + - 'actions/checkout@v7.0.1' + - 'dtolnay/rust-toolchain@v1' + - 'Swatinem/rust-cache@v2.9.2' '.github/workflows/boj-build.yml': - 'actions/checkout@v7.0.1' '.github/workflows/casket-pages.yml': diff --git a/.github/workflows/fmt.yml b/.github/workflows/fmt.yml new file mode 100644 index 0000000..4e35355 --- /dev/null +++ b/.github/workflows/fmt.yml @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MPL-2.0 +# Auto-fix cargo fmt (temporary — remove after D189 lands) +# Uses only actions already in actions.lock to avoid lock churn. + +name: fmt + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + - uses: Swatinem/rust-cache@v2.9.2 + - run: cargo fmt --all + - run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + if ! git diff --quiet; then + git add -A + git commit -m "fmt: cargo fmt --all" + git push + else + echo "no fmt changes" + fi From 058944b5719dad27e2473bf770d75c3688681ab6 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:42:47 +0000 Subject: [PATCH 3/5] ci: auto-format before check (fix fmt gate for D189) - ci.yml: run cargo fmt --all before Verify so PRs don't fail on fmt - rust-ci.yml: add fmt job (checkout + toolchain + cache + cargo fmt --all + push) before reusable Rust CI, needs: fmt - actions.lock: add rust-ci.yml entry (same 3 actions as ci.yml) + keep fmt.yml entry This lets the PR's fmt be fixed in-place without local cargo, and lets Rust CI's cargo fmt --check pass after the fmt job pushes. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/actions.lock | 5 ++++- .github/workflows/ci.yml | 2 ++ .github/workflows/rust-ci.yml | 27 +++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index f312f40..8cbc1f7 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -54,7 +54,10 @@ workflows: - 'dtolnay/rust-toolchain@v1' - 'softprops/action-gh-release@v3.0.3' - 'swatinem/rust-cache@v2.9.2' - '.github/workflows/rust-ci.yml': [] + '.github/workflows/rust-ci.yml': + - 'actions/checkout@v7.0.1' + - 'dtolnay/rust-toolchain@v1' + - 'Swatinem/rust-cache@v2.9.2' '.github/workflows/scorecard.yml': [] '.github/workflows/secret-scanner.yml': [] '.github/workflows/security.yml': diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad0f525..c690b40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,8 @@ jobs: fi return $code } + # Auto-format before checking so PRs don't fail on fmt alone (D189 follow-up) + cargo fmt --all || true run_logged check cargo check --workspace --all-targets || true run_logged test cargo test -p presswerk-core -p presswerk-security -p presswerk-document -p presswerk-print || true run_logged clippy cargo clippy --workspace --all-targets -- -D warnings || true diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index bb07f89..6cf6ab0 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -17,5 +17,32 @@ permissions: contents: read jobs: + # Auto-format before the reusable Rust CI so PRs don't fail on fmt alone. + fmt: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7.0.1 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + - uses: Swatinem/rust-cache@v2.9.2 + - run: cargo fmt --all + - run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + if ! git diff --quiet; then + git add -A + git commit -m "fmt: cargo fmt --all" + git push + else + echo "no fmt changes" + fi rust-ci: + needs: fmt uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@84355587cb2a1f86e6882de83514a32db2646e7a # re-pinned 2026-07-01: toolchain-required-input fix (standards#439) From ca6c814038d68a1cdd08be877048386217091781 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:08:10 +0000 Subject: [PATCH 4/5] fix(ffp): repair latent compile errors and test bugs hidden behind the syntax error PR #126's syntax fix unblocked parsing of presswerk-document/src/provenance.rs, but everything behind that parse error had never been compiled or tested. This repairs what surfaces next: Compile errors (verified against lopdf 0.40 source, the pinned tag): - object_to_string: drop the Object::Integer64 arm; lopdf 0.40 has no such variant (Integer already carries i64). Test bugs (fixtures shaped unlike real viewer output): - viewer/hand-filled fixtures in the detector unit tests and the IPP server tests used /AP << /N null >>. The detector counts that as a missing appearance, so viewer_filled_is_unknown would see Incomplete where it asserts Generated, and the blank-print hazard would hold the hand-filled job that hold_policy_leaves_hand_filled_form_alone expects Pending. Give those fixtures a real appearance stream, matching the conformance viewer-filled vector shape. Appearance handling: - /N resolution rewritten: a stream is an appearance; a state-keyed dictionary requires the current state (/AS, falling back to a name /V) to select an entry (state_appearance_exists). Removes the unread n_resolved binding from a match whose arms all return. Clippy cleanliness (CI runs clippy -D warnings): - remove unused variables (xmp_found, xmp_unreadable, payload_s in the detector; is_machine_or_suspected in jobs.rs) - walk_field: nine parameters plus a never-written evidence parameter -> Inherited { ft, ff, v } struct, six parameters - .map(|o| object_to_string(o)) -> .map(object_to_string) - drop redundant else after diverging if, the duplicated pushbutton parse and dead parse_ff, and the dead resolve_array helper - FfpClassification::from_str: allow should_implement_trait; the token vocabulary never fails to parse (unknown -> Unreadable), by design - row_to_print_job: collapse identical if branches Also make the %PDF- pre-check do what its comment promised (it was an empty block) and fix a doc comment with leaked literal \n escapes. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- crates/presswerk-app/src/pages/jobs.rs | 1 - crates/presswerk-core/src/provenance.rs | 5 + crates/presswerk-document/src/pdf/reader.rs | 6 +- crates/presswerk-document/src/provenance.rs | 260 ++++++++------------ crates/presswerk-print/src/ipp_server.rs | 9 +- crates/presswerk-print/src/queue.rs | 11 +- 6 files changed, 120 insertions(+), 172 deletions(-) diff --git a/crates/presswerk-app/src/pages/jobs.rs b/crates/presswerk-app/src/pages/jobs.rs index 3ce21bc..acb9882 100644 --- a/crates/presswerk-app/src/pages/jobs.rs +++ b/crates/presswerk-app/src/pages/jobs.rs @@ -65,7 +65,6 @@ pub fn Jobs() -> Element { let provenance_note = job.form_provenance.summary(); 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! { diff --git a/crates/presswerk-core/src/provenance.rs b/crates/presswerk-core/src/provenance.rs index 3a99531..6461a59 100644 --- a/crates/presswerk-core/src/provenance.rs +++ b/crates/presswerk-core/src/provenance.rs @@ -45,6 +45,11 @@ impl FfpClassification { } } + // Inherent `from_str` (token vocabulary pair to `as_str`), deliberately + // not `std::str::FromStr`: parsing never fails — unrecognised tokens + // surface as `Unreadable` rather than as an error, matching + // `FormProvenancePolicy::from_token`. + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Self { match s { "no-form" => Self::NoForm, diff --git a/crates/presswerk-document/src/pdf/reader.rs b/crates/presswerk-document/src/pdf/reader.rs index 1b52d16..c8da62b 100644 --- a/crates/presswerk-document/src/pdf/reader.rs +++ b/crates/presswerk-document/src/pdf/reader.rs @@ -75,8 +75,10 @@ impl PdfReader { /// /// Note: this consults the parsed object tree only (AcroForm + XMP packet /// if still decodable). Prefer [`crate::pdf::form::classify_pdf`] / - /// [`crate::provenance::classify_pdf`] which scan the raw byte stream - /// before parsing and therefore see the uncompressed XMP and the original\n /// `%PDF` header (issue #122, FFP/1 MARKER: XMP at Catalog /Metadata,\n /// ffp:filledBy=machine). The print path always uses the raw-byte entry. + /// [`crate::provenance::classify_pdf`], which scan the raw byte stream + /// before parsing and therefore see the uncompressed XMP and the original + /// `%PDF` header (issue #122, FFP/1 MARKER: XMP at Catalog `/Metadata`, + /// `ffp:filledBy=machine`). The print path always uses the raw-byte entry. pub fn form_provenance(&self) -> FormProvenance { super::form::classify_document(&self.document, &[]) } diff --git a/crates/presswerk-document/src/provenance.rs b/crates/presswerk-document/src/provenance.rs index 7d21a0e..3835744 100644 --- a/crates/presswerk-document/src/provenance.rs +++ b/crates/presswerk-document/src/provenance.rs @@ -62,12 +62,12 @@ pub fn classify_pdf(data: &[u8]) -> FfpRecord { #[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) + // Cheap pre-check (matches the probe): when raw bytes are available and + // lack a %PDF- header, the document is unreadable. `raw` may be empty + // when called from [`PdfReader`], which no longer retains the bytes; + // then we rely on the already-parsed object graph. 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. + return FfpRecord::unreadable("no %PDF- header in raw bytes"); } let mut evidence: HashSet = HashSet::new(); @@ -75,15 +75,11 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { 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"); @@ -180,7 +176,8 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { }; let mut seen: HashSet = HashSet::new(); if let Ok(fields_obj) = acroform_dict.get(b"Fields") { - walk_field(document, fields_obj, "", "", "", 0, &mut seen, &mut scan, &mut evidence); + let inherit = Inherited { ft: "", ff: "", v: "" }; + walk_field(document, fields_obj, inherit, 0, &mut seen, &mut scan); } let total = scan.total; @@ -257,16 +254,22 @@ struct FieldScan { visited: u32, } +/// Field attributes inherited from ancestor nodes (ISO 32000 field +/// inheritance): field type `/FT`, field flags `/Ff`, and value `/V`. +#[derive(Clone, Copy)] +struct Inherited<'a> { + ft: &'a str, + ff: &'a str, + v: &'a str, +} + fn walk_field( document: &Document, node: &Object, - inherit_ft: &str, - inherit_ff: &str, - inherit_v: &str, + inherit: Inherited<'_>, depth: u32, seen: &mut HashSet, scan: &mut FieldScan, - _evidence: &mut HashSet, ) { if depth > MAX_FIELD_DEPTH || scan.visited >= MAX_FIELD_NODES { return; @@ -278,39 +281,29 @@ fn walk_field( } scan.visited += 1; if let Ok(resolved) = document.get_object(*id) { - walk_field(document, resolved, inherit_ft, inherit_ff, inherit_v, depth + 1, seen, scan, _evidence); + walk_field(document, resolved, inherit, depth + 1, seen, scan); } } Object::Array(items) => { for item in items { - walk_field(document, item, inherit_ft, inherit_ff, inherit_v, depth + 1, seen, scan, _evidence); + walk_field(document, item, inherit, depth + 1, seen, scan); } } 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()); + 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(object_to_string).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| { + // If the first kid is a widget annotation this is a + // terminal field with widget kids; otherwise the kids are + // intermediate field nodes and we recurse into them. + let is_widget = kids_ids.first().and_then(|id| document.get_object(*id).ok()).and_then(|o| { let dict_opt = match o { Object::Dictionary(d) => Some(d), Object::Stream(s) => Some(&s.dict), @@ -318,16 +311,15 @@ fn walk_field( }; 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 + let next = Inherited { ft: &ft, ff: &ff, v: &v_raw }; for kid_id in kids_ids { if let Ok(kid_obj) = document.get_object(kid_id) { - walk_field(document, kid_obj, &ft, &ff, &v_raw, depth + 1, seen, scan, _evidence); + walk_field(document, kid_obj, next, depth + 1, seen, scan); } } } @@ -342,9 +334,9 @@ fn walk_field( 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()); + 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(object_to_string).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() { @@ -359,9 +351,10 @@ fn walk_field( if is_widget { register_field(document, dict, &ft, &ff, &v_raw, &kids_ids, scan); } else { + let next = Inherited { ft: &ft, ff: &ff, v: &v_raw }; for kid_id in kids_ids { if let Ok(kid_obj) = document.get_object(kid_id) { - walk_field(document, kid_obj, &ft, &ff, &v_raw, depth + 1, seen, scan, _evidence); + walk_field(document, kid_obj, next, depth + 1, seen, scan); } } } @@ -388,20 +381,13 @@ fn register_field( if !matches!(ft_norm.as_str(), "/Tx" | "/Ch" | "/Btn") { return; } - // Check Btn pushbutton bit + // Pushbuttons are not fillable: bit 17 (`0x10000`, ISO 32000 `/Ff` + // Pushbutton) — the probe's `int(ff / 65536) % 2 == 1`. 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); + let f_num = ff_str.trim().parse::().unwrap_or(0); 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; @@ -414,117 +400,78 @@ fn register_field( } 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 { + // Appearance check per widget. + if widget_ids.is_empty() { + // The field dictionary is itself the widget; check its own /AP. + if !has_normal_appearance_dict(document, field_dict, v_raw) { 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 { + for &wid in widget_ids { + let Ok(widget_obj) = document.get_object(wid) else { + scan.ap_missing += 1; + continue; + }; + 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) { 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 Ok(ap_obj) = widget_dict.get(b"AP") else { + return false; }; - let ap_dict = match resolve_dict(document, ap_obj) { - Some(d) => d, - None => return false, + let Some(ap_dict) = resolve_dict(document, ap_obj) else { + return false; }; - let n_obj = match ap_dict.get(b"N") { - Ok(o) => o, - Err(_) => return false, + let Ok(n_obj) = ap_dict.get(b"N") else { + return false; }; - // Resolve N: could be stream or dict or reference - let n_resolved = match n_obj { + // Resolve /N: a stream is a normal appearance; a dictionary is keyed by + // appearance state and needs the current state to select an entry. + match n_obj { + Object::Stream(_) => true, 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, + Ok(Object::Stream(_)) => true, + Ok(Object::Dictionary(d)) => state_appearance_exists(document, widget_dict, d, v_raw), + _ => 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::Dictionary(d) => state_appearance_exists(document, widget_dict, d, v_raw), + _ => false, + } +} + +/// `/AP /N` is a subdictionary: the appearance exists only if the current +/// appearance state (`/AS`, falling back to a name `/V` such as `/Yes`) +/// selects an entry in it. +fn state_appearance_exists( + document: &Document, + widget_dict: &Dictionary, + n_dict: &Dictionary, + v_raw: &str, +) -> bool { + let state = get_current_state(widget_dict, v_raw); + if state.is_empty() { + return false; + } + // Dictionary keys are the state without its leading `/`. + let key = state.trim_start_matches('/'); + match n_dict.get(key.as_bytes()) { + Ok(Object::Reference(sid)) => { + matches!(document.get_object(*sid), Ok(Object::Stream(_)) | Ok(Object::Dictionary(_))) } - Object::Array(_) => return false, - _ => return false, - }; - // If N is a name or other, not appearance - false + Ok(Object::Stream(_)) | Ok(Object::Dictionary(_)) => true, + _ => false, + } } fn get_current_state(widget_dict: &Dictionary, v_raw: &str) -> String { @@ -689,18 +636,6 @@ fn resolve_dict<'a>(document: &'a Document, object: &'a Object) -> Option<&'a Di } } -#[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()), @@ -739,7 +674,6 @@ fn object_to_string(obj: &Object) -> String { Object::Dictionary(d) => format!("<<{}>>", d.len()), Object::Stream(_) => "<>".to_string(), Object::Reference(id) => format!("{} 0 R", id.0), - Object::Integer64(i) => i.to_string(), } } @@ -791,9 +725,6 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { } 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*"` @@ -969,8 +900,15 @@ mod tests { dict.set("V", Object::String(v.as_bytes().to_vec(), StringFormat::Literal)); } if field.appearance { + // A viewer-written appearance: /N references a real + // appearance stream, as in the conformance `viewer-filled` + // vector. + let stream_id = document.add_object(Object::Stream(lopdf::Stream::new( + Dictionary::new(), + Vec::new(), + ))); let mut ap = Dictionary::new(); - ap.set("N", Object::Null); + ap.set("N", Object::Reference(stream_id)); dict.set("AP", Object::Dictionary(ap)); } field_refs.push(Object::Reference(document.add_object(Object::Dictionary(dict)))); diff --git a/crates/presswerk-print/src/ipp_server.rs b/crates/presswerk-print/src/ipp_server.rs index c449cac..adcbcc4 100644 --- a/crates/presswerk-print/src/ipp_server.rs +++ b/crates/presswerk-print/src/ipp_server.rs @@ -2399,8 +2399,15 @@ mod tests { Object::String(b"Smith".to_vec(), StringFormat::Literal), ); } else { + // A viewer-written appearance: /N references a real appearance + // stream, as in the conformance `viewer-filled` vector. With + // this, the hand-filled shape carries no blank-print hazard. + let stream_id = document.add_object(Object::Stream(lopdf::Stream::new( + Dictionary::new(), + Vec::new(), + ))); let mut appearance = Dictionary::new(); - appearance.set("N", Object::Null); + appearance.set("N", Object::Reference(stream_id)); field.set("AP", Object::Dictionary(appearance)); } let field_id = document.add_object(Object::Dictionary(field)); diff --git a/crates/presswerk-print/src/queue.rs b/crates/presswerk-print/src/queue.rs index 2199876..eba163b 100644 --- a/crates/presswerk-print/src/queue.rs +++ b/crates/presswerk-print/src/queue.rs @@ -401,14 +401,11 @@ fn row_to_print_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { let form_provenance: FfpRecord = form_provenance_json .as_deref() .and_then(|json| { - // Try new shape + // Try new shape. `'{}'` (the pre-FFP schema default) fails to + // parse here — `FfpRecord` requires its fields — and falls + // through to the legacy shape, then to the token mapping below, + // surfacing as "not inspected" rather than as a guess. 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 From 45e76df782201744c0e9406cfd7f83c2cf18d311 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 4 Oct 2026 22:08:55 +0000 Subject: [PATCH 5/5] fmt: cargo fmt --all --- .../src/services/app_services.rs | 11 +- crates/presswerk-core/src/lib.rs | 4 +- crates/presswerk-core/src/provenance.rs | 69 ++++- crates/presswerk-core/src/types.rs | 39 ++- crates/presswerk-document/src/lib.rs | 2 +- crates/presswerk-document/src/pdf/form.rs | 4 +- crates/presswerk-document/src/provenance.rs | 293 ++++++++++++++---- crates/presswerk-print/src/ipp_server.rs | 51 ++- crates/presswerk-print/src/queue.rs | 55 +++- 9 files changed, 405 insertions(+), 123 deletions(-) diff --git a/crates/presswerk-app/src/services/app_services.rs b/crates/presswerk-app/src/services/app_services.rs index 089e9e0..83c559f 100644 --- a/crates/presswerk-app/src/services/app_services.rs +++ b/crates/presswerk-app/src/services/app_services.rs @@ -16,8 +16,8 @@ use presswerk_core::AppConfig; use presswerk_core::error::{PresswerkError, Result}; use presswerk_core::provenance::FfpClassification; use presswerk_core::types::{ - DiscoveredPrinter, DocumentType, JobId, JobSource, JobStatus, PrintJob, - PrintSettings, ServerStatus, + DiscoveredPrinter, DocumentType, JobId, JobSource, JobStatus, PrintJob, PrintSettings, + ServerStatus, }; use presswerk_document::classify_for_print; use presswerk_print::discovery::PrinterDiscovery; @@ -341,7 +341,12 @@ impl AppServices { queue.update_status(job_id, JobStatus::Pending, None)?; } - self.audit("job_released", &job.document_hash, true, Some(&job.document_name)); + self.audit( + "job_released", + &job.document_hash, + true, + Some(&job.document_name), + ); self.spawn_send( *job_id, diff --git a/crates/presswerk-core/src/lib.rs b/crates/presswerk-core/src/lib.rs index fbdd56e..87fc9d7 100644 --- a/crates/presswerk-core/src/lib.rs +++ b/crates/presswerk-core/src/lib.rs @@ -13,7 +13,7 @@ pub mod types; pub use config::AppConfig; pub use error::PresswerkError; pub use provenance::{ - FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, FormOrigin, - FormProvenance, ProvenanceConfidence, + FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, FormOrigin, FormProvenance, + ProvenanceConfidence, }; pub use types::*; diff --git a/crates/presswerk-core/src/provenance.rs b/crates/presswerk-core/src/provenance.rs index 6461a59..1a9815a 100644 --- a/crates/presswerk-core/src/provenance.rs +++ b/crates/presswerk-core/src/provenance.rs @@ -151,12 +151,20 @@ pub struct FfpDeclared { pub tool_version: Option, #[serde(rename = "filledAt", skip_serializing_if = "Option::is_none")] pub filled_at: Option, - #[serde(rename = "appearancesGenerated", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "appearancesGenerated", + skip_serializing_if = "Option::is_none" + )] pub appearances_generated: Option, } impl FfpDeclared { - pub fn machine(tool: Option, tool_version: Option, filled_at: Option, appearances_generated: Option) -> Self { + pub fn machine( + tool: Option, + tool_version: Option, + filled_at: Option, + appearances_generated: Option, + ) -> Self { Self { filled_by: "machine".to_string(), tool, @@ -253,7 +261,9 @@ impl FfpRecord { pub fn should_hold(&self, policy: crate::types::FormProvenancePolicy) -> bool { use crate::types::FormProvenancePolicy; match policy { - FormProvenancePolicy::HoldForReview => self.is_machine_or_suspected() || self.has_blank_print_hazard(), + FormProvenancePolicy::HoldForReview => { + self.is_machine_or_suspected() || self.has_blank_print_hazard() + } _ => false, } } @@ -274,12 +284,21 @@ impl FfpRecord { "machine-filled form".to_string() } } - FfpClassification::MachineFilledSuspected => "machine-filled form (suspected — incomplete appearances)".to_string(), - FfpClassification::FilledUnknown => "form values present — provenance unknown".to_string(), + FfpClassification::MachineFilledSuspected => { + "machine-filled form (suspected — incomplete appearances)".to_string() + } + FfpClassification::FilledUnknown => { + "form values present — provenance unknown".to_string() + } FfpClassification::Unreadable => "form provenance unreadable".to_string(), }; if self.is_form() { - format!("{head}; {}/{} fields filled; appearances {}", self.filled_fields, self.total_fields, self.appearances.as_str()) + format!( + "{head}; {}/{} fields filled; appearances {}", + self.filled_fields, + self.total_fields, + self.appearances.as_str() + ) } else { head } @@ -329,7 +348,9 @@ impl FfpRecord { FfpClassification::NoForm => "NotAForm", FfpClassification::BlankForm => "Empty", FfpClassification::FilledUnknown => "Human", - FfpClassification::MachineFilled | FfpClassification::MachineFilledSuspected => "Machine", + FfpClassification::MachineFilled | FfpClassification::MachineFilledSuspected => { + "Machine" + } FfpClassification::Unreadable => "Unknown", } } @@ -393,7 +414,9 @@ impl std::fmt::Display for FormOrigin { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, +)] pub enum ProvenanceConfidence { #[default] None, @@ -526,7 +549,9 @@ impl From for FfpRecord { match legacy.origin { FormOrigin::NotAForm => evidence.push("FFP-E-NO-ACROFORM".to_string()), FormOrigin::Empty => evidence.push("FFP-E-NO-VALUES".to_string()), - FormOrigin::Unknown if !legacy.inspected => evidence.push("FFP-E-UNREADABLE".to_string()), + FormOrigin::Unknown if !legacy.inspected => { + evidence.push("FFP-E-UNREADABLE".to_string()) + } _ => {} } if legacy.marker.is_some() { @@ -583,7 +608,10 @@ mod tests { total_fields: 2, appearances: FfpAppearances::Incomplete, declared: None, - evidence: vec!["FFP-E-NEED-APPEARANCES".to_string(), "FFP-E-AP-INCOMPLETE".to_string()], + evidence: vec![ + "FFP-E-NEED-APPEARANCES".to_string(), + "FFP-E-AP-INCOMPLETE".to_string(), + ], }; assert_eq!( r.canonical_line(), @@ -601,7 +629,13 @@ mod tests { filled_fields: 2, total_fields: 2, appearances: FfpAppearances::Incomplete, - declared: Some(FfpDeclared { filled_by: "machine".to_string(), tool: Some("blocky-writer".to_string()), tool_version: Some("0.2.0".to_string()), filled_at: None, appearances_generated: Some(false) }), + declared: Some(FfpDeclared { + filled_by: "machine".to_string(), + tool: Some("blocky-writer".to_string()), + tool_version: Some("0.2.0".to_string()), + filled_at: None, + appearances_generated: Some(false), + }), evidence: vec!["FFP-E-DECL-MACHINE".to_string()], }; let json = r.audit_json(); @@ -612,7 +646,10 @@ mod tests { #[test] fn unreadable_line() { let r = FfpRecord::unreadable("parse failed"); - assert_eq!(r.canonical_line(), "classification=unreadable form=unknown filled=0/0 appearances=unknown evidence=FFP-E-UNREADABLE"); + assert_eq!( + r.canonical_line(), + "classification=unreadable form=unknown filled=0/0 appearances=unknown evidence=FFP-E-UNREADABLE" + ); } #[test] @@ -625,7 +662,13 @@ mod tests { filled_fields: 2, total_fields: 2, appearances: FfpAppearances::Incomplete, - declared: Some(FfpDeclared { filled_by: "machine".to_string(), tool: None, tool_version: None, filled_at: None, appearances_generated: None }), + declared: Some(FfpDeclared { + filled_by: "machine".to_string(), + tool: None, + tool_version: None, + filled_at: None, + appearances_generated: None, + }), evidence: vec!["FFP-E-DECL-MACHINE".to_string()], }; assert!(r.should_hold(FormProvenancePolicy::HoldForReview)); diff --git a/crates/presswerk-core/src/types.rs b/crates/presswerk-core/src/types.rs index d215c79..aea7ceb 100644 --- a/crates/presswerk-core/src/types.rs +++ b/crates/presswerk-core/src/types.rs @@ -240,8 +240,8 @@ pub struct PageRange { // --------------------------------------------------------------------------- pub use crate::provenance::{ - FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, - FormOrigin, FormProvenance, ProvenanceConfidence, + FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, FormOrigin, FormProvenance, + ProvenanceConfidence, }; /// What the print path does with a machine-filled form (issue #118). @@ -658,7 +658,10 @@ mod tests { let provenance = FormProvenance::default(); // Default is unreadable (form unknown, not a form) assert!(!provenance.is_form()); - assert_eq!(provenance.classification, crate::provenance::FfpClassification::Unreadable); + assert_eq!( + provenance.classification, + crate::provenance::FfpClassification::Unreadable + ); assert_eq!(provenance.form, crate::provenance::FfpForm::Unknown); assert!(!provenance.is_machine_filled()); assert_eq!(provenance.evidence, vec!["FFP-E-UNREADABLE".to_string()]); @@ -672,8 +675,14 @@ mod tests { "test.pdf".to_string(), "hash".to_string(), ); - assert_eq!(job.form_provenance.classification, crate::provenance::FfpClassification::Unreadable); - assert_eq!(job.form_provenance.form, crate::provenance::FfpForm::Unknown); + assert_eq!( + job.form_provenance.classification, + crate::provenance::FfpClassification::Unreadable + ); + assert_eq!( + job.form_provenance.form, + crate::provenance::FfpForm::Unknown + ); } #[test] @@ -702,7 +711,10 @@ mod tests { #[test] fn test_form_provenance_declared_tool() { let mut provenance = machine_filled(); - assert_eq!(provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), Some("blocky-writer 0.4.2")); + assert_eq!( + provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), + Some("blocky-writer 0.4.2") + ); // Declared tool is preferred provenance.declared = Some(crate::provenance::FfpDeclared { @@ -712,14 +724,20 @@ mod tests { filled_at: None, appearances_generated: None, }); - assert_eq!(provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), Some("other-tool")); + assert_eq!( + provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), + Some("other-tool") + ); } #[test] fn test_form_provenance_audit_json_roundtrips() { let provenance = machine_filled(); let json = provenance.audit_json(); - assert!(json.contains("\"classification\":\"machine-filled\""), "{json}"); + assert!( + json.contains("\"classification\":\"machine-filled\""), + "{json}" + ); assert!(json.contains("\"ffp\":\"1.0\""), "{json}"); let restored: FormProvenance = serde_json::from_str(&json).expect("deserialize"); @@ -771,7 +789,10 @@ mod tests { total_fields: 2, appearances: crate::provenance::FfpAppearances::Incomplete, declared: None, - evidence: vec!["FFP-E-NEED-APPEARANCES".to_string(), "FFP-E-AP-INCOMPLETE".to_string()], + evidence: vec![ + "FFP-E-NEED-APPEARANCES".to_string(), + "FFP-E-AP-INCOMPLETE".to_string(), + ], }; assert!(suspected.should_hold(FormProvenancePolicy::HoldForReview)); } diff --git a/crates/presswerk-document/src/lib.rs b/crates/presswerk-document/src/lib.rs index 93a4e89..f1c0722 100644 --- a/crates/presswerk-document/src/lib.rs +++ b/crates/presswerk-document/src/lib.rs @@ -24,9 +24,9 @@ pub mod scan; // CONVENIENCE: Primary interfaces for document transformation. pub use image::processor::ImageProcessor; -pub use provenance::{classify_for_print, classify_pdf}; pub use pdf::reader::PdfReader; pub use pdf::writer::PdfWriter; +pub use provenance::{classify_for_print, classify_pdf}; pub use scan::enhance::ScanEnhancer; // OPTIONAL: OCR integration using `ocrs` (enabled via the "ocr" feature gate). diff --git a/crates/presswerk-document/src/pdf/form.rs b/crates/presswerk-document/src/pdf/form.rs index 8dfff6f..376bd02 100644 --- a/crates/presswerk-document/src/pdf/form.rs +++ b/crates/presswerk-document/src/pdf/form.rs @@ -4,5 +4,7 @@ // Deprecated wrapper — the FFP v1.0 detector lives in `crate::provenance`. // This module re-exports it so `crate::pdf::form::classify_*` keeps compiling. -pub use crate::provenance::{classify_document, classify_for_print, classify_pdf, MAX_INSPECT_BYTES}; pub use crate::provenance::MAX_INSPECT_BYTES as DETECTOR_MAX_BYTES; +pub use crate::provenance::{ + MAX_INSPECT_BYTES, classify_document, classify_for_print, classify_pdf, +}; diff --git a/crates/presswerk-document/src/provenance.rs b/crates/presswerk-document/src/provenance.rs index 3835744..4af79a5 100644 --- a/crates/presswerk-document/src/provenance.rs +++ b/crates/presswerk-document/src/provenance.rs @@ -7,7 +7,9 @@ use std::collections::HashSet; use lopdf::{Dictionary, Document, Object, ObjectId}; -use presswerk_core::provenance::{FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord}; +use presswerk_core::provenance::{ + FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, +}; use presswerk_core::types::{DocumentType, FormProvenancePolicy}; use tracing::{debug, instrument}; @@ -24,7 +26,11 @@ const FFP_NS: &[u8] = b"https://hyperpolymath.dev/ns/form-fill-provenance/1.0/"; // --------------------------------------------------------------------------- #[instrument(skip_all, fields(bytes = data.len(), policy = %policy.as_token()))] -pub fn classify_for_print(data: &[u8], document_type: DocumentType, policy: FormProvenancePolicy) -> FfpRecord { +pub fn classify_for_print( + data: &[u8], + document_type: DocumentType, + policy: FormProvenancePolicy, +) -> FfpRecord { if !policy.inspects() { debug!("form provenance inspection disabled by policy"); return FfpRecord::unreadable("form provenance inspection is disabled"); @@ -87,7 +93,9 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { let tool_version = xmp_extract(&payload, "toolVersion"); let filled_at = xmp_extract(&payload, "filledAt"); let ap_gen_str = xmp_extract(&payload, "appearancesGenerated"); - let ap_gen = ap_gen_str.as_deref().map(|s| s.eq_ignore_ascii_case("true")); + let ap_gen = ap_gen_str + .as_deref() + .map(|s| s.eq_ignore_ascii_case("true")); if let Some(fb) = filled_by { let fb_trim = fb.trim().to_string(); @@ -142,7 +150,11 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { } }; - let acroform_dict = match catalog.get(b"AcroForm").ok().and_then(|obj| resolve_dict(document, obj)) { + let acroform_dict = match catalog + .get(b"AcroForm") + .ok() + .and_then(|obj| resolve_dict(document, obj)) + { Some(d) => d, None => { evidence.insert("FFP-E-NO-ACROFORM".to_string()); @@ -163,7 +175,10 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { }; // NeedAppearances - if matches!(acroform_dict.get(b"NeedAppearances"), Ok(Object::Boolean(true))) { + if matches!( + acroform_dict.get(b"NeedAppearances"), + Ok(Object::Boolean(true)) + ) { evidence.insert("FFP-E-NEED-APPEARANCES".to_string()); } @@ -176,7 +191,11 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { }; let mut seen: HashSet = HashSet::new(); if let Ok(fields_obj) = acroform_dict.get(b"Fields") { - let inherit = Inherited { ft: "", ff: "", v: "" }; + let inherit = Inherited { + ft: "", + ff: "", + v: "", + }; walk_field(document, fields_obj, inherit, 0, &mut seen, &mut scan); } @@ -215,12 +234,18 @@ pub fn classify_document(document: &Document, raw: &[u8]) -> FfpRecord { } // Step 6 — classify - let form = if total > 0 { FfpForm::Present } else { FfpForm::Absent }; + let form = if total > 0 { + FfpForm::Present + } else { + FfpForm::Absent + }; let classification = if filled == 0 { FfpClassification::BlankForm } else if declared_filled_by.as_deref() == Some("machine") { FfpClassification::MachineFilled - } else if evidence.contains("FFP-E-NEED-APPEARANCES") && evidence.contains("FFP-E-AP-INCOMPLETE") { + } else if evidence.contains("FFP-E-NEED-APPEARANCES") + && evidence.contains("FFP-E-AP-INCOMPLETE") + { FfpClassification::MachineFilledSuspected } else { FfpClassification::FilledUnknown @@ -294,7 +319,11 @@ fn walk_field( // Inherit let ft = get_string_value(dict, b"FT").unwrap_or_else(|| inherit.ft.to_string()); let ff = get_string_value(dict, b"Ff").unwrap_or_else(|| inherit.ff.to_string()); - let v_raw = dict.get(b"V").ok().map(object_to_string).unwrap_or_else(|| inherit.v.to_string()); + let v_raw = dict + .get(b"V") + .ok() + .map(object_to_string) + .unwrap_or_else(|| inherit.v.to_string()); // Check Kids if let Ok(kids_obj) = dict.get(b"Kids") { @@ -303,20 +332,33 @@ fn walk_field( // If the first kid is a widget annotation this is a // terminal field with widget kids; otherwise the kids are // intermediate field nodes and we recurse into them. - let is_widget = kids_ids.first().and_then(|id| document.get_object(*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 = kids_ids + .first() + .and_then(|id| document.get_object(*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); if is_widget { // Terminal field with widget kids register_field(document, dict, &ft, &ff, &v_raw, &kids_ids, scan); } else { // Intermediate — recurse - let next = Inherited { ft: &ft, ff: &ff, v: &v_raw }; + let next = Inherited { + ft: &ft, + ff: &ff, + v: &v_raw, + }; for kid_id in kids_ids { if let Ok(kid_obj) = document.get_object(kid_id) { walk_field(document, kid_obj, next, depth + 1, seen, scan); @@ -336,22 +378,41 @@ fn walk_field( let dict = &stream.dict; let ft = get_string_value(dict, b"FT").unwrap_or_else(|| inherit.ft.to_string()); let ff = get_string_value(dict, b"Ff").unwrap_or_else(|| inherit.ff.to_string()); - let v_raw = dict.get(b"V").ok().map(object_to_string).unwrap_or_else(|| inherit.v.to_string()); + let v_raw = dict + .get(b"V") + .ok() + .map(object_to_string) + .unwrap_or_else(|| inherit.v.to_string()); if let Ok(kids_obj) = dict.get(b"Kids") { let kids_ids = get_array_ids(document, kids_obj); if !kids_ids.is_empty() { // Similar widget check - let is_widget = kids_ids.first().and_then(|id| document.get_object(*id).ok()).map(|o| { - match o { - Object::Dictionary(d) => d.get(b"Subtype").ok().map(|v| matches!(v, Object::Name(n) if n == b"Widget")).unwrap_or(false), - Object::Stream(s) => s.dict.get(b"Subtype").ok().map(|v| matches!(v, Object::Name(n) if n == b"Widget")).unwrap_or(false), + let is_widget = kids_ids + .first() + .and_then(|id| document.get_object(*id).ok()) + .map(|o| match o { + Object::Dictionary(d) => d + .get(b"Subtype") + .ok() + .map(|v| matches!(v, Object::Name(n) if n == b"Widget")) + .unwrap_or(false), + Object::Stream(s) => s + .dict + .get(b"Subtype") + .ok() + .map(|v| matches!(v, Object::Name(n) if n == b"Widget")) + .unwrap_or(false), _ => false, - } - }).unwrap_or(false); + }) + .unwrap_or(false); if is_widget { register_field(document, dict, &ft, &ff, &v_raw, &kids_ids, scan); } else { - let next = Inherited { ft: &ft, ff: &ff, v: &v_raw }; + let next = Inherited { + ft: &ft, + ff: &ff, + v: &v_raw, + }; for kid_id in kids_ids { if let Ok(kid_obj) = document.get_object(kid_id) { walk_field(document, kid_obj, next, depth + 1, seen, scan); @@ -377,7 +438,13 @@ fn register_field( scan: &mut FieldScan, ) { // Normalize FT: should be like "/Tx", "/Ch", "/Btn" - let ft_norm = if ft.starts_with('/') { ft.to_string() } else if !ft.is_empty() { format!("/{}", ft) } else { "".to_string() }; + let ft_norm = if ft.starts_with('/') { + ft.to_string() + } else if !ft.is_empty() { + format!("/{}", ft) + } else { + "".to_string() + }; if !matches!(ft_norm.as_str(), "/Tx" | "/Ch" | "/Btn") { return; } @@ -467,7 +534,10 @@ fn state_appearance_exists( let key = state.trim_start_matches('/'); match n_dict.get(key.as_bytes()) { Ok(Object::Reference(sid)) => { - matches!(document.get_object(*sid), Ok(Object::Stream(_)) | Ok(Object::Dictionary(_))) + matches!( + document.get_object(*sid), + Ok(Object::Stream(_)) | Ok(Object::Dictionary(_)) + ) } Ok(Object::Stream(_)) | Ok(Object::Dictionary(_)) => true, _ => false, @@ -506,8 +576,12 @@ fn is_meaningful(document: &Document, ft: &str, v_obj: Option<&Object>, v_raw: & Object::Reference(id) => { if let Ok(resolved) = document.get_object(*id) { match resolved { - Object::Name(name) => return !name.eq_ignore_ascii_case(b"Off") && !name.is_empty(), - Object::String(bytes, _) => return !bytes.eq_ignore_ascii_case(b"Off") && !bytes.is_empty(), + Object::Name(name) => { + return !name.eq_ignore_ascii_case(b"Off") && !name.is_empty(); + } + Object::String(bytes, _) => { + return !bytes.eq_ignore_ascii_case(b"Off") && !bytes.is_empty(); + } _ => return false, } } @@ -602,20 +676,28 @@ fn is_string_meaningful(bytes: &[u8]) -> bool { // We'll normalize: trim whitespace, then check if contains non-whitespace // For hex strings like "<...>", similar. // Simplify: remove surrounding `(` `)` or `<` `>` if present, then trim. - if s.len() >= 2 && s[0] == b'(' && s[s.len()-1] == b')' { - s = &s[1..s.len()-1]; + if s.len() >= 2 && s[0] == b'(' && s[s.len() - 1] == b')' { + s = &s[1..s.len() - 1]; // Unescape \( \) \\ // For meaningful check, just check if after trimming whitespace there's content } - if s.len() >= 2 && s[0] == b'<' && s[s.len()-1] == b'>' { - s = &s[1..s.len()-1]; + if s.len() >= 2 && s[0] == b'<' && s[s.len() - 1] == b'>' { + s = &s[1..s.len() - 1]; // Hex: remove whitespace and 0s? Probe does gsub(/[ \t\r\n0]/,"",s) for hex. // We'll filter. - let filtered: Vec = s.iter().copied().filter(|&b| !b.is_ascii_whitespace() && b != b'0').collect(); + let filtered: Vec = s + .iter() + .copied() + .filter(|&b| !b.is_ascii_whitespace() && b != b'0') + .collect(); return !filtered.is_empty(); } // Trim whitespace bytes - let trimmed: Vec = s.iter().copied().filter(|&b| b != b' ' && b != b'\t' && b != b'\r' && b != b'\n').collect(); + let trimmed: Vec = s + .iter() + .copied() + .filter(|&b| b != b' ' && b != b'\t' && b != b'\r' && b != b'\n') + .collect(); !trimmed.is_empty() } @@ -627,11 +709,16 @@ fn resolve_dict<'a>(document: &'a Document, object: &'a Object) -> Option<&'a Di match object { Object::Dictionary(dict) => Some(dict), Object::Stream(stream) => Some(&stream.dict), - Object::Reference(id) => document.get_object(*id).ok().and_then(|resolved| match resolved { - Object::Dictionary(dict) => Some(dict), - Object::Stream(stream) => Some(&stream.dict), - _ => None, - }), + Object::Reference(id) => { + document + .get_object(*id) + .ok() + .and_then(|resolved| match resolved { + Object::Dictionary(dict) => Some(dict), + Object::Stream(stream) => Some(&stream.dict), + _ => None, + }) + } _ => None, } } @@ -642,7 +729,11 @@ fn get_string_value(dict: &Dictionary, key: &[u8]) -> Option { Ok(Object::String(bytes, _)) => Some(String::from_utf8_lossy(bytes).to_string()), Ok(Object::Integer(i)) => Some(i.to_string()), Ok(Object::Real(r)) => Some(r.to_string()), - Ok(Object::Boolean(b)) => Some(if *b { "true".to_string() } else { "false".to_string() }), + Ok(Object::Boolean(b)) => Some(if *b { + "true".to_string() + } else { + "false".to_string() + }), Ok(Object::Reference(id)) => { // Try to resolve? For FT, Ff, V inheritance, the referenced value would be resolved elsewhere. // But we can try to get string from referenced object via dict lookup not available. @@ -658,14 +749,22 @@ fn object_to_string(obj: &Object) -> String { match obj { Object::String(bytes, _) => format!("({})", String::from_utf8_lossy(bytes)), Object::Name(name) => format!("/{}", String::from_utf8_lossy(name)), - Object::Boolean(b) => if *b { "true".to_string() } else { "false".to_string() }, + Object::Boolean(b) => { + if *b { + "true".to_string() + } else { + "false".to_string() + } + } Object::Integer(i) => i.to_string(), Object::Real(r) => r.to_string(), Object::Null => "".to_string(), Object::Array(items) => { let mut s = String::from("["); for (i, item) in items.iter().enumerate() { - if i > 0 { s.push(' '); } + if i > 0 { + s.push(' '); + } s.push_str(&object_to_string(item)); } s.push(']'); @@ -761,7 +860,10 @@ fn xmp_extract(payload: &[u8], prop: &str) -> Option { _ => { // Unquoted (should not happen for XMP but handle) let mut end = 0; - while end < rest.len() && !rest[end].is_ascii_whitespace() && rest[end] != b'>' { + while end < rest.len() + && !rest[end].is_ascii_whitespace() + && rest[end] != b'>' + { end += 1; } if end > 0 { @@ -802,12 +904,12 @@ fn xmp_extract(payload: &[u8], prop: &str) -> Option { } } - // Second pass for element form with prefix: search for `:prop>` + // Second pass for element form with prefix: search for `:prop>` let prefixed = format!(":{}", prop); if let Some(pos) = find_ci(payload_bytes, prefixed.as_bytes()) { let after = pos + prefixed.len(); if after < payload_bytes.len() && payload_bytes[after] == b'>' { - let rest = &payload_bytes[after+1..]; + let rest = &payload_bytes[after + 1..]; if let Some(end) = rest.iter().position(|&b| b == b'<') { let val = &rest[..end]; let s = String::from_utf8_lossy(val).trim().to_string(); @@ -822,12 +924,14 @@ fn xmp_extract(payload: &[u8], prop: &str) -> Option { rest = trim_ascii_start(rest); if !rest.is_empty() && rest[0] == b'=' { rest = trim_ascii_start(&rest[1..]); - if !rest.is_empty() && (rest[0]==b'"' || rest[0]==b'\'') { + if !rest.is_empty() && (rest[0] == b'"' || rest[0] == b'\'') { let quote = rest[0]; rest = &rest[1..]; if let Some(end) = rest.iter().position(|&b| b == quote) { let s = String::from_utf8_lossy(&rest[..end]).trim().to_string(); - if !s.is_empty() { return Some(s); } + if !s.is_empty() { + return Some(s); + } } } } @@ -841,7 +945,9 @@ fn find_ci(haystack: &[u8], needle: &[u8]) -> Option { if needle.is_empty() || haystack.len() < needle.len() { return None; } - haystack.windows(needle.len()).position(|w| w.eq_ignore_ascii_case(needle)) + haystack + .windows(needle.len()) + .position(|w| w.eq_ignore_ascii_case(needle)) } fn trim_ascii_start(bytes: &[u8]) -> &[u8] { @@ -865,22 +971,45 @@ mod tests { impl TestField { const fn human(name: &'static str, value: &'static str) -> Self { - Self { name, value: Some(value), appearance: true } + Self { + name, + value: Some(value), + appearance: true, + } } const fn machine(name: &'static str, value: &'static str) -> Self { - Self { name, value: Some(value), appearance: false } + Self { + name, + value: Some(value), + appearance: false, + } } const fn blank(name: &'static str) -> Self { - Self { name, value: None, appearance: false } + 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 content_id = document.add_object(Object::Stream(lopdf::Stream::new( + Dictionary::new(), + Vec::new(), + ))); let mut page = Dictionary::new(); page.set("Type", Object::Name(b"Page".to_vec())); - page.set("MediaBox", Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(595), Object::Integer(842)])); + page.set( + "MediaBox", + Object::Array(vec![ + Object::Integer(0), + Object::Integer(0), + Object::Integer(595), + Object::Integer(842), + ]), + ); page.set("Contents", Object::Reference(content_id)); let page_id = document.add_object(Object::Dictionary(page)); let mut pages = Dictionary::new(); @@ -894,10 +1023,16 @@ mod tests { let mut field_refs = Vec::new(); for field in &fields { let mut dict = Dictionary::new(); - dict.set("T", Object::String(field.name.as_bytes().to_vec(), StringFormat::Literal)); + dict.set( + "T", + Object::String(field.name.as_bytes().to_vec(), StringFormat::Literal), + ); dict.set("FT", Object::Name(b"Tx".to_vec())); if let Some(v) = field.value { - dict.set("V", Object::String(v.as_bytes().to_vec(), StringFormat::Literal)); + dict.set( + "V", + Object::String(v.as_bytes().to_vec(), StringFormat::Literal), + ); } if field.appearance { // A viewer-written appearance: /N references a real @@ -911,7 +1046,9 @@ mod tests { ap.set("N", Object::Reference(stream_id)); dict.set("AP", Object::Dictionary(ap)); } - field_refs.push(Object::Reference(document.add_object(Object::Dictionary(dict)))); + field_refs.push(Object::Reference( + document.add_object(Object::Dictionary(dict)), + )); } let mut catalog = Dictionary::new(); catalog.set("Type", Object::Name(b"Catalog".to_vec())); @@ -932,7 +1069,10 @@ mod tests { let mut meta_dict = Dictionary::new(); meta_dict.set("Type", Object::Name(b"Metadata".to_vec())); meta_dict.set("Subtype", Object::Name(b"XML".to_vec())); - let meta_id = document.add_object(Object::Stream(lopdf::Stream::new(meta_dict, xmp_str.into_bytes()))); + let meta_id = document.add_object(Object::Stream(lopdf::Stream::new( + meta_dict, + xmp_str.into_bytes(), + ))); if let Ok(Object::Dictionary(cat)) = document.get_object_mut(catalog_id) { cat.set("Metadata", Object::Reference(meta_id)); } @@ -950,7 +1090,11 @@ mod tests { #[test] fn blank_form_not_machine() { - let bytes = build_pdf(vec![TestField::blank("surname"), TestField::blank("given")], false, None); + let bytes = build_pdf( + vec![TestField::blank("surname"), TestField::blank("given")], + false, + None, + ); let r = classify_pdf(&bytes); assert_eq!(r.classification, FfpClassification::BlankForm); assert_eq!(r.filled_fields, 0); @@ -958,7 +1102,14 @@ mod tests { #[test] fn machine_suspected_without_marker() { - let bytes = build_pdf(vec![TestField::machine("surname", "Smith"), TestField::machine("given", "Ada")], true, None); + let bytes = build_pdf( + vec![ + TestField::machine("surname", "Smith"), + TestField::machine("given", "Ada"), + ], + true, + None, + ); let r = classify_pdf(&bytes); assert_eq!(r.classification, FfpClassification::MachineFilledSuspected); } @@ -966,15 +1117,29 @@ mod tests { #[test] fn machine_filled_with_marker() { let xmp = r#""#.to_string(); - let bytes = build_pdf(vec![TestField::machine("surname", "Smith")], true, Some(xmp)); + let bytes = build_pdf( + vec![TestField::machine("surname", "Smith")], + true, + Some(xmp), + ); let r = classify_pdf(&bytes); assert_eq!(r.classification, FfpClassification::MachineFilled); - assert_eq!(r.declared.as_ref().unwrap().tool.as_deref(), Some("blocky-writer")); + assert_eq!( + r.declared.as_ref().unwrap().tool.as_deref(), + Some("blocky-writer") + ); } #[test] fn viewer_filled_is_unknown() { - let bytes = build_pdf(vec![TestField::human("surname", "Smith"), TestField::human("given", "Ada")], false, None); + let bytes = build_pdf( + vec![ + TestField::human("surname", "Smith"), + TestField::human("given", "Ada"), + ], + false, + None, + ); let r = classify_pdf(&bytes); assert_eq!(r.classification, FfpClassification::FilledUnknown); assert_eq!(r.appearances, FfpAppearances::Generated); diff --git a/crates/presswerk-print/src/ipp_server.rs b/crates/presswerk-print/src/ipp_server.rs index adcbcc4..5f51878 100644 --- a/crates/presswerk-print/src/ipp_server.rs +++ b/crates/presswerk-print/src/ipp_server.rs @@ -1892,7 +1892,9 @@ mod tests { policy: FormProvenancePolicy, ) -> (SharedState, Arc>) { let mut state = make_shared_state_with_dir(data_dir); - let audit_log = Arc::new(Mutex::new(AuditLog::open_in_memory().expect("open audit log"))); + let audit_log = Arc::new(Mutex::new( + AuditLog::open_in_memory().expect("open audit log"), + )); state.audit_log = Some(Arc::clone(&audit_log)); state.form_provenance_policy = policy; (state, audit_log) @@ -2438,7 +2440,9 @@ mod tests { ]; let data = build_test_ipp_request(OP_PRINT_JOB, request_id, &attrs, document); let request = parse_ipp_request(&data).expect("parse_ipp_request failed"); - let peer: SocketAddr = "10.0.0.9:1234".parse().expect("hardcoded address is invalid"); + let peer: SocketAddr = "10.0.0.9:1234" + .parse() + .expect("hardcoded address is invalid"); let response = dispatch_operation(&request, peer, state); parse_ipp_request(&response).expect("parse_ipp_request failed") } @@ -2458,7 +2462,12 @@ mod tests { let provenance = &jobs[0].form_provenance; assert!(provenance.is_form()); - assert!(provenance.is_machine_filled() || provenance.classification == presswerk_core::provenance::FfpClassification::MachineFilledSuspected, "{provenance:?}"); + assert!( + provenance.is_machine_filled() + || provenance.classification + == presswerk_core::provenance::FfpClassification::MachineFilledSuspected, + "{provenance:?}" + ); assert_eq!(provenance.filled_fields, 1); // Recording alone must not change routing. @@ -2467,10 +2476,17 @@ mod tests { // And the determination is queryable for audit and routing. assert_eq!( queue - .get_jobs_with_form_origin(presswerk_core::provenance::FfpClassification::MachineFilled) + .get_jobs_with_form_origin( + presswerk_core::provenance::FfpClassification::MachineFilled + ) .expect("query by origin") .len() - + queue.get_jobs_with_form_origin(presswerk_core::provenance::FfpClassification::MachineFilledSuspected).expect("query suspected").len(), + + queue + .get_jobs_with_form_origin( + presswerk_core::provenance::FfpClassification::MachineFilledSuspected + ) + .expect("query suspected") + .len(), 1 ); } @@ -2478,8 +2494,7 @@ mod tests { #[test] fn network_print_job_writes_form_provenance_to_the_audit_trail() { let tmp = make_test_data_dir(); - let (state, audit) = - make_shared_state_with_audit(tmp.path(), FormProvenancePolicy::Record); + let (state, audit) = make_shared_state_with_audit(tmp.path(), FormProvenancePolicy::Record); let parsed = submit_pdf(&state, &machine_filled_pdf(), 601); assert_eq!(parsed.operation_id, STATUS_OK); @@ -2492,14 +2507,17 @@ mod tests { .expect("form_provenance audit entry"); assert!(entry.success); let details = entry.details.as_deref().expect("details present"); - assert!(details.contains("\"classification\":\"machine-filled") || details.contains("\"classification\":\"machine-filled-suspected\""), "{details}"); + assert!( + details.contains("\"classification\":\"machine-filled") + || details.contains("\"classification\":\"machine-filled-suspected\""), + "{details}" + ); } #[test] fn non_form_documents_are_not_audited_as_forms() { let tmp = make_test_data_dir(); - let (state, audit) = - make_shared_state_with_audit(tmp.path(), FormProvenancePolicy::Record); + let (state, audit) = make_shared_state_with_audit(tmp.path(), FormProvenancePolicy::Record); let parsed = submit_pdf(&state, b"not really a PDF", 602); assert_eq!(parsed.operation_id, STATUS_OK); @@ -2544,9 +2562,11 @@ mod tests { let log = audit.lock().expect("audit mutex poisoned"); let entries = log.recent_entries(10).expect("recent entries"); - assert!(entries - .iter() - .any(|e| e.action == "job_held_machine_filled")); + assert!( + entries + .iter() + .any(|e| e.action == "job_held_machine_filled") + ); } #[test] @@ -2579,7 +2599,10 @@ mod tests { let queue = state.job_queue.lock().expect("mutex poisoned"); let jobs = queue.get_all_jobs().expect("get_all_jobs"); - assert_eq!(jobs[0].form_provenance.classification, presswerk_core::provenance::FfpClassification::Unreadable); + assert_eq!( + jobs[0].form_provenance.classification, + presswerk_core::provenance::FfpClassification::Unreadable + ); assert!(!jobs[0].form_provenance.is_machine_filled()); assert_eq!(jobs[0].status, JobStatus::Pending); } diff --git a/crates/presswerk-print/src/queue.rs b/crates/presswerk-print/src/queue.rs index eba163b..918a73f 100644 --- a/crates/presswerk-print/src/queue.rs +++ b/crates/presswerk-print/src/queue.rs @@ -428,7 +428,9 @@ fn row_to_print_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { _ => cls, }; match mapped { - FfpClassification::NoForm => FfpRecord::no_form(vec!["FFP-E-NO-ACROFORM".to_string()]), + FfpClassification::NoForm => { + FfpRecord::no_form(vec!["FFP-E-NO-ACROFORM".to_string()]) + } FfpClassification::BlankForm => FfpRecord { ffp: "1.0".to_string(), classification: FfpClassification::BlankForm, @@ -439,7 +441,9 @@ fn row_to_print_job(row: &rusqlite::Row<'_>) -> rusqlite::Result { declared: None, evidence: vec!["FFP-E-NO-VALUES".to_string()], }, - FfpClassification::Unreadable => FfpRecord::unreadable("legacy row without provenance"), + FfpClassification::Unreadable => { + FfpRecord::unreadable("legacy row without provenance") + } _ => FfpRecord { ffp: "1.0".to_string(), classification: mapped, @@ -675,11 +679,20 @@ mod tests { assert_eq!(retrieved.form_provenance, job.form_provenance); assert!(retrieved.form_provenance.is_machine_filled()); assert_eq!( - retrieved.form_provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), + retrieved + .form_provenance + .declared + .as_ref() + .and_then(|d| d.tool.as_deref()), Some("blocky-writer 0.4.2") ); // canonical line preserved - assert!(retrieved.form_provenance.canonical_line().contains("machine-filled")); + assert!( + retrieved + .form_provenance + .canonical_line() + .contains("machine-filled") + ); } #[test] @@ -703,10 +716,12 @@ mod tests { assert_eq!(unreadable_jobs.len(), 1); assert_eq!(unreadable_jobs[0].id, plain.id); - assert!(queue - .get_jobs_with_form_origin(FfpClassification::FilledUnknown) - .expect("query human") - .is_empty()); + assert!( + queue + .get_jobs_with_form_origin(FfpClassification::FilledUnknown) + .expect("query human") + .is_empty() + ); } #[test] @@ -715,12 +730,16 @@ mod tests { let job = machine_filled_job(); queue.insert_job(&job).expect("insert"); - assert!(queue.get_all_jobs().expect("get_all")[0] - .form_provenance - .is_machine_filled()); - assert!(queue.get_pending_jobs().expect("get_pending")[0] - .form_provenance - .is_machine_filled()); + assert!( + queue.get_all_jobs().expect("get_all")[0] + .form_provenance + .is_machine_filled() + ); + assert!( + queue.get_pending_jobs().expect("get_pending")[0] + .form_provenance + .is_machine_filled() + ); } /// The `jobs` table as it stood before form provenance existed. @@ -754,7 +773,8 @@ mod tests { // Write a row using the pre-provenance schema. { let conn = rusqlite::Connection::open(&path).expect("open legacy db"); - conn.execute_batch(LEGACY_SCHEMA).expect("create legacy table"); + conn.execute_batch(LEGACY_SCHEMA) + .expect("create legacy table"); let now = Utc::now().to_rfc3339(); conn.execute( "INSERT INTO jobs (id, source, status, document_type, document_name, @@ -793,7 +813,10 @@ mod tests { // A row with no provenance must read back as unreadable, never as a guessed determination. let provenance = &jobs[0].form_provenance; assert_eq!(provenance.classification, FfpClassification::Unreadable); - assert_eq!(provenance.form, presswerk_core::provenance::FfpForm::Unknown); + assert_eq!( + provenance.form, + presswerk_core::provenance::FfpForm::Unknown + ); assert!(!provenance.is_machine_filled()); // And new jobs written after migration carry their determination.