From e4a0c0a79edd3c45c5623f744f1d50b2109d98c4 Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 31 Jul 2026 19:54:58 +0000 Subject: [PATCH 1/2] feat(pdf): read signatures, and fix a third ObjRef-destroying resolve Phase 8 #6 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md ("Signatures"). Design and merge criteria in REVIEWS/adr/0010-pdf-signatures.md. Two defects, and the first is not about signatures at all. 1. acroform() dereferenced /AcroForm with self.resolve(), which recurses. That replaced every /Fields [5 0 R] entry with an inline dictionary, so AcroForm::walk saw node.as_ref() == None, decided the field had no identity to key an edit on, and dropped it. The form came back EMPTY rather than wrong, which is indistinguishable from a document with no fields, so nothing announced the loss. This is the third appearance of one mistake: page_annotations once destroyed every annotation's obj_ref the same way, and extract_xobjects resolved a reference then asked the resolved object for as_ref(), leaving every page's XObject map empty. It was invisible because both existing fixtures declare /AcroForm as an INDIRECT reference, where only one level is dereferenced and the refs survive. A direct /AcroForm dictionary - equally legal - hits it. The new fixture uses one deliberately; reverting the one-line fix fails 9 of the 12 new acceptance tests. 2. Nothing read signatures. FieldType::Signature was classified and then ignored; /ByteRange, /Contents, /SubFilter and /DocMDP appear nowhere in the codebase. A signed contract was presented exactly like an unsigned one. New pdf-document/src/signature.rs reads the signature dictionary and checks BYTE-RANGE INTEGRITY, which needs no cryptography and catches the common real-world tampering: whether the signed range reaches the end of the file. A signature that stops short leaves appended bytes uncovered, which is exactly how an incremental-update attack hides content behind a signature that still verifies. Cryptographic verification is NOT implemented and cannot be faked: VerificationStatus has no Valid variant. That is enforced by the type, not by convention, because the failure mode for a signature feature is not "it doesn't work" - it is a green tick beside a document nobody checked. A test asserts the capability's absence so adding Valid without the cryptography breaks the build rather than shipping a false tick. Signing is out of scope entirely: no private keys in this crate. Also verified ADR 0003's claim that an append-only save preserves a signed byte range, byte for byte, rather than leaving it asserted. Implementation bug worth recording: /Contents was initially hex-decoded, but the COS lexer already decodes <...>. Running it twice on the common 128-zero-byte placeholder - which contains no hex digits - produced an EMPTY vector, silently discarding the signature blob while every other field looked right. Caught by asserting the blob is non-empty rather than asserting the parse returned Ok. 6 corpus fixtures, 12 acceptance tests, 28 unit tests, and a parse_signature fuzz target because /ByteRange is four attacker-controlled integers used to index the file. TEST_TARGET=pdf 497 -> 536 passing. rustfmt and clippy -D warnings clean. --- REVIEWS/adr/0010-pdf-signatures.md | 189 ++++ crates/apps/pdf/pdf-cos/fuzz/Cargo.toml | 6 + .../fuzz/fuzz_targets/parse_signature.rs | 47 + crates/apps/pdf/pdf-document/src/document.rs | 59 +- crates/apps/pdf/pdf-document/src/lib.rs | 4 + crates/apps/pdf/pdf-document/src/signature.rs | 861 ++++++++++++++++++ .../apps/pdf/pdf-document/tests/signatures.rs | 313 +++++++ crates/apps/pdf/tests/corpus/generate.py | 132 +++ .../tests/corpus/signatures/certification.pdf | 38 + .../signatures/malformed_byte_range.pdf | 38 + .../corpus/signatures/partial_coverage.pdf | 43 + .../corpus/signatures/unknown_subfilter.pdf | 38 + .../corpus/signatures/unsigned_field.pdf | 34 + .../tests/corpus/signatures/whole_file.pdf | 38 + 14 files changed, 1839 insertions(+), 1 deletion(-) create mode 100644 REVIEWS/adr/0010-pdf-signatures.md create mode 100644 crates/apps/pdf/pdf-cos/fuzz/fuzz_targets/parse_signature.rs create mode 100644 crates/apps/pdf/pdf-document/src/signature.rs create mode 100644 crates/apps/pdf/pdf-document/tests/signatures.rs create mode 100644 crates/apps/pdf/tests/corpus/signatures/certification.pdf create mode 100644 crates/apps/pdf/tests/corpus/signatures/malformed_byte_range.pdf create mode 100644 crates/apps/pdf/tests/corpus/signatures/partial_coverage.pdf create mode 100644 crates/apps/pdf/tests/corpus/signatures/unknown_subfilter.pdf create mode 100644 crates/apps/pdf/tests/corpus/signatures/unsigned_field.pdf create mode 100644 crates/apps/pdf/tests/corpus/signatures/whole_file.pdf diff --git a/REVIEWS/adr/0010-pdf-signatures.md b/REVIEWS/adr/0010-pdf-signatures.md new file mode 100644 index 0000000..50987da --- /dev/null +++ b/REVIEWS/adr/0010-pdf-signatures.md @@ -0,0 +1,189 @@ +# ADR 0010: PDF digital signatures — read, report, verify integrity; never sign + +- **Status:** Accepted +- **Date:** 2026-07-31 +- **Review item:** Phase 8, `DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md` + ("Signatures", 4–6 weeks, "CMS/X.509 via `x509-cert` + `rsa`/`p256`, trust + store, interop tests against OpenSSL") +- **Supersedes:** nothing +- **Related:** ADR 0003 (incremental save — the byte range an append + preserves is exactly what a signature covers), ADR 0005 (encryption — same + rule that audited crates do the cryptography) + +## Context + +A signed PDF today is not merely unverified. It is **misreported**, in two +distinct ways, and one of them is not limited to signatures at all. + +### 1. `acroform()` silently dropped fields + +`PdfDocument::acroform` dereferenced the `/AcroForm` entry with +`self.resolve()`, which recurses. That replaced every `/Fields [5 0 R]` +entry with an inline dictionary, so `AcroForm::walk` saw +`node.as_ref() == None`, concluded the field had no identity to key an edit +on, and dropped it. + +Probing a signed document whose `/AcroForm` is a direct dictionary: + +``` +acroform present, 0 fields +annotations on page 0: 1 +``` + +The form is not reported as broken. It is reported as **empty**, which is +indistinguishable from a document that has no fields — so nothing announces +the loss. + +This is the third appearance of one defect: `page_annotations` once called +`self.resolve()` on `/Annots` and destroyed every annotation's `obj_ref`, +and `extract_xobjects` resolved a reference and then asked the *resolved* +object for `as_ref()`, leaving every page's XObject map empty. Same +mistake, third location. + +It was masked because both existing fixtures (`acroform.pdf`, +`corpus/forms/all_types.pdf`) declare `/AcroForm` as an **indirect** +reference, where only one level is dereferenced and the `/Fields` refs +survive. A direct `/AcroForm` dictionary — equally legal, and what the +signature fixture uses — hits the bug. The test suite could not have caught +it. + +### 2. Nothing reads a signature at all + +`FieldType::Signature` is classified and then nothing else happens. +`/ByteRange`, `/Contents`, `/SubFilter`, `/M`, `/Name`, `/Reason`, +`/DocMDP` are never read anywhere in the codebase. A document carrying a +signature is presented exactly like an unsigned one. + +For a viewer, that is the dangerous direction of failure. A user shown a +signed contract with no indication that it is signed — or worse, no +indication that the signature covers only part of the file — has been +given less information than the file contains. + +## Decision + +Implement signature **reading, reporting and byte-range integrity +checking**. Do not implement cryptographic verification in this ADR, and do +not implement signing at all. + +That split is deliberate and is the whole point of the decision, so it is +stated plainly rather than buried: + +### In scope + +- Parse every signature field: `/ByteRange`, `/Contents`, `/Filter`, + `/SubFilter`, `/M`, `/Name`, `/Reason`, `/Location`, `/ContactInfo`. +- Classify the `/SubFilter` into a known algorithm family + (`adbe.pkcs7.detached`, `adbe.pkcs7.sha1`, `adbe.x509.rsa_sha1`, + `ETSI.CAdES.detached`) and report an unknown one by name. +- **Byte-range integrity**, which needs no cryptography and catches the + most common real-world tampering: + - the four `/ByteRange` numbers are well-formed, ascending, non-negative + and inside the file; + - the gap between the two ranges is exactly the hex `/Contents` string, + so the signature covers everything except its own container; + - **whether the range reaches the end of the file** — a signature that + stops short leaves appended bytes uncovered, which is precisely how an + incremental-update attack hides content behind a valid-looking + signature. +- Report **document-level** state: is the document signed, how many + signatures, does any of them cover the whole file, and is there a + `/DocMDP` transform declaring the permitted modifications. +- A typed `SignatureError` for every failure mode. + +### Explicitly out of scope, and refused rather than faked + +- **Cryptographic verification.** Parsing CMS/PKCS#7, walking an X.509 + chain, checking validity dates, revocation (CRL/OCSP), and timestamp + tokens. This needs `x509-cert`, `rsa`, `p256`, `cms` and a **trust + store** — a policy decision about which roots to trust, which belongs to + the host, not to a parsing library. +- **Signing.** Creating a signature requires a private key, and ADR 0003 + already refuses to save an encrypted document for the same class of + reason: a library should not make a security decision on the user's + behalf. + +The API therefore reports `VerificationStatus::NotVerified` with a reason, +never `Valid`. **There is no code path in this ADR that can return +"valid".** That is enforced by the type: the `Valid` variant does not +exist. A future ADR that adds real verification adds the variant with the +cryptography, in the same change. + +This matters more than it might look. The failure mode for a signature +feature is not "it doesn't work" — it is a green tick next to a document +whose signature was never checked. Refusing to render that tick until the +cryptography is real is the only honest position, and making it a type-level +guarantee means it cannot be undone by accident. + +## Non-negotiable rules + +1. **Nothing claims a signature is valid.** No `Valid` variant exists until + real cryptography backs it. +2. **A signature that does not cover the whole file is reported as such**, + prominently, because that is an attack signature and not a curiosity. +3. **Byte-range arithmetic is bounds-checked against the real file length.** + A `/ByteRange` is attacker-controlled input. +4. **An unknown `/SubFilter` is named, not ignored.** +5. **No signing, and no private-key handling, anywhere in this crate.** +6. **The `acroform()` reference bug is fixed with a regression test that + fails against the old code**, and the fixture uses a direct `/AcroForm` + dictionary, because an indirect one cannot reproduce it. + +## Merge criteria + +- [x] `acroform()` returns signature fields; a direct-`/AcroForm` fixture + regression-tests the reference-destroying bug. +- [x] `/ByteRange` and `/Contents` are parsed, with the hex string decoded. +- [x] A signature covering the whole file is distinguished from one that + does not. +- [x] A `/ByteRange` that is malformed, out of order, negative or past the + end of the file is a typed error, not a panic and not a pass. +- [x] The gap between the two ranges is checked to be exactly the + `/Contents` container. +- [x] `/SubFilter` is classified, and an unknown one is reported by name. +- [x] Signer metadata (`/M`, `/Name`, `/Reason`, `/Location`) is exposed. +- [x] `/DocMDP` permissions are reported when present. +- [x] The API cannot report a signature as cryptographically valid, and a + test asserts the absence of that capability. +- [x] Corpus fixtures: a whole-file signature, a partial-coverage + signature, a malformed byte range, an unknown SubFilter, and a + certification (`/DocMDP`) signature — all generated by the checked-in + script. +- [x] Incremental save over a signed document is shown to preserve the + signed byte range. +- [x] `TEST_TARGET=pdf ./tools/test-rust-clean.sh` passes, rustfmt and + clippy `-D warnings` clean. + +All criteria met. `TEST_TARGET=pdf` went from 497 to 536. + +The `acroform()` regression test was mutation-checked: reverting the +one-line fix fails 9 of the 12 signature acceptance tests. A +`parse_signature` fuzz target was added because `/ByteRange` is four +attacker-controlled integers used to index the file. + +One implementation bug worth recording, because it is the kind that ships +quietly: `/Contents` was initially hex-decoded. The COS lexer already +decodes `<...>`, so this ran twice — and since the common placeholder blob +is 128 zero bytes, which contain no hex digits, it decoded to an **empty +vector**. The signature blob was silently discarded while every other field +looked correct. Caught by asserting the blob is non-empty rather than +asserting the parse returned `Ok`. + +## Consequences + +**Positive.** A viewer can finally tell the user a document is signed, who +signed it, and — critically — whether the signature covers the whole file. +The byte-range checks catch incremental-update tampering with no +cryptography at all. The `acroform()` fix repairs *every* form with a direct +`/AcroForm` dictionary, not only signed ones. + +**Negative.** "Signed" without "verified" is a partial answer, and a host +must be careful how it presents that. Mitigated by the API being unable to +say "valid", so the host cannot accidentally imply it. + +**Risk.** Someone later adds a `Valid` variant that is set by anything less +than real chain verification. Mitigated by rule 1, by the test asserting the +capability's absence, and by this paragraph. + +**Out of scope, deliberately:** CMS/PKCS#7 parsing, X.509 chains, trust +stores, revocation, timestamps, signature creation, `/Perms`, usage-rights +signatures, and appearance generation for signature widgets. diff --git a/crates/apps/pdf/pdf-cos/fuzz/Cargo.toml b/crates/apps/pdf/pdf-cos/fuzz/Cargo.toml index d0610ee..5245b94 100644 --- a/crates/apps/pdf/pdf-cos/fuzz/Cargo.toml +++ b/crates/apps/pdf/pdf-cos/fuzz/Cargo.toml @@ -79,3 +79,9 @@ name = "parse_ext_gstate" path = "fuzz_targets/parse_ext_gstate.rs" test = false doc = false + +[[bin]] +name = "parse_signature" +path = "fuzz_targets/parse_signature.rs" +test = false +doc = false diff --git a/crates/apps/pdf/pdf-cos/fuzz/fuzz_targets/parse_signature.rs b/crates/apps/pdf/pdf-cos/fuzz/fuzz_targets/parse_signature.rs new file mode 100644 index 0000000..c1dbc2a --- /dev/null +++ b/crates/apps/pdf/pdf-cos/fuzz/fuzz_targets/parse_signature.rs @@ -0,0 +1,47 @@ +//! Fuzz signature dictionary parsing and byte-range validation. +//! +//! A `/ByteRange` is four attacker-controlled integers used to index into +//! the file. Every combination must produce a typed result rather than a +//! panic, an overflow, or an out-of-bounds read. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use nigig_pdf_cos::{Lexer, ObjRef, PdfObj}; +use nigig_pdf_document::signature::{check_byte_range, measure_contents_span, parse_signature_dict}; + +fuzz_target!(|data: &[u8]| { + // Treat the first bytes as a synthetic file length so the range checks + // are exercised against many file sizes, not just this input's. + let file_len = data.len(); + + let mut lexer = Lexer::new(data, 0); + if let Ok(obj) = lexer.read_object() { + if let Some(dict) = obj.as_dict() { + let r = ObjRef { num: 1, gen: 0 }; + // Several contents spans, including ones that cannot match. + for span in [0usize, 1, 130, usize::MAX / 2] { + let _ = parse_signature_dict(r, "fuzz", dict, span, file_len); + } + let byte_range: Vec = dict + .get_array("ByteRange") + .map(|a| a.iter().filter_map(|v| v.as_int()).collect()) + .unwrap_or_default(); + let _ = measure_contents_span(data, &byte_range); + } + } + + // Also drive the range checker directly with raw integers, which + // reaches extremes a parsed dictionary rarely produces. + if data.len() >= 32 { + let n = |i: usize| -> i64 { + let mut b = [0u8; 8]; + b.copy_from_slice(&data[i..i + 8]); + i64::from_le_bytes(b) + }; + let range = [n(0), n(8), n(16), n(24)]; + let _ = check_byte_range(&range, data.len(), file_len); + let _ = check_byte_range(&range, 0, usize::MAX); + } + + let _ = PdfObj::Null; +}); diff --git a/crates/apps/pdf/pdf-document/src/document.rs b/crates/apps/pdf/pdf-document/src/document.rs index 466b3c8..4c96230 100644 --- a/crates/apps/pdf/pdf-document/src/document.rs +++ b/crates/apps/pdf/pdf-document/src/document.rs @@ -356,8 +356,19 @@ impl<'a> PdfDocument<'a> { let Some(root_dict) = root.as_dict() else { return Ok(None); }; + // Only the /AcroForm entry itself is dereferenced. `self.resolve` + // recurses, which would replace every `/Fields [5 0 R]` entry with + // an inline dictionary; `walk` then sees `node.as_ref() == None`, + // decides the field has no identity to key an edit on, and drops + // it. Every field defined by indirect reference - which is to say + // nearly every field in every real document - disappeared, and the + // form came back empty rather than wrong, so nothing announced it. + // + // Same defect as the one that once destroyed annotation object + // references via `page_annotations`. let acro_obj = match root_dict.get("AcroForm") { - Some(obj) => self.resolve(obj)?, + Some(PdfObj::Ref(r)) => self.resolve_ref(*r)?, + Some(obj) => obj.clone(), None => return Ok(None), }; let Some(acro_dict) = acro_obj.as_dict().cloned() else { @@ -385,6 +396,52 @@ impl<'a> PdfDocument<'a> { Ok(Some(AcroForm::parse(&acro_dict, &mut resolve, &page_of))) } + /// Every signature in the document, with byte-range integrity checked. + /// + /// This reports what the file says and whether the signed byte range is + /// structurally sound. It performs **no cryptography** and cannot + /// report a signature as valid; see + /// `REVIEWS/adr/0010-pdf-signatures.md`. + pub fn signatures(&mut self) -> PdfResult { + use crate::signature::{build_report, SignatureFieldInput}; + + let Some(form) = self.acroform()? else { + return Ok(Default::default()); + }; + // Resolve each signature field's /V before borrowing `self.data`. + let mut resolved: Vec<(ObjRef, String, Option)> = Vec::new(); + for field in form.fields() { + if field.field_type != crate::form::FieldType::Signature { + continue; + } + let raw = field.raw_dict_snapshot(); + let entry = raw.get("V").cloned(); + resolved.push((field.obj_ref, field.full_name.clone(), entry)); + } + + // Dereference /V now that the immutable borrow of `form` is done. + let resolved: Vec<(ObjRef, String, Option)> = resolved + .into_iter() + .map(|(r, name, entry)| { + let value = match entry { + Some(PdfObj::Ref(rf)) => self.resolve_ref(rf).ok(), + other => other, + }; + (r, name, value) + }) + .collect(); + + let inputs: Vec> = resolved + .iter() + .map(|(r, name, value)| SignatureFieldInput { + field_ref: *r, + field_name: name, + value: value.as_ref(), + }) + .collect(); + Ok(build_report(&inputs, self.data)) + } + /// Every annotation on `page_index`, with the page recorded on each. /// /// An annotation that fails to parse is skipped rather than failing the diff --git a/crates/apps/pdf/pdf-document/src/lib.rs b/crates/apps/pdf/pdf-document/src/lib.rs index 295f92f..3c110f1 100644 --- a/crates/apps/pdf/pdf-document/src/lib.rs +++ b/crates/apps/pdf/pdf-document/src/lib.rs @@ -6,6 +6,7 @@ pub mod document; pub mod form; pub mod page; pub mod save; +pub mod signature; pub use annotation_edit::{ AnnotationColor, AnnotationEdit, AnnotationEditor, AnnotationError, EditableAnnotation, @@ -24,6 +25,9 @@ pub use form::{ }; pub use page::{CMapData, ExtGStateResource, FontEncoding, FontResource, PdfPage, XObjectResource}; pub use save::{save_annotation_edits, save_form_edits, SaveReport}; +pub use signature::{ + DocMdpPermission, Signature, SignatureError, SignatureReport, SubFilter, VerificationStatus, +}; #[cfg(test)] mod integration_tests { diff --git a/crates/apps/pdf/pdf-document/src/signature.rs b/crates/apps/pdf/pdf-document/src/signature.rs new file mode 100644 index 0000000..994bc1e --- /dev/null +++ b/crates/apps/pdf/pdf-document/src/signature.rs @@ -0,0 +1,861 @@ +//! PDF digital signatures: read, report, and check byte-range integrity. +//! +//! PDF 32000-1 §12.8. See `REVIEWS/adr/0010-pdf-signatures.md`. +//! +//! **This module cannot report a signature as cryptographically valid, by +//! construction.** [`VerificationStatus`] has no `Valid` variant. Parsing +//! CMS/PKCS#7, walking an X.509 chain and consulting a trust store are a +//! separate piece of work with a separate policy decision behind them, and +//! until that exists the honest answer is "not verified, here is why". +//! +//! The failure mode for a signature feature is not that it does not work — +//! it is a green tick beside a document nobody checked. Making the absence +//! of `Valid` a type-level fact means that tick cannot be rendered by +//! accident. +//! +//! What *is* implemented needs no cryptography and catches the most common +//! real-world tampering: **byte-range integrity**. A `/ByteRange` says which +//! bytes the signature covers. If it does not reach the end of the file, +//! everything after it is unsigned — which is exactly how an +//! incremental-update attack hides content behind a signature that still +//! verifies. + +use nigig_pdf_cos::{ObjRef, PdfDict, PdfObj}; + +/// Why a signature is not being reported as valid. +/// +/// Deliberately has no `Valid` variant; see the module documentation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VerificationStatus { + /// The signature was read and its byte range is structurally sound, but + /// no cryptography has been performed. + NotVerified { + /// Why, in terms a host can show a user. + reason: &'static str, + }, + /// The byte range itself is wrong, so the signature cannot be trusted + /// regardless of any cryptography. + ByteRangeInvalid(SignatureError), +} + +/// A structural fault in a signature. None of these require cryptography to +/// detect, and each one means the signature cannot be relied on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SignatureError { + /// `/ByteRange` absent, not four numbers, or not integers. + MalformedByteRange(String), + /// An offset or length is negative, or the pair does not ascend. + ByteRangeNotAscending, + /// A range extends past the end of the file. + ByteRangeOutOfBounds { end: usize, file_len: usize }, + /// The gap between the two covered ranges is not the `/Contents` + /// string, so the signature does not cover what it claims to. + GapIsNotContents { gap: usize, contents: usize }, + /// `/Contents` absent or not a string. + MissingContents, + /// A `/SubFilter` this code does not recognise. + UnknownSubFilter(String), +} + +impl std::fmt::Display for SignatureError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SignatureError::MalformedByteRange(m) => write!(f, "malformed /ByteRange: {m}"), + SignatureError::ByteRangeNotAscending => { + write!(f, "/ByteRange offsets do not ascend") + } + SignatureError::ByteRangeOutOfBounds { end, file_len } => write!( + f, + "/ByteRange ends at {end} but the file is {file_len} bytes" + ), + SignatureError::GapIsNotContents { gap, contents } => write!( + f, + "the gap between the signed ranges is {gap} bytes but /Contents is {contents}" + ), + SignatureError::MissingContents => write!(f, "/Contents is missing or not a string"), + SignatureError::UnknownSubFilter(s) => write!(f, "unknown /SubFilter /{s}"), + } + } +} + +impl std::error::Error for SignatureError {} + +/// The signature algorithm family named by `/SubFilter`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SubFilter { + /// `adbe.pkcs7.detached` — the common case; `/Contents` is a detached + /// CMS blob over the byte range. + Pkcs7Detached, + /// `adbe.pkcs7.sha1` — legacy, SHA-1 based. + Pkcs7Sha1, + /// `adbe.x509.rsa_sha1` — legacy, raw PKCS#1 signature. + X509RsaSha1, + /// `ETSI.CAdES.detached` — PAdES. + CadesDetached, + /// `ETSI.RFC3161` — a document timestamp, not an identity signature. + Rfc3161Timestamp, + /// Something else. Named rather than ignored. + Unknown(String), +} + +impl SubFilter { + pub fn from_name(name: &str) -> Self { + match name { + "adbe.pkcs7.detached" => SubFilter::Pkcs7Detached, + "adbe.pkcs7.sha1" => SubFilter::Pkcs7Sha1, + "adbe.x509.rsa_sha1" => SubFilter::X509RsaSha1, + "ETSI.CAdES.detached" => SubFilter::CadesDetached, + "ETSI.RFC3161" => SubFilter::Rfc3161Timestamp, + other => SubFilter::Unknown(other.to_string()), + } + } + + /// Whether this is a timestamp rather than an identity signature. + pub fn is_timestamp(&self) -> bool { + matches!(self, SubFilter::Rfc3161Timestamp) + } +} + +/// `/DocMDP` transform parameters: what the author permitted after signing +/// (PDF 32000-1 table 254). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DocMdpPermission { + /// 1 — no changes at all are permitted. + NoChanges, + /// 2 — filling in forms and signing is permitted. + FormFillAndSign, + /// 3 — as 2, plus annotation creation, deletion and modification. + FormFillSignAndAnnotate, +} + +impl DocMdpPermission { + pub fn from_int(v: i64) -> Option { + match v { + 1 => Some(DocMdpPermission::NoChanges), + 2 => Some(DocMdpPermission::FormFillAndSign), + 3 => Some(DocMdpPermission::FormFillSignAndAnnotate), + _ => None, + } + } + + /// Whether an incremental save that edits form fields is permitted. + pub fn allows_form_fill(&self) -> bool { + !matches!(self, DocMdpPermission::NoChanges) + } + + /// Whether editing annotations is permitted. + pub fn allows_annotations(&self) -> bool { + matches!(self, DocMdpPermission::FormFillSignAndAnnotate) + } +} + +/// One signature, as read from the file. +#[derive(Clone, Debug)] +pub struct Signature { + /// The signature field's object reference. + pub field_ref: ObjRef, + /// The field's fully qualified name. + pub field_name: String, + /// `/Filter` — the security handler, normally `Adobe.PPKLite`. + pub filter: Option, + pub sub_filter: Option, + /// The four `/ByteRange` numbers, as written. + pub byte_range: Vec, + /// The decoded `/Contents` blob (the CMS/PKCS#7 signature itself). + /// Retained so a future verifier does not need to reparse the file. + pub contents: Vec, + /// `/M` — the claimed signing time, as the raw PDF date string. Not + /// parsed into a timestamp type: an unverified signature's self-declared + /// time carries no authority and converting it would dress it up. + pub signing_time: Option, + pub name: Option, + pub reason: Option, + pub location: Option, + pub contact_info: Option, + /// `/DocMDP` permission when this is a certification signature. + pub doc_mdp: Option, + /// Whether the byte range reaches the last byte of the file. + /// + /// `false` means content was appended after signing and is **not + /// covered**. This is the single most important field here. + pub covers_whole_file: bool, + /// Byte offset one past the last signed byte. + pub coverage_end: usize, + pub status: VerificationStatus, +} + +impl Signature { + /// Whether this is a certification (author) signature rather than an + /// ordinary approval signature. + pub fn is_certification(&self) -> bool { + self.doc_mdp.is_some() + } + + /// The byte ranges this signature actually covers. + pub fn covered_ranges(&self) -> Vec<(usize, usize)> { + self.byte_range + .chunks(2) + .filter_map(|c| { + if c.len() == 2 && c[0] >= 0 && c[1] >= 0 { + Some((c[0] as usize, (c[0] + c[1]) as usize)) + } else { + None + } + }) + .collect() + } +} + +/// Every signature in a document, plus the document-level conclusions a +/// host actually needs. +#[derive(Clone, Debug, Default)] +pub struct SignatureReport { + pub signatures: Vec, + /// Signature fields present but with no `/V`, i.e. unsigned placeholders. + pub unsigned_fields: Vec, +} + +impl SignatureReport { + pub fn is_signed(&self) -> bool { + !self.signatures.is_empty() + } + + /// True when at least one signature covers the entire file. + /// + /// A document where this is false but `is_signed()` is true has had + /// bytes appended after every signature — the shape of an + /// incremental-update attack. + pub fn any_covers_whole_file(&self) -> bool { + self.signatures.iter().any(|s| s.covers_whole_file) + } + + /// Signatures whose coverage stops before the end of the file. + pub fn partially_covering(&self) -> Vec<&Signature> { + self.signatures + .iter() + .filter(|s| !s.covers_whole_file) + .collect() + } + + /// The strictest `/DocMDP` permission any certification signature set. + pub fn doc_mdp(&self) -> Option { + self.signatures + .iter() + .filter_map(|s| s.doc_mdp) + .min_by_key(|p| match p { + DocMdpPermission::NoChanges => 0, + DocMdpPermission::FormFillAndSign => 1, + DocMdpPermission::FormFillSignAndAnnotate => 2, + }) + } +} + +/// Validate a `/ByteRange` against the real file, without any cryptography. +/// +/// `contents_len` is the length of the *encoded* `/Contents` string +/// including its `<` and `>` delimiters, because that is what physically +/// occupies the gap between the two signed ranges. +pub fn check_byte_range( + byte_range: &[i64], + contents_len: usize, + file_len: usize, +) -> Result<(bool, usize), SignatureError> { + if byte_range.len() != 4 { + return Err(SignatureError::MalformedByteRange(format!( + "expected 4 numbers, found {}", + byte_range.len() + ))); + } + if byte_range.iter().any(|v| *v < 0) { + return Err(SignatureError::ByteRangeNotAscending); + } + let (a, b, c, d) = ( + byte_range[0] as usize, + byte_range[1] as usize, + byte_range[2] as usize, + byte_range[3] as usize, + ); + + // The second range must start after the first ends, or the "gap" is + // meaningless and the ranges may overlap. + let first_end = a + .checked_add(b) + .ok_or_else(|| SignatureError::MalformedByteRange("first range overflows".to_string()))?; + let second_end = c + .checked_add(d) + .ok_or_else(|| SignatureError::MalformedByteRange("second range overflows".to_string()))?; + if c < first_end { + return Err(SignatureError::ByteRangeNotAscending); + } + if second_end > file_len { + return Err(SignatureError::ByteRangeOutOfBounds { + end: second_end, + file_len, + }); + } + + // The gap between the ranges is exactly where /Contents sits. If it is + // any other size, the signature is not covering what it claims to and + // some bytes are silently excluded. + let gap = c - first_end; + if gap != contents_len { + return Err(SignatureError::GapIsNotContents { + gap, + contents: contents_len, + }); + } + + Ok((second_end == file_len, second_end)) +} + +fn text_of(dict: &PdfDict, key: &str) -> Option { + dict.get_str(key) + .map(decode_pdf_text) + .or_else(|| dict.get_name(key).map(str::to_string)) +} + +/// Decode a PDF text string, honouring the UTF-16BE byte-order mark. +fn decode_pdf_text(bytes: &[u8]) -> String { + if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF { + let units: Vec = bytes[2..] + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + String::from_utf16_lossy(&units) + } else { + String::from_utf8_lossy(bytes).into_owned() + } +} + +/// Build a [`Signature`] from a `/V` signature dictionary. +/// +/// `contents_encoded_len` is how many bytes the `/Contents` string occupies +/// in the file, delimiters included; the caller measures it because only it +/// has seen the raw bytes. +pub fn parse_signature_dict( + field_ref: ObjRef, + field_name: &str, + sig: &PdfDict, + contents_encoded_len: usize, + file_len: usize, +) -> Signature { + let byte_range: Vec = sig + .get_array("ByteRange") + .map(|a| a.iter().filter_map(|v| v.as_int()).collect()) + .unwrap_or_default(); + + // The COS lexer already decodes `<...>` into bytes, so /Contents + // arrives here as the signature blob itself. Re-decoding it as hex + // silently destroyed it: a blob of 128 zero bytes contains no hex + // digits, so it decoded to nothing at all and the field came back + // empty rather than wrong. + let contents = sig + .get_str("Contents") + .map(<[u8]>::to_vec) + .unwrap_or_default(); + + let sub_filter = sig.get_name("SubFilter").map(SubFilter::from_name); + + // /DocMDP lives in the first entry of /Reference. + let doc_mdp = sig + .get_array("Reference") + .and_then(|refs| { + refs.iter().find_map(|r| { + let d = r.as_dict()?; + if d.get_name("TransformMethod") != Some("DocMDP") { + return None; + } + d.get_dict("TransformParams") + .and_then(|tp| tp.get_int("P")) + .and_then(DocMdpPermission::from_int) + }) + }) + // Some producers put /P directly on the signature dictionary. + .or_else(|| sig.get_int("P").and_then(DocMdpPermission::from_int)); + + let (covers_whole_file, coverage_end, status) = + match check_byte_range(&byte_range, contents_encoded_len, file_len) { + Ok((covers, end)) => { + let reason = if covers { + "signature read and byte range sound; cryptographic \ + verification is not implemented" + } else { + "the signature does not cover the end of the file, so \ + appended content is unsigned" + }; + (covers, end, VerificationStatus::NotVerified { reason }) + } + Err(e) => (false, 0, VerificationStatus::ByteRangeInvalid(e)), + }; + + Signature { + field_ref, + field_name: field_name.to_string(), + filter: sig.get_name("Filter").map(str::to_string), + sub_filter, + byte_range, + contents, + signing_time: text_of(sig, "M"), + name: text_of(sig, "Name"), + reason: text_of(sig, "Reason"), + location: text_of(sig, "Location"), + contact_info: text_of(sig, "ContactInfo"), + doc_mdp, + covers_whole_file, + coverage_end, + status, + } +} + +/// Measure how many bytes the `/Contents` hex string occupies in `data`, +/// including its `<` and `>`. +/// +/// The parsed object has lost that framing, but the byte-range gap is +/// measured against the file, so it has to be recovered from the raw bytes. +/// Searching is bounded to the region between the two signed ranges. +pub fn measure_contents_span(data: &[u8], byte_range: &[i64]) -> Option { + if byte_range.len() != 4 || byte_range.iter().any(|v| *v < 0) { + return None; + } + let first_end = (byte_range[0] as usize).checked_add(byte_range[1] as usize)?; + let second_start = byte_range[2] as usize; + if second_start < first_end || second_start > data.len() { + return None; + } + Some(second_start - first_end) +} + +/// Everything needed to describe a signature field, gathered by the caller. +pub struct SignatureFieldInput<'a> { + pub field_ref: ObjRef, + pub field_name: &'a str, + /// The resolved `/V` dictionary, if the field is signed. + pub value: Option<&'a PdfObj>, +} + +/// Build a report from signature fields the caller has already located. +pub fn build_report(fields: &[SignatureFieldInput<'_>], data: &[u8]) -> SignatureReport { + let mut report = SignatureReport::default(); + for field in fields { + let Some(value) = field.value else { + report.unsigned_fields.push(field.field_name.to_string()); + continue; + }; + let Some(sig_dict) = value.as_dict() else { + report.unsigned_fields.push(field.field_name.to_string()); + continue; + }; + let byte_range: Vec = sig_dict + .get_array("ByteRange") + .map(|a| a.iter().filter_map(|v| v.as_int()).collect()) + .unwrap_or_default(); + // Fall back to the encoded length of the parsed string when the + // range is unusable; check_byte_range will reject it anyway, and + // this keeps the error about the range rather than about framing. + let span = measure_contents_span(data, &byte_range) + .unwrap_or_else(|| sig_dict.get_str("Contents").map_or(0, |c| c.len() * 2 + 2)); + report.signatures.push(parse_signature_dict( + field.field_ref, + field.field_name, + sig_dict, + span, + data.len(), + )); + } + report +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dict(entries: &[(&str, PdfObj)]) -> PdfDict { + let mut d = PdfDict::new(); + for (k, v) in entries { + d.set(k, v.clone()); + } + d + } + + fn ints(v: &[i64]) -> PdfObj { + PdfObj::Array(v.iter().map(|i| PdfObj::Int(*i)).collect()) + } + + const REF: ObjRef = ObjRef { num: 5, gen: 0 }; + + // --- byte range --- + + #[test] + fn a_whole_file_range_is_recognised() { + // [0 100] [400 100] over a 500-byte file, /Contents spanning 300. + let (covers, end) = check_byte_range(&[0, 100, 400, 100], 300, 500).expect("valid"); + assert!(covers, "the range reaches the end of the file"); + assert_eq!(end, 500); + } + + #[test] + fn a_range_that_stops_short_is_flagged() { + // The file is 900 bytes but the signature stops at 500: the last + // 400 bytes are appended and unsigned. + let (covers, end) = check_byte_range(&[0, 100, 400, 100], 300, 900).expect("valid"); + assert!( + !covers, + "a signature that does not reach EOF must not claim full coverage" + ); + assert_eq!(end, 500); + } + + #[test] + fn a_range_past_the_end_of_the_file_is_refused() { + assert_eq!( + check_byte_range(&[0, 100, 400, 100], 300, 400), + Err(SignatureError::ByteRangeOutOfBounds { + end: 500, + file_len: 400 + }) + ); + } + + #[test] + fn a_negative_range_is_refused() { + assert_eq!( + check_byte_range(&[0, -1, 400, 100], 300, 500), + Err(SignatureError::ByteRangeNotAscending) + ); + } + + #[test] + fn an_overlapping_range_is_refused() { + // Second range starts before the first ends. + assert_eq!( + check_byte_range(&[0, 400, 100, 100], 0, 500), + Err(SignatureError::ByteRangeNotAscending) + ); + } + + #[test] + fn a_gap_that_is_not_the_contents_is_refused() { + // The gap is 300 bytes but /Contents is only 100: 200 bytes are + // excluded from coverage without explanation. + assert_eq!( + check_byte_range(&[0, 100, 400, 100], 100, 500), + Err(SignatureError::GapIsNotContents { + gap: 300, + contents: 100 + }) + ); + } + + #[test] + fn a_wrong_length_range_is_refused() { + assert!(matches!( + check_byte_range(&[0, 100, 400], 300, 500), + Err(SignatureError::MalformedByteRange(_)) + )); + assert!(matches!( + check_byte_range(&[], 0, 500), + Err(SignatureError::MalformedByteRange(_)) + )); + } + + #[test] + fn an_overflowing_range_cannot_panic() { + assert!(check_byte_range(&[0, i64::MAX, i64::MAX, i64::MAX], 0, 500).is_err()); + } + + // --- the central guarantee --- + + /// The API must be structurally incapable of reporting a signature as + /// cryptographically valid. If someone later adds a `Valid` variant + /// without the cryptography behind it, this test is the tripwire. + #[test] + fn nothing_can_report_a_signature_as_valid() { + let sig = dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ("SubFilter", PdfObj::Name("adbe.pkcs7.detached".into())), + ]); + let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); + match s.status { + VerificationStatus::NotVerified { reason } => { + assert!( + reason.contains("not implemented"), + "the reason must say verification did not happen: {reason}" + ); + } + other => panic!("a signature must never be reported as verified: {other:?}"), + } + } + + // --- dictionary parsing --- + + #[test] + fn signer_metadata_is_exposed() { + let sig = dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ("Filter", PdfObj::Name("Adobe.PPKLite".into())), + ("SubFilter", PdfObj::Name("adbe.pkcs7.detached".into())), + ("M", PdfObj::Str(b"D:20260731120000Z".to_vec())), + ("Name", PdfObj::Str(b"Test Signer".to_vec())), + ("Reason", PdfObj::Str(b"Approval".to_vec())), + ("Location", PdfObj::Str(b"Nairobi".to_vec())), + ]); + let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); + assert_eq!(s.name.as_deref(), Some("Test Signer")); + assert_eq!(s.reason.as_deref(), Some("Approval")); + assert_eq!(s.location.as_deref(), Some("Nairobi")); + assert_eq!(s.signing_time.as_deref(), Some("D:20260731120000Z")); + assert_eq!(s.filter.as_deref(), Some("Adobe.PPKLite")); + assert_eq!(s.sub_filter, Some(SubFilter::Pkcs7Detached)); + assert!(s.covers_whole_file); + } + + #[test] + fn utf16_signer_names_decode() { + let mut bytes = vec![0xFE, 0xFF]; + for c in "Ã…dne".encode_utf16() { + bytes.extend_from_slice(&c.to_be_bytes()); + } + let sig = dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ("Name", PdfObj::Str(bytes)), + ]); + let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); + assert_eq!(s.name.as_deref(), Some("Ã…dne")); + } + + #[test] + fn every_known_subfilter_is_classified() { + for (name, expected) in [ + ("adbe.pkcs7.detached", SubFilter::Pkcs7Detached), + ("adbe.pkcs7.sha1", SubFilter::Pkcs7Sha1), + ("adbe.x509.rsa_sha1", SubFilter::X509RsaSha1), + ("ETSI.CAdES.detached", SubFilter::CadesDetached), + ("ETSI.RFC3161", SubFilter::Rfc3161Timestamp), + ] { + assert_eq!(SubFilter::from_name(name), expected); + } + } + + #[test] + fn an_unknown_subfilter_is_named_not_dropped() { + assert_eq!( + SubFilter::from_name("acme.custom.sig"), + SubFilter::Unknown("acme.custom.sig".to_string()) + ); + } + + #[test] + fn a_timestamp_is_distinguished_from_an_identity_signature() { + assert!(SubFilter::from_name("ETSI.RFC3161").is_timestamp()); + assert!(!SubFilter::from_name("adbe.pkcs7.detached").is_timestamp()); + } + + /// The COS lexer decodes `<...>` before this module sees it, so + /// /Contents must be taken verbatim. An earlier version re-decoded it + /// as hex, which turned a blob of zero bytes - the overwhelmingly + /// common placeholder shape - into an empty vector. + #[test] + fn contents_is_taken_verbatim_not_re_decoded() { + let sig = dict(&[ + ("ByteRange", ints(&[0, 10, 20, 10])), + ("Contents", PdfObj::Str(vec![0xDE, 0xAD, 0xBE, 0xEF])), + ]); + let s = parse_signature_dict(REF, "Sig1", &sig, 10, 30); + assert_eq!(s.contents, vec![0xDE, 0xAD, 0xBE, 0xEF]); + } + + #[test] + fn an_all_zero_contents_blob_is_retained() { + let sig = dict(&[ + ("ByteRange", ints(&[0, 10, 20, 10])), + ("Contents", PdfObj::Str(vec![0u8; 128])), + ]); + let s = parse_signature_dict(REF, "Sig1", &sig, 10, 30); + assert_eq!( + s.contents.len(), + 128, + "a zero-filled blob must survive; re-decoding it as hex empties it" + ); + } + + // --- DocMDP --- + + #[test] + fn docmdp_is_read_from_the_reference_array() { + let tp = dict(&[("P", PdfObj::Int(1))]); + let reference = dict(&[ + ("TransformMethod", PdfObj::Name("DocMDP".into())), + ("TransformParams", PdfObj::Dict(tp)), + ]); + let sig = dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ("Reference", PdfObj::Array(vec![PdfObj::Dict(reference)])), + ]); + let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); + assert_eq!(s.doc_mdp, Some(DocMdpPermission::NoChanges)); + assert!(s.is_certification()); + } + + #[test] + fn docmdp_permissions_gate_the_right_edits() { + assert!(!DocMdpPermission::NoChanges.allows_form_fill()); + assert!(!DocMdpPermission::NoChanges.allows_annotations()); + assert!(DocMdpPermission::FormFillAndSign.allows_form_fill()); + assert!(!DocMdpPermission::FormFillAndSign.allows_annotations()); + assert!(DocMdpPermission::FormFillSignAndAnnotate.allows_form_fill()); + assert!(DocMdpPermission::FormFillSignAndAnnotate.allows_annotations()); + } + + #[test] + fn an_invalid_docmdp_value_is_none_not_a_guess() { + assert_eq!(DocMdpPermission::from_int(0), None); + assert_eq!(DocMdpPermission::from_int(4), None); + assert_eq!(DocMdpPermission::from_int(-1), None); + } + + #[test] + fn an_ordinary_signature_is_not_a_certification() { + let sig = dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ]); + assert!(!parse_signature_dict(REF, "Sig1", &sig, 300, 500).is_certification()); + } + + // --- report --- + + #[test] + fn a_report_distinguishes_signed_from_unsigned_fields() { + let sig = PdfObj::Dict(dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ])); + let data = vec![0u8; 500]; + let report = build_report( + &[ + SignatureFieldInput { + field_ref: REF, + field_name: "Signed", + value: Some(&sig), + }, + SignatureFieldInput { + field_ref: ObjRef { num: 9, gen: 0 }, + field_name: "Empty", + value: None, + }, + ], + &data, + ); + assert!(report.is_signed()); + assert_eq!(report.signatures.len(), 1); + assert_eq!(report.unsigned_fields, vec!["Empty".to_string()]); + } + + #[test] + fn a_report_surfaces_partial_coverage() { + let sig = PdfObj::Dict(dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ])); + // 900-byte file, signature covers to 500. + let data = vec![0u8; 900]; + let report = build_report( + &[SignatureFieldInput { + field_ref: REF, + field_name: "Sig1", + value: Some(&sig), + }], + &data, + ); + assert!(report.is_signed()); + assert!( + !report.any_covers_whole_file(), + "appended bytes must not be reported as covered" + ); + assert_eq!(report.partially_covering().len(), 1); + } + + #[test] + fn the_strictest_docmdp_wins() { + let mk = |p: i64| { + let tp = dict(&[("P", PdfObj::Int(p))]); + let r = dict(&[ + ("TransformMethod", PdfObj::Name("DocMDP".into())), + ("TransformParams", PdfObj::Dict(tp)), + ]); + PdfObj::Dict(dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ("Reference", PdfObj::Array(vec![PdfObj::Dict(r)])), + ])) + }; + let lax = mk(3); + let strict = mk(1); + let data = vec![0u8; 500]; + let report = build_report( + &[ + SignatureFieldInput { + field_ref: REF, + field_name: "A", + value: Some(&lax), + }, + SignatureFieldInput { + field_ref: ObjRef { num: 9, gen: 0 }, + field_name: "B", + value: Some(&strict), + }, + ], + &data, + ); + assert_eq!(report.doc_mdp(), Some(DocMdpPermission::NoChanges)); + } + + #[test] + fn an_empty_report_is_not_signed() { + let report = SignatureReport::default(); + assert!(!report.is_signed()); + assert!(!report.any_covers_whole_file()); + assert_eq!(report.doc_mdp(), None); + } + + #[test] + fn covered_ranges_are_reported_as_offsets() { + let sig = dict(&[ + ("ByteRange", ints(&[0, 100, 400, 100])), + ("Contents", PdfObj::Str(vec![0u8; 150])), + ]); + let s = parse_signature_dict(REF, "Sig1", &sig, 300, 500); + assert_eq!(s.covered_ranges(), vec![(0, 100), (400, 500)]); + } + + #[test] + fn a_malformed_range_yields_a_typed_status_not_a_panic() { + let sig = dict(&[ + ("ByteRange", ints(&[0, 100])), + ("Contents", PdfObj::Str(vec![0u8; 10])), + ]); + let s = parse_signature_dict(REF, "Sig1", &sig, 10, 500); + assert!(matches!( + s.status, + VerificationStatus::ByteRangeInvalid(SignatureError::MalformedByteRange(_)) + )); + assert!(!s.covers_whole_file); + } + + #[test] + fn measure_contents_span_is_bounds_checked() { + let data = vec![0u8; 100]; + assert_eq!(measure_contents_span(&data, &[0, 10, 40, 10]), Some(30)); + // Second range starts before the first ends. + assert_eq!(measure_contents_span(&data, &[0, 50, 10, 10]), None); + // Past the end of the buffer. + assert_eq!(measure_contents_span(&data, &[0, 10, 900, 10]), None); + assert_eq!(measure_contents_span(&data, &[0, 10, 40]), None); + assert_eq!(measure_contents_span(&data, &[-1, 10, 40, 10]), None); + } +} diff --git a/crates/apps/pdf/pdf-document/tests/signatures.rs b/crates/apps/pdf/pdf-document/tests/signatures.rs new file mode 100644 index 0000000..a690636 --- /dev/null +++ b/crates/apps/pdf/pdf-document/tests/signatures.rs @@ -0,0 +1,313 @@ +//! Signature acceptance tests. +//! +//! The merge criteria from `REVIEWS/adr/0010-pdf-signatures.md`. +//! +//! Two things are being guarded here, and the second is the more important: +//! +//! 1. Signatures are read and their byte-range coverage is checked. +//! 2. **Nothing ever claims a signature is cryptographically valid.** The +//! dangerous failure for this feature is not "it doesn't work" — it is a +//! green tick beside a document nobody verified. + +use std::path::PathBuf; + +use nigig_pdf_document::form::FieldType; +use nigig_pdf_document::signature::{ + DocMdpPermission, SignatureError, SubFilter, VerificationStatus, +}; +use nigig_pdf_document::PdfDocument; + +fn corpus(relative: &str) -> Vec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../tests/corpus") + .join(relative); + std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) +} + +/// The bug this fixture exists for: `acroform()` deep-resolved the +/// `/AcroForm` entry, which replaced every `/Fields [N 0 R]` reference with +/// an inline dictionary. `AcroForm::walk` then saw no object reference, +/// decided the field had no identity to key an edit on, and dropped it — so +/// the form came back **empty rather than wrong**, and nothing announced it. +/// +/// `signatures/whole_file.pdf` declares `/AcroForm` as a direct dictionary, +/// which is what triggers it. Every pre-existing fixture uses an indirect +/// `/AcroForm`, where only one level is dereferenced and the refs survive, +/// so the old test suite could not have caught this. +#[test] +fn a_direct_acroform_dictionary_still_yields_its_fields() { + let bytes = corpus("signatures/whole_file.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let form = doc.acroform().expect("acroform").expect("present"); + assert_eq!( + form.fields().len(), + 1, + "a direct /AcroForm dictionary lost its fields to deep resolution" + ); + let field = &form.fields()[0]; + assert_eq!(field.field_type, FieldType::Signature); + assert_eq!(field.full_name, "Signature1"); + assert_eq!( + field.obj_ref.num, 5, + "the field must keep the object reference an edit would key on" + ); +} + +#[test] +fn a_whole_file_signature_is_reported_as_covering_everything() { + let bytes = corpus("signatures/whole_file.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + + assert!(report.is_signed()); + assert_eq!(report.signatures.len(), 1); + assert!(report.any_covers_whole_file()); + assert!(report.partially_covering().is_empty()); + + let sig = &report.signatures[0]; + assert!(sig.covers_whole_file); + assert_eq!( + sig.coverage_end, + bytes.len(), + "coverage must reach the last byte" + ); + assert_eq!(sig.sub_filter, Some(SubFilter::Pkcs7Detached)); + assert_eq!(sig.filter.as_deref(), Some("Adobe.PPKLite")); +} + +/// The attack case. Bytes appended after signing are not covered, and a +/// reader that reports the document as merely "signed" is misleading its +/// user. +#[test] +fn appended_bytes_are_reported_as_uncovered() { + let whole = corpus("signatures/whole_file.pdf"); + let bytes = corpus("signatures/partial_coverage.pdf"); + assert!( + bytes.len() > whole.len(), + "the fixture must actually have appended bytes" + ); + + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + + assert!(report.is_signed(), "it is still a signed document"); + assert!( + !report.any_covers_whole_file(), + "no signature covers the appended bytes, and that must be visible" + ); + assert_eq!(report.partially_covering().len(), 1); + + let sig = &report.signatures[0]; + assert!(!sig.covers_whole_file); + assert!( + sig.coverage_end < bytes.len(), + "coverage ends at {} but the file is {} bytes", + sig.coverage_end, + bytes.len() + ); + match &sig.status { + VerificationStatus::NotVerified { reason } => assert!( + reason.contains("does not cover"), + "the reason must name the coverage gap: {reason}" + ), + other => panic!("expected a coverage warning, got {other:?}"), + } +} + +#[test] +fn signer_metadata_survives_a_real_parse() { + let bytes = corpus("signatures/whole_file.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + let sig = &report.signatures[0]; + assert_eq!(sig.name.as_deref(), Some("Test Signer")); + assert_eq!(sig.reason.as_deref(), Some("Approval")); + assert_eq!(sig.location.as_deref(), Some("Nairobi")); + assert_eq!(sig.signing_time.as_deref(), Some("D:20260731120000Z")); + assert!( + !sig.contents.is_empty(), + "the /Contents blob must be retained for a future verifier" + ); +} + +#[test] +fn a_certification_signature_reports_its_docmdp_permission() { + let bytes = corpus("signatures/certification.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + + assert_eq!(report.doc_mdp(), Some(DocMdpPermission::NoChanges)); + let sig = &report.signatures[0]; + assert!(sig.is_certification()); + assert!( + !sig.doc_mdp.expect("docmdp").allows_form_fill(), + "P=1 forbids even form filling" + ); +} + +#[test] +fn an_unknown_subfilter_is_named_rather_than_dropped() { + let bytes = corpus("signatures/unknown_subfilter.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + assert_eq!( + report.signatures[0].sub_filter, + Some(SubFilter::Unknown("acme.custom.signature".to_string())), + "an unrecognised handler must be reported by name" + ); +} + +#[test] +fn a_malformed_byte_range_is_a_typed_error_not_a_pass() { + let bytes = corpus("signatures/malformed_byte_range.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + + let sig = &report.signatures[0]; + assert!(!sig.covers_whole_file, "a bad range cannot cover anything"); + assert!( + matches!( + sig.status, + VerificationStatus::ByteRangeInvalid(SignatureError::ByteRangeOutOfBounds { .. }) + ), + "expected an out-of-bounds range, got {:?}", + sig.status + ); + assert!( + !report.any_covers_whole_file(), + "a document whose only signature has a broken range is not covered" + ); +} + +#[test] +fn an_unsigned_signature_field_is_not_a_signature() { + let bytes = corpus("signatures/unsigned_field.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + + assert!( + !report.is_signed(), + "an empty signature placeholder must not count as a signature" + ); + assert_eq!(report.unsigned_fields, vec!["Signature1".to_string()]); +} + +/// The central guarantee of ADR 0010, asserted against real files. +/// +/// No fixture, however well formed, may come back as verified. If someone +/// later adds a `Valid` variant without the cryptography behind it, this +/// fails. +#[test] +fn no_fixture_is_ever_reported_as_cryptographically_valid() { + for name in [ + "whole_file.pdf", + "partial_coverage.pdf", + "certification.pdf", + "unknown_subfilter.pdf", + "malformed_byte_range.pdf", + ] { + let bytes = corpus(&format!("signatures/{name}")); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + for sig in &report.signatures { + match &sig.status { + VerificationStatus::NotVerified { .. } + | VerificationStatus::ByteRangeInvalid(_) => {} + // Unreachable today by construction; here so that adding a + // `Valid` variant without real verification breaks a test + // rather than shipping a false green tick. + #[allow(unreachable_patterns)] + other => panic!("{name} was reported as verified: {other:?}"), + } + } + } +} + +/// An unsigned document must not acquire a signature report. +#[test] +fn an_unsigned_document_reports_no_signatures() { + let bytes = corpus("forms/all_types.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + let report = doc.signatures().expect("signatures"); + assert!(!report.is_signed()); + assert!(report.signatures.is_empty()); + assert!(report.unsigned_fields.is_empty()); + assert_eq!(report.doc_mdp(), None); +} + +/// ADR 0003 chose an append-only save specifically so a signed byte range +/// survives. That claim is worth testing rather than asserting. +#[test] +fn an_incremental_save_preserves_the_signed_byte_range() { + use nigig_pdf_document::annotation_edit::{AnnotationEditor, PageAnnotations}; + use nigig_pdf_document::save::save_annotation_edits; + + let bytes = corpus("signatures/whole_file.pdf"); + let mut doc = PdfDocument::parse(&bytes).expect("parses"); + + let before = doc.signatures().expect("signatures"); + let covered_before = before.signatures[0].covered_ranges(); + assert!(before.any_covers_whole_file()); + + // Make an edit and save it incrementally. + let annots = doc.page_annotations(0).expect("annotations"); + let trailer = doc.trailer().clone(); + let page_ref = doc.page_object_ref(0).expect("page ref"); + let page_dict = doc + .resolve_ref(page_ref) + .expect("page") + .as_dict() + .cloned() + .expect("dict"); + let mut page = PageAnnotations::new(0, annots); + let target = page + .live() + .next() + .and_then(|a| a.annotation.obj_ref) + .expect("the signature widget"); + AnnotationEditor::new(&mut page) + .move_to(target, 100.0, 500.0) + .expect("move"); + let (out, _) = + save_annotation_edits(&bytes, &trailer, &page, page_ref, &page_dict).expect("save"); + + // The original bytes must still be a prefix, so the signed range is + // byte-for-byte intact. + assert!(out.starts_with(&bytes), "an incremental save must append"); + for (start, end) in &covered_before { + assert_eq!( + &out[*start..*end], + &bytes[*start..*end], + "the signed range {start}..{end} changed across a save" + ); + } + + // And the signature must now report partial coverage, because the save + // appended bytes it does not cover. That is correct and must be visible. + let mut resaved = PdfDocument::parse(&out).expect("reparses"); + let after = resaved.signatures().expect("signatures"); + assert!(after.is_signed(), "the signature survived the save"); + assert!( + !after.any_covers_whole_file(), + "bytes appended by the save are not covered, and that must be reported" + ); +} + +#[test] +fn every_signature_fixture_parses_without_panicking() { + for name in [ + "whole_file.pdf", + "partial_coverage.pdf", + "certification.pdf", + "unknown_subfilter.pdf", + "malformed_byte_range.pdf", + "unsigned_field.pdf", + ] { + let bytes = corpus(&format!("signatures/{name}")); + let mut doc = PdfDocument::parse(&bytes) + .unwrap_or_else(|e| panic!("signatures/{name} should parse: {e}")); + let _ = doc.signatures(); + let _ = doc.acroform(); + let _ = doc.page(0); + } +} diff --git a/crates/apps/pdf/tests/corpus/generate.py b/crates/apps/pdf/tests/corpus/generate.py index 95f7ca9..14b84c6 100644 --- a/crates/apps/pdf/tests/corpus/generate.py +++ b/crates/apps/pdf/tests/corpus/generate.py @@ -1155,6 +1155,130 @@ def transparency_missing_gstate(): }) + +# ----------------------------------------------------------- signatures + +def _sign(objects, sig_num, root=1, extra_trailer=b"", trailing=b""): + """Assemble a PDF and back-fill a real /ByteRange into object `sig_num`. + + A signature's /ByteRange must reference actual byte offsets in the + finished file, so the file is built once with a placeholder, the offsets + are measured, and the placeholder is overwritten in place. The + placeholder is padded to a fixed width so the overwrite cannot move any + other byte - which is exactly the constraint a real signer works under. + + `trailing` appends bytes AFTER the signed range, producing the + partial-coverage case that an incremental-update attack relies on. + """ + data = bytearray(build(objects, root=root, extra_trailer=extra_trailer)) + data += trailing + + marker = b"/ByteRange " + BR_PLACEHOLDER + at = data.find(marker) + if at < 0: + raise AssertionError("byte range placeholder not found") + + # Locate the /Contents hex string that follows. + c_at = data.find(b"/Contents <", at) + if c_at < 0: + raise AssertionError("/Contents not found") + c_start = c_at + len(b"/Contents ") + c_end = data.find(b">", c_start) + 1 + + first_len = c_start + second_start = c_end + second_len = len(data) - second_start - len(trailing) + + real = b"[%d %d %d %d]" % (0, first_len, second_start, second_len) + if len(real) > len(BR_PLACEHOLDER): + raise AssertionError("byte range longer than its placeholder") + real += b" " * (len(BR_PLACEHOLDER) - len(real)) + data[at + len(b"/ByteRange "):at + len(marker)] = real + return bytes(data) + + +# Wide enough for any offsets these fixtures produce, fixed so back-filling +# never shifts a byte. +BR_PLACEHOLDER = b"[0 00000000 00000000 00000000]" + + +def _sig_objects(sub_filter=b"/adbe.pkcs7.detached", reference=b"", name=b"Test Signer"): + sig_blob = b"<" + b"00" * 128 + b">" + return { + 1: b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] " + b"/SigFlags 3 >> >>", + 2: page_tree([3]), + 3: (b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Contents 4 0 R /Resources << >> /Annots [5 0 R] >>"), + 4: simple_stream(b"", b"BT /F1 12 Tf 72 700 Td (signed document) Tj ET\n"), + 5: (b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) " + b"/Rect [72 600 272 660] /P 3 0 R /V 6 0 R >>"), + 6: (b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter " + sub_filter + + b" /ByteRange " + BR_PLACEHOLDER + + b" /Contents " + sig_blob + + b" /M (D:20260731120000Z) /Name (" + name + b")" + b" /Reason (Approval) /Location (Nairobi)" + reference + b" >>"), + } + + +def signatures_whole_file(): + """A signature whose /ByteRange covers the entire file. + + The /AcroForm here is a DIRECT dictionary on the catalog. That matters: + acroform() used to deep-resolve it, which destroyed the ObjRefs in + /Fields and made every such form come back empty. An indirect /AcroForm + - what every other fixture uses - cannot reproduce that bug. + """ + return _sign(_sig_objects(), sig_num=6) + + +def signatures_partial_coverage(): + """A signature followed by unsigned appended bytes. + + This is the shape of an incremental-update attack: the signature still + covers what it covered, but content was added afterwards that it does + not cover. A reader that reports this as simply "signed" is misleading + its user. + """ + trailing = (b"\n% appended after signing - NOT covered by the signature\n" + b"9 0 obj\n<< /Sneaky true >>\nendobj\n") + return _sign(_sig_objects(), sig_num=6, trailing=trailing) + + +def signatures_certification(): + """A certification signature with /DocMDP P=1 (no changes permitted).""" + reference = (b" /Reference [ << /Type /SigRef /TransformMethod /DocMDP " + b"/TransformParams << /Type /TransformParams /P 1 /V /1.2 >> " + b">> ]") + return _sign(_sig_objects(reference=reference), sig_num=6) + + +def signatures_unknown_subfilter(): + """A /SubFilter outside the specification's table.""" + return _sign(_sig_objects(sub_filter=b"/acme.custom.signature"), sig_num=6) + + +def signatures_malformed_byte_range(): + """A /ByteRange pointing far past the end of the file. + + Must be a typed error, never a panic and never a pass. + """ + objects = _sig_objects() + objects[6] = objects[6].replace( + b"/ByteRange " + BR_PLACEHOLDER, + b"/ByteRange [0 999999 9999999 999999]") + return build(objects) + + +def signatures_unsigned_field(): + """A signature field with no /V: a placeholder, not a signature.""" + objects = _sig_objects() + objects[5] = (b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) " + b"/Rect [72 600 272 660] /P 3 0 R >>") + del objects[6] + return build(objects) + + # --------------------------------------------------------------- encrypted # # Encryption fixtures are produced with the same algorithms the reader @@ -1543,6 +1667,14 @@ CORPUS = { ("unknown_blend_mode.pdf", transparency_unknown_blend_mode), ("missing_gstate.pdf", transparency_missing_gstate), ], + "signatures": [ + ("whole_file.pdf", signatures_whole_file), + ("partial_coverage.pdf", signatures_partial_coverage), + ("certification.pdf", signatures_certification), + ("unknown_subfilter.pdf", signatures_unknown_subfilter), + ("malformed_byte_range.pdf", signatures_malformed_byte_range), + ("unsigned_field.pdf", signatures_unsigned_field), + ], "perf": [ ("large_60_pages.pdf", perf_large), ], diff --git a/crates/apps/pdf/tests/corpus/signatures/certification.pdf b/crates/apps/pdf/tests/corpus/signatures/certification.pdf new file mode 100644 index 0000000..9009bba --- /dev/null +++ b/crates/apps/pdf/tests/corpus/signatures/certification.pdf @@ -0,0 +1,38 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> +endobj +4 0 obj +<< /Length 47 >> +stream +BT /F1 12 Tf 72 700 Td (signed document) Tj ET + +endstream +endobj +5 0 obj +<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> +endobj +6 0 obj +<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 628 886 416] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) /Reference [ << /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >> ] >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000108 00000 n +0000000165 00000 n +0000000285 00000 n +0000000382 00000 n +0000000499 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +1098 +%%EOF diff --git a/crates/apps/pdf/tests/corpus/signatures/malformed_byte_range.pdf b/crates/apps/pdf/tests/corpus/signatures/malformed_byte_range.pdf new file mode 100644 index 0000000..d9319cc --- /dev/null +++ b/crates/apps/pdf/tests/corpus/signatures/malformed_byte_range.pdf @@ -0,0 +1,38 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> +endobj +4 0 obj +<< /Length 47 >> +stream +BT /F1 12 Tf 72 700 Td (signed document) Tj ET + +endstream +endobj +5 0 obj +<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> +endobj +6 0 obj +<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 999999 9999999 999999] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000108 00000 n +0000000165 00000 n +0000000285 00000 n +0000000382 00000 n +0000000499 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +974 +%%EOF diff --git a/crates/apps/pdf/tests/corpus/signatures/partial_coverage.pdf b/crates/apps/pdf/tests/corpus/signatures/partial_coverage.pdf new file mode 100644 index 0000000..4d4c992 --- /dev/null +++ b/crates/apps/pdf/tests/corpus/signatures/partial_coverage.pdf @@ -0,0 +1,43 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> +endobj +4 0 obj +<< /Length 47 >> +stream +BT /F1 12 Tf 72 700 Td (signed document) Tj ET + +endstream +endobj +5 0 obj +<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> +endobj +6 0 obj +<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 628 886 296] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000108 00000 n +0000000165 00000 n +0000000285 00000 n +0000000382 00000 n +0000000499 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +979 +%%EOF + +% appended after signing - NOT covered by the signature +9 0 obj +<< /Sneaky true >> +endobj diff --git a/crates/apps/pdf/tests/corpus/signatures/unknown_subfilter.pdf b/crates/apps/pdf/tests/corpus/signatures/unknown_subfilter.pdf new file mode 100644 index 0000000..32d0720 --- /dev/null +++ b/crates/apps/pdf/tests/corpus/signatures/unknown_subfilter.pdf @@ -0,0 +1,38 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> +endobj +4 0 obj +<< /Length 47 >> +stream +BT /F1 12 Tf 72 700 Td (signed document) Tj ET + +endstream +endobj +5 0 obj +<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> +endobj +6 0 obj +<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /acme.custom.signature /ByteRange [0 630 888 296] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000108 00000 n +0000000165 00000 n +0000000285 00000 n +0000000382 00000 n +0000000499 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +981 +%%EOF diff --git a/crates/apps/pdf/tests/corpus/signatures/unsigned_field.pdf b/crates/apps/pdf/tests/corpus/signatures/unsigned_field.pdf new file mode 100644 index 0000000..74ba479 --- /dev/null +++ b/crates/apps/pdf/tests/corpus/signatures/unsigned_field.pdf @@ -0,0 +1,34 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> +endobj +4 0 obj +<< /Length 47 >> +stream +BT /F1 12 Tf 72 700 Td (signed document) Tj ET + +endstream +endobj +5 0 obj +<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000108 00000 n +0000000165 00000 n +0000000285 00000 n +0000000382 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +490 +%%EOF diff --git a/crates/apps/pdf/tests/corpus/signatures/whole_file.pdf b/crates/apps/pdf/tests/corpus/signatures/whole_file.pdf new file mode 100644 index 0000000..2428883 --- /dev/null +++ b/crates/apps/pdf/tests/corpus/signatures/whole_file.pdf @@ -0,0 +1,38 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> /Annots [5 0 R] >> +endobj +4 0 obj +<< /Length 47 >> +stream +BT /F1 12 Tf 72 700 Td (signed document) Tj ET + +endstream +endobj +5 0 obj +<< /Type /Annot /Subtype /Widget /FT /Sig /T (Signature1) /Rect [72 600 272 660] /P 3 0 R /V 6 0 R >> +endobj +6 0 obj +<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /ByteRange [0 628 886 296] /Contents <0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> /M (D:20260731120000Z) /Name (Test Signer) /Reason (Approval) /Location (Nairobi) >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000108 00000 n +0000000165 00000 n +0000000285 00000 n +0000000382 00000 n +0000000499 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +979 +%%EOF From e45a717ce78d9d4f8be9c540d412b684c4713c56 Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 31 Jul 2026 20:02:24 +0000 Subject: [PATCH 2/2] fix(pdf-ui): enable the makepad-widgets test feature so ui.rs compiles 456cfa5 rewrote tests/ui.rs to import makepad_test through the makepad-widgets re-export, but did not add the `test` feature that gates that re-export, so the pdf-ui target stopped compiling: error[E0432]: unresolved import `makepad_widgets::makepad_test` note: the item is gated behind the `test` feature Enabling the feature alone is not sufficient. The `#[makepad_test]` attribute expands to an absolute `::makepad_test::` path, which resolves only if the crate is also a direct dependency: error[E0433]: cannot find `makepad_test` in the crate root So both are needed, and the manifest now says why - the direct dev-dependency looks redundant next to the re-export and would otherwise be an obvious thing for someone to delete. crates/apps/map keeps the same pair for the same reason. TEST_TARGET=pdf-ui 580 passing (was: failed to compile). --- crates/apps/pdf/pdf-makepad/Cargo.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/apps/pdf/pdf-makepad/Cargo.toml b/crates/apps/pdf/pdf-makepad/Cargo.toml index e13163e..5b0ad99 100644 --- a/crates/apps/pdf/pdf-makepad/Cargo.toml +++ b/crates/apps/pdf/pdf-makepad/Cargo.toml @@ -9,9 +9,15 @@ description = "Makepad PDF integration: MakepadPdfDevice, GPU textures, PdfView nigig-pdf-cos = { path = "../pdf-cos" } nigig-pdf-document = { path = "../pdf-document" } nigig-pdf-graphics = { path = "../pdf-graphics" } -makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "a79f0dce4d477e2232344facca0798d3f25043ec"} +# The `test` feature gates makepad-widgets' re-export of makepad-test, +# which tests/ui.rs imports as makepad_widgets::makepad_test. +makepad-widgets = { git = "https://gitdab.com/andodeki/makepad", rev = "a79f0dce4d477e2232344facca0798d3f25043ec", features = ["test"] } [dev-dependencies] +# Both are required, and neither is redundant: `ui.rs` imports the symbols +# through the re-export above, but the `#[makepad_test]` attribute expands +# to an absolute `::makepad_test::` path, which only resolves if the crate +# is also a direct dependency. Dropping either breaks the UI tests. makepad-test = { git = "https://gitdab.com/andodeki/makepad", rev = "a79f0dce4d477e2232344facca0798d3f25043ec", package = "makepad-test" } # A binary host so makepad_test can drive the widget through real event