diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index c8fa0d703a..73c02fe87a 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -372,11 +372,42 @@ fn drawn_for_first_signature(cos: std::borrow::Cow<'_, pdfcraft_cos::Document>) std::borrow::Cow::Owned(drawn) } -/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through a -/// form XObject it paints (counted as text without looking inside). +/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through the +/// form XObjects it paints (a background-only one doesn't count). fn draws_text(cos: &pdfcraft_cos::Document, widget: &pdfcraft_cos::Dict) -> bool { let ap = widget.get(b"AP").map(|ap| cos.resolve(ap)); let normal = ap.as_deref().and_then(|ap| ap.as_dict()).and_then(|ap| ap.get(b"N")).map(|n| cos.resolve(n)); let Some(pdfcraft_cos::Object::Stream(s)) = normal.as_deref() else { return false }; - s.decoded().is_ok_and(|data| pdfcraft_content::ops(&data).any(|op| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"" | b"Do"))) + stream_draws_text(cos, s, &mut 16) +} + +/// Whether the form stream `s` shows text, following `Do` into its form XObjects. `budget` caps +/// the streams opened along the way; running out (deep nesting, a cycle, many XObjects) counts +/// as text, so the field keeps its own appearance as it would without the look inside. +fn stream_draws_text(cos: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, budget: &mut u8) -> bool { + let Ok(data) = s.decoded() else { return false }; + let resources = s.dict.get(b"Resources").map(|r| cos.resolve(r)); + let xobjects = resources.as_deref().and_then(|r| r.as_dict()).and_then(|r| r.get(b"XObject")).map(|x| cos.resolve(x)); + for op in pdfcraft_content::ops(&data) { + match op.op.as_slice() { + b"Tj" | b"TJ" | b"'" | b"\"" => return true, + b"Do" => { + let name = op.operands.first().and_then(|n| n.as_name()); + let x = name.and_then(|n| xobjects.as_deref().and_then(|x| x.as_dict()).and_then(|x| x.get(n))).map(|x| cos.resolve(x)); + let Some(pdfcraft_cos::Object::Stream(f)) = x.as_deref() else { continue }; + if f.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + if *budget == 0 { + return true; + } + *budget -= 1; + if stream_draws_text(cos, f, budget) { + return true; + } + } + _ => {} + } + } + false } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 55c57c2f29..b12e84419f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2701,14 +2701,17 @@ fn a_signed_file_carries_no_password_field_value() { /// A form with `/NeedAppearances true` whose filled fields have stored appearances that draw no /// text: `name` an empty placeholder, `town` one too with a value Helvetica can't hold (`ň`), -/// `city` such a value with its own appearance that draws it (#5939). +/// `city` such a value with its own appearance that draws it (#5939), `street` no appearance at +/// all, `river` a value Helvetica can't hold (`ř`) with an appearance that paints only a +/// background form XObject, and `lake` such a value drawn by a form XObject its appearance +/// paints. fn need_appearances_form() -> Vec { let stream = |content: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Length {} >>\nstream\n{content}\nendstream", content.len()); let objs = [ - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(), + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R 15 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), - "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R 15 0 R] >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada Lovelace) /Rect [20 250 180 270] /P 3 0 R /AP << /N 8 0 R >> >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (town) /V /Rect [20 200 180 220] /P 3 0 R /AP << /N 9 0 R >> >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V /Rect [20 150 180 170] /P 3 0 R /AP << /N 10 0 R >> >>".into(), @@ -2716,6 +2719,13 @@ fn need_appearances_form() -> Vec { stream("/Tx BMC EMC"), stream("/Tx BMC EMC"), stream("/Tx BMC BT /Helv 10 Tf 2 5 Td (Brno r) Tj ET EMC"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (street) /V (Main Street) /Rect [20 100 180 120] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (river) /V /Rect [20 50 180 70] /P 3 0 R /AP << /N 13 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Resources << /XObject << /Bg 14 0 R >> >> /Length 10 >>\nstream\nq /Bg Do Q\nendstream".into(), + stream("0.9 g 0 0 160 20 re f"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (lake) /V /Rect [20 20 180 40] /P 3 0 R /AP << /N 16 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Resources << /XObject << /Fm 17 0 R >> >> /Length 10 >>\nstream\nq /Fm Do Q\nendstream".into(), + stream("BT /Helv 10 Tf 2 5 Td (Orech) Tj ET"), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -2757,9 +2767,14 @@ fn the_first_signature_draws_the_fields_need_appearances_left_to_the_viewer() { assert!(normal_appearance(&doc, 4).contains("(Ada Lovelace) Tj"), "{}", normal_appearance(&doc, 4)); assert!(normal_appearance(&doc, 5).contains("(Plze?) Tj"), "{}", normal_appearance(&doc, 5)); assert!(normal_appearance(&doc, 6).contains("(Brno r) Tj"), "{}", normal_appearance(&doc, 6)); + assert!(normal_appearance(&doc, 11).contains("(Main Street) Tj"), "{}", normal_appearance(&doc, 11)); + // Its own appearance only paints a background, so `river` gets the drawing, `?` and all. + assert!(normal_appearance(&doc, 12).contains("(O?ech) Tj"), "{}", normal_appearance(&doc, 12)); let city = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(6, 0)).as_dict().unwrap().get(b"AP").cloned(); let original = pdfcraft_cos::Document::open(Arc::new(need_appearances_form())).unwrap(); assert_eq!(city(&doc), city(&original), "city keeps its own appearance"); + let lake = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(15, 0)).as_dict().unwrap().get(b"AP").cloned(); + assert_eq!(lake(&doc), lake(&original), "lake keeps the appearance its form XObject draws"); s.mark_signed(id, signed, None).unwrap(); s.set_trusted_certificates(vec![digital_id.certificate.clone()]); let sig = &s.get(id).unwrap().signatures[0]; diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index efb5960ee5..f331ee8228 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -120,16 +120,24 @@ fn check_signing_permissions(doc: &Document, opts: &SignOptions) -> Result<(), S const BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333]; /// Whether `doc` already holds a signature or a document timestamp: a signature dictionary whose -/// `/Contents` is not empty (a field whose `/V` has an empty `/Contents` is not signed yet). A -/// signature field without a name (`/T`) counts too: indirect signature dictionaries are found by -/// their `/ByteRange`, not through the field tree. +/// `/Contents` holds a nonzero byte (a field whose `/V` has an empty or zero-filled `/Contents` +/// is not signed yet). A signature field without a name (`/T`) counts too: indirect signature +/// dictionaries and document timestamps are found in one pass over the file (by `/ByteRange`, +/// or as timestamps), not through the field tree. pub fn has_signatures(doc: &Document) -> bool { let signed = |d: &Dict| { let contents = d.get(b"Contents").map(|c| doc.resolve(c)); - contents.as_deref().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()) + // Hex strings decode to the DER when parsed; a literal string carries hex text. + let der = contents + .as_deref() + .and_then(Object::as_string) + .and_then(|s| if s.hex { Some(s.bytes.clone()) } else { super::validate::unhex(&s.bytes) }); + der.is_some_and(|der| der.iter().any(|b| *b != 0)) }; - super::validate::signature_contents(doc).iter().any(|der| !der.is_empty()) - || doc.scan_objects().any(|(_, o)| o.as_dict().is_some_and(|d| d.contains(b"ByteRange") && signed(d))) + sig_fields(doc).iter().any(|f| f.dict.get(b"V").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).is_some_and(signed)) + || doc + .scan_objects() + .any(|(_, o)| o.as_dict().is_some_and(|d| (d.contains(b"ByteRange") || super::validate::is_doc_timestamp(d)) && signed(d))) } /// Sign `doc` with `id`; returns the signed file (an incremental update when possible). diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 08b482c4b4..09a4e5264c 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -26,7 +26,7 @@ pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) - list(doc, bytes, trust).into_iter().find(|s| s.field == field) } -fn unhex(s: &[u8]) -> Option> { +pub(super) fn unhex(s: &[u8]) -> Option> { let digits: Vec = s.iter().copied().filter(|c| !c.is_ascii_whitespace()).collect(); let val = |c: u8| (c as char).to_digit(16).map(|d| d as u8); digits.chunks(2).map(|p| Some(val(p[0])? << 4 | p.get(1).map_or(Some(0), |c| val(*c))?)).collect() diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index b327c104ee..de55fdca6c 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -167,6 +167,36 @@ fn an_empty_signature_value_does_not_count_as_an_earlier_signature() { assert!(form_of(&signed).get(b"NeedAppearances").is_none()); } +/// A signature dictionary whose /Contents is still the zero-filled placeholder (hex or literal +/// hex text, with a /ByteRange) is not signed either: the first real signature drops the flag. +#[test] +fn a_zero_filled_signature_placeholder_does_not_count_as_an_earlier_signature() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + for contents in [&b"<0000000000000000>"[..], b"(00000000)"] { + let sig = [&b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /ByteRange [0 10 20 10] /Contents "[..], contents, b" >>"] + .concat(); + let bytes = fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] /NeedAppearances true >> >>", + &[b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Pending) /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>", &sig], + ); + assert!(!pdfcraft_sign::has_signatures(&open(&bytes)), "{}", String::from_utf8_lossy(contents)); + let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); + assert!(form_of(&signed).get(b"NeedAppearances").is_none()); + assert!(pdfcraft_sign::has_signatures(&open(&signed))); + } +} + +/// A document timestamp outside the field tree counts by its /Contents alone, even without a +/// /ByteRange; a zero-filled one does not. +#[test] +fn a_document_timestamp_counts_unless_its_contents_are_zero_filled() { + for (contents, stamped) in [(&b"<3080>"[..], true), (b"<0000>", false)] { + let ts = [&b"<< /Type /DocTimeStamp /SubFilter /ETSI.RFC3161 /Contents "[..], contents, b" >>"].concat(); + let bytes = fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true >> >>", &[&ts]); + assert_eq!(pdfcraft_sign::has_signatures(&open(&bytes)), stamped, "{}", String::from_utf8_lossy(contents)); + } +} + /// A signed field without a name (`/T` is optional) is an earlier signature too: signing another /// field leaves `/NeedAppearances` as it is. #[test]