Merge pull request #6208 from vangel76/fix/5939-sign-need-appearances

Signing a form that sets NeedAppearances no longer makes Acrobat ignore the signature
This commit is contained in:
FredApps authored and GitHub committed 2026-11-08 13:31:20 +01:00
commit 82c2009d09
7 files changed
+366 -6

No files matched your search

+77 -2
View File
@@ -314,7 +314,7 @@ impl Session {
opts.date = self.signing_date();
}
// A typed Password-field value is not signed into the file (#3926), as it isn't saved.
let cos = writable(&editor.cos)?;
let cos = drawn_for_first_signature(writable(&editor.cos)?);
Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?))
}
@@ -332,7 +332,7 @@ impl Session {
if opts.date.is_empty() {
opts.date = self.signing_date();
}
let cos = writable(&editor.cos)?;
let cos = drawn_for_first_signature(writable(&editor.cos)?);
let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?);
// Checked like a save before anyone writes it (#2166).
self.check_save(doc, &signed)?;
@@ -374,3 +374,78 @@ impl Session {
Ok(())
}
}
/// `cos` with its form fields drawn, when the signature about to be made is the document's first
/// and the form sets `/NeedAppearances` (#5939). That signature removes the flag, after which
/// viewers show each field's stored appearance; a filled field whose stored appearance draws no
/// text (an empty placeholder such as `/Tx BMC EMC`, or a stale look) would go blank. So every
/// text and choice field is drawn from its value first, as the document is displayed
/// ([`pdfcraft_forms::with_field_appearances`]), and the signature covers that look.
///
/// One exception: where PdfCraft's drawing would show a character as `?` (the field's font can't
/// hold it) and the field's own appearance draws text, the field keeps its own appearance, which
/// the program that filled it drew with a font that has the character. A field whose own
/// appearance draws no text gets PdfCraft's drawing, `?` and all. Later signatures change
/// nothing here: redrawing would count as a change to the earlier signatures.
fn drawn_for_first_signature(cos: std::borrow::Cow<'_, pdfcraft_cos::Document>) -> std::borrow::Cow<'_, pdfcraft_cos::Document> {
use pdfcraft_forms::FieldKind;
if !pdfcraft_forms::need_appearances(&cos) || pdfcraft_sign::has_signatures(&cos) {
return cos;
}
let Some(mut drawn) = pdfcraft_forms::with_field_appearances(&cos) else { return cos };
for f in pdfcraft_forms::fields(&cos) {
if !matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List) {
continue;
}
for w in &f.widgets {
let own = cos.get(w.obj).as_dict().filter(|d| draws_text(&cos, d)).and_then(|d| d.get(b"AP").cloned());
// `.1`: the first character the drawing showed as `?`.
if let Some(ap) = own
&& pdfcraft_forms::appearance::field_appearance_drawn(&cos, &f, w, &f.value, true).1.is_some()
{
drawn.update_dict(w.obj, |d| d.set(b"AP".to_vec(), ap)).ok();
}
}
}
std::borrow::Cow::Owned(drawn)
}
/// 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 };
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
}
+120
View File
@@ -2699,6 +2699,126 @@ fn a_signed_file_carries_no_password_field_value() {
assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length");
}
/// 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), `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<u8> {
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 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 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 <FEFF0050006C007A00650148> /Rect [20 200 180 220] /P 3 0 R /AP << /N 9 0 R >> >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V <FEFF00420072006E006F00200159> /Rect [20 150 180 170] /P 3 0 R /AP << /N 10 0 R >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
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 <FEFF004F0159006500630068> /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 <FEFF004F0159006500630068> /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();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// The decoded normal appearance of the widget `num` in `doc`.
fn normal_appearance(doc: &pdfcraft_cos::Document, num: u32) -> String {
let widget = doc.get(pdfcraft_cos::ObjRef::new(num, 0));
let ap = doc.resolve(widget.as_dict().unwrap().get(b"AP").unwrap());
let n = doc.resolve(ap.as_dict().unwrap().get(b"N").unwrap());
let pdfcraft_cos::Object::Stream(s) = &*n else { panic!("{n:?}") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
/// #5939 review: the first signature removes `/NeedAppearances`, so the fields are drawn from
/// their values first; without that, `name` and `town` would show their empty placeholders. A
/// field whose own appearance draws its value keeps it rather than get a `?` for `ř`.
#[test]
fn the_first_signature_draws_the_fields_need_appearances_left_to_the_viewer() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
// After the test certificate's start date (2026-10-02).
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("form.pdf", None, Arc::new(need_appearances_form()), None).unwrap();
let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
let doc = pdfcraft_cos::Document::open(signed.clone()).unwrap();
assert!(!pdfcraft_forms::need_appearances(&doc));
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];
assert_eq!((sig.signed, sig.status, sig.modification.clone()), (true, SignatureStatus::Valid, sign::Modification::None));
}
/// A later signature draws nothing and leaves the flag: either would be a change to what the
/// first signature covers.
#[test]
fn a_later_signature_leaves_need_appearances_forms_alone() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("form.pdf", None, Arc::new(need_appearances_form()), None).unwrap();
let first = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
// The flag set again after the first signature, and `name` back to its empty placeholder.
let mut cos = pdfcraft_cos::Document::open(first).unwrap();
let root = cos.root().unwrap();
cos.update_dict(root, |c| {
if let Some(pdfcraft_cos::Object::Dict(af)) = c.get_mut(b"AcroForm") {
af.set(b"NeedAppearances".to_vec(), pdfcraft_cos::Object::Bool(true));
}
})
.unwrap();
let placeholder = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(Default::default(), b"/Tx BMC EMC".to_vec())));
cos.update_dict(pdfcraft_cos::ObjRef::new(4, 0), |w| {
let mut ap = pdfcraft_cos::Dict::new();
ap.set(b"N".to_vec(), pdfcraft_cos::Object::Ref(placeholder));
w.set(b"AP".to_vec(), pdfcraft_cos::Object::Dict(ap));
})
.unwrap();
let flagged =
Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap());
let id = s.open("flagged.pdf", None, flagged.clone(), None).unwrap();
let second = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap();
let (before, after) = (pdfcraft_cos::Document::open(flagged).unwrap(), pdfcraft_cos::Document::open(second.clone()).unwrap());
assert!(pdfcraft_forms::need_appearances(&after));
let widget = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(4, 0));
assert_eq!(widget(&after), widget(&before));
assert_eq!(normal_appearance(&after, 4), "/Tx BMC EMC");
s.mark_signed(id, second, None).unwrap();
assert_eq!(s.get(id).unwrap().signatures.iter().filter(|x| x.signed).count(), 2);
}
/// #3944: a value typed into an XFA `<passwordEdit/>` field stays in the open document (both the
/// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`),
/// but is written to no file — neither on the widget nor in the XFA datasets stream.
+1 -1
View File
@@ -30,7 +30,7 @@ pub use graphic::Graphic;
pub use keys::{DigestAlg, PrivateKey, PublicKey};
pub use pdf::{
Appearance, AppearanceLabels, Detail, DigestCache, Fill, Modification, SignOptions, SignatureInfo, Status, TrustStore, WORDINGS, Words,
in_english, kind, list as signatures, sign, sign_with_timestamp, timestamp_document, validate,
has_signatures, in_english, kind, list as signatures, sign, sign_with_timestamp, timestamp_document, validate,
};
pub use pkcs12::DigitalId;
pub use status_layers::{StatusCaptions, has_status_layers, show_status_layers, status_rgb};
+1 -1
View File
@@ -38,7 +38,7 @@ use listing::{DISCOVERY_CACHE_LIMIT, Field, MAX_ENTRIES, MAX_FIELDS, MAX_NAME_BY
pub use listing::{DigestCache, list, list_cached};
pub(crate) use listing::{walk_sig_fields, widget_shown};
pub(crate) use signing::fmt;
pub use signing::{Appearance, AppearanceLabels, SignOptions, display_date, sign, sign_with_timestamp, timestamp_document};
pub use signing::{Appearance, AppearanceLabels, SignOptions, display_date, has_signatures, sign, sign_with_timestamp, timestamp_document};
pub use types::{Detail, Fill, Modification, Status, TrustStore, WORDINGS, Words, in_english, kind};
pub(crate) use validate::signature_contents;
pub use validate::validate;
+50 -1
View File
@@ -119,14 +119,44 @@ fn check_signing_permissions(doc: &Document, opts: &SignOptions) -> Result<(), S
/// Placeholder `/ByteRange` values: fixed width, patched after writing.
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` 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));
// 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))
};
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).
///
/// The document's first signature (no [`has_signatures`]) removes the AcroForm's
/// `/NeedAppearances` flag: Acrobat does not recognise a signature while it is set. Later
/// signatures leave the flag as it is. This function draws no field appearances: once the flag is
/// gone, viewers show each field's stored normal appearance (`/AP /N`), so a filled field whose
/// stored appearance draws no text (an empty placeholder such as `/Tx BMC EMC`, or a look left
/// from an earlier value) shows that instead of its value. A caller that wants the values kept
/// draws the fields first, as `Session::sign` in the engine does.
pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result<Vec<u8>, SignError> {
sign_inner(doc, id, opts, None)
}
/// Sign and embed an RFC 3161 signature timestamp from `tsa` (PAdES B-T). The transport is the
/// caller's: this crate never opens a socket, and a rejected or malformed token fails the
/// signing before any file is produced.
/// signing before any file is produced. The form's `/NeedAppearances` flag is handled as in
/// [`sign`].
pub fn sign_with_timestamp(
doc: &Document,
id: &DigitalId,
@@ -153,6 +183,7 @@ fn sign_inner(
}
check_signing_permissions(doc, opts)?;
let mut doc = doc.clone();
let earlier_signatures = has_signatures(&doc);
let root = doc.root().ok_or_else(|| SignError::Pdf("the document has no catalog".into()))?;
let alg = id.key.preferred_digest();
let reserve =
@@ -305,6 +336,24 @@ fn sign_inner(
doc.update_dict(r, |af| af.set(b"SigFlags".to_vec(), Object::Int(3)))?;
}
}
// Acrobat does not recognise a signature in a form that asks viewers to redraw its fields
// (`/NeedAppearances true`): redrawing would change what the signature covers. The first
// signature drops the flag; a later one leaves it, as removing it would count as a change
// to the earlier signatures. Without the flag viewers draw each field's stored `/AP /N`, so
// a filled field whose stored appearance draws no text (an empty placeholder, or a stale
// look) shows that instead of its value unless the caller drew the fields first (see `sign`).
if !earlier_signatures {
if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) {
doc.update_dict(r, |af| {
af.remove(b"NeedAppearances");
})?;
}
doc.update_dict(root, |c| {
if let Some(Object::Dict(af)) = c.get_mut(b"AcroForm") {
af.remove(b"NeedAppearances");
}
})?;
}
// The appearance.
let ap = appearance(&mut doc, rect, turn, &name, &id.certificate, opts)?;
let ap_ref = doc.add(Object::Stream(ap));
+1 -1
View File
@@ -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<Vec<u8>> {
pub(super) fn unhex(s: &[u8]) -> Option<Vec<u8>> {
let digits: Vec<u8> = 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()
+116
View File
@@ -122,6 +122,122 @@ fn signing_then_validating_with_and_without_trust() {
}
}
/// The form dictionary of `bytes`, with the catalog's reference resolved.
fn form_of(bytes: &[u8]) -> Dict {
let doc = open(bytes);
let form = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"AcroForm").map(|a| doc.resolve(a))).unwrap();
form.as_dict().unwrap().clone()
}
/// A form with `/NeedAppearances true` makes Acrobat ignore the signature (#5939): the first
/// signature drops the flag, whether the AcroForm is inline or indirect, and keeps /Fields and
/// sets /SigFlags.
#[test]
fn signing_drops_need_appearances() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let inline = fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true >> >>", &[]);
let indirect =
fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm 7 0 R >>", &[b"<< /Fields [6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) >>"]);
for (what, bytes) in [("inline", inline), ("indirect", indirect)] {
assert_eq!(form_of(&bytes).get(b"NeedAppearances"), Some(&Object::Bool(true)), "{what}");
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let form = form_of(&signed);
assert!(form.get(b"NeedAppearances").is_none(), "{what}");
assert_eq!(form.get(b"SigFlags"), Some(&Object::Int(3)), "{what}");
assert!(form.get(b"Fields").is_some(), "{what}");
let doc = open(&signed);
assert!(signatures(&doc, &signed, &TrustStore::default()).iter().any(|s| s.field == "Approval" && s.signed), "{what}");
}
}
/// A field whose /V has an empty /Contents is no earlier signature: the first real one still
/// drops `/NeedAppearances`.
#[test]
fn an_empty_signature_value_does_not_count_as_an_earlier_signature() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
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 (Empty) /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>",
b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /Contents <> >>",
],
);
assert!(!pdfcraft_sign::has_signatures(&open(&bytes)));
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
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]
fn an_unnamed_signed_field_counts_as_an_earlier_signature() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
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 /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>",
b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /ByteRange [0 10 20 10] /Contents <3080> >>",
],
);
assert!(pdfcraft_sign::has_signatures(&open(&bytes)));
let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
assert_eq!(form_of(&signed).get(b"NeedAppearances"), Some(&Object::Bool(true)));
}
/// A second signer leaves the form's flags alone: changing them would count as a change to
/// what the first signature covered.
#[test]
fn a_later_signature_does_not_change_need_appearances() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let first = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
let mut doc = open(&first);
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
if let Some(Object::Dict(af)) = c.get_mut(b"AcroForm") {
af.set(b"NeedAppearances".to_vec(), Object::Bool(true));
}
})
.unwrap();
if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) {
doc.update_dict(r, |af| af.set(b"NeedAppearances".to_vec(), Object::Bool(true))).unwrap();
}
let flagged = write_incremental(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
let second = pdfcraft_sign::sign(&open(&flagged), &id, &opts()).unwrap();
assert_eq!(form_of(&second).get(b"NeedAppearances"), Some(&Object::Bool(true)));
let doc = open(&second);
assert!(signatures(&doc, &second, &TrustStore::default()).iter().any(|s| s.field == "Approval" && s.signed));
}
#[test]
fn signing_an_existing_field_and_counter_signing() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();