From 44428b36d318cf910ab25dd29ac9f5ed8961ff11 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sat, 7 Nov 2026 12:46:10 +0800 Subject: [PATCH] M9: a signed number saved back as an integer is no change after signing (sign.changes-after-signing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISO 32000-2 §7.3.3: wherever a real number is expected, an integer may be used instead. PdfCraft's own writer writes a whole real as an integer, so a form fill that rewrites a signed widget turned /Rect [20.0 20.0 120.0 40.0] into [20 20 120 40] and was refused as a page content change. same_object now compares an integer and a real by value (exactly, in i128), for the widget /Rect and every other signed comparison, such as a page /MediaBox rewritten when a comment is added. Also states the reordered page's verdict as None explicitly, in a test of its own next to the one from #5962. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/sign/src/pdf/changes.rs | 61 ++++++++++++++++++- .../src/pdf/revision_classification_tests.rs | 38 ++++++++++++ parity/acrobat-features.toml | 4 +- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index b0d082a366..ce7349e2ad 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -680,12 +680,18 @@ fn same_entries(a: &Dict, b: &Dict, skip: &[&[u8]]) -> bool { /// Object equality where dictionaries (and stream dictionaries) ignore key order; arrays keep it. /// A string is its bytes (ISO 32000-2 §7.3.4.1): written as a literal or as hexadecimal, it is the -/// same string, so a later save that respells one changes nothing it says. References compare by -/// object and generation, and stream data byte for byte as stored. The recursion follows the -/// parsed nesting, which the parser bounds. +/// same string, so a later save that respells one changes nothing it says. Likewise a number is +/// its value (§7.3.3: "wherever a real number is expected, an integer may be used instead"), so a +/// signed `/Rect [20.0 20.0 120.0 40.0]` saved again as `[20 20 120 40]` is the same rectangle. +/// References compare by object and generation, and stream data byte for byte as stored. The +/// recursion follows the parsed nesting, which the parser bounds. fn same_object(a: &Object, b: &Object) -> bool { match (a, b) { (Object::String(a), Object::String(b)) => a.bytes == b.bytes, + // Compared in i128, where every i64 and every whole real near its range is exact (the cast + // saturates far beyond it): no fractional real, NaN or infinity (whose fract is NaN) + // equals an integer, nor does the real 2^63 pass for i64::MAX. + (Object::Int(i), Object::Real(r)) | (Object::Real(r), Object::Int(i)) => r.fract() == 0.0 && *r as i128 == i128::from(*i), (Object::Dict(a), Object::Dict(b)) => same_entries(a, b, &[]), (Object::Stream(a), Object::Stream(b)) => a.raw == b.raw && same_entries(&a.dict, &b.dict, &[]), (Object::Array(a), Object::Array(b)) => a.len() == b.len() && a.iter().zip(b).all(|(v, w)| same_object(v, w)), @@ -1023,6 +1029,55 @@ mod tests { assert_eq!(change_kind(&doc, &doc, &changed, Some(&before)), kind::PAGE_CONTENT); } + /// The page as signed and the same page with its inline resources reordered are each no + /// page change at all, stated as `None` rather than only equal to each other, and so is a page + /// whose numbers and strings are respelled as well (§7.3.3, §7.3.4.1). + #[test] + fn reordered_and_respelled_pages_are_no_page_change() { + let doc = Document::new_empty(); + let order = ["Type", "Subtype", "BaseFont", "Encoding"]; + let mut before = page(vec![("F1", font(&order, "Times-Roman")), ("F2", font(&order, "Helvetica"))], vec![]); + before.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(612.0), Object::Real(792.0)])); + before.set( + b"PieceInfo".to_vec(), + Object::Dict(Dict::from_iter([(b"Note".to_vec(), Object::String(pdfcraft_cos::PdfString::literal("signed")))])), + ); + let mut reordered = page( + vec![ + ("F2", font(&["Encoding", "BaseFont", "Type", "Subtype"], "Helvetica")), + ("F1", font(&["BaseFont", "Subtype", "Encoding", "Type"], "Times-Roman")), + ], + vec![], + ); + reordered.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Real(0.0), Object::Int(0), Object::Int(612), Object::Int(792)])); + reordered.set( + b"PieceInfo".to_vec(), + Object::Dict(Dict::from_iter([(b"Note".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"signed".to_vec(), hex: true }))])), + ); + assert_eq!(page_change_kind(&doc, &doc, &before, Some(&before)), None); + assert_eq!(page_change_kind(&doc, &doc, &reordered, Some(&before)), None); + assert_eq!(page_change_kind(&doc, &doc, &before, Some(&reordered)), None); + + // A real that is not the integer it replaced is a moved box. + let mut moved = reordered.clone(); + moved.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(612.5), Object::Int(792)])); + assert_eq!(page_change_kind(&doc, &doc, &moved, Some(&before)), Some(kind::PAGE_CONTENT)); + } + + /// Numbers compare by value across integer and real, and only when the values are equal. + #[test] + fn integers_and_reals_compare_by_value() { + let same = |a: Object, b: Object| same_object(&a, &b) && same_object(&b, &a); + assert!(same(Object::Int(20), Object::Real(20.0))); + assert!(same(Object::Int(0), Object::Real(-0.0))); + assert!(same(Object::Int(-7), Object::Real(-7.0))); + assert!(!same(Object::Int(20), Object::Real(20.5))); + assert!(!same(Object::Int(20), Object::Real(f64::NAN))); + assert!(!same(Object::Int(i64::MAX), Object::Real(f64::INFINITY))); + assert!(!same(Object::Int(i64::MAX), Object::Real(9_223_372_036_854_775_808.0))); + assert!(!same(Object::Int(1), Object::Bool(true))); + } + #[test] fn a_swapped_nested_key_is_page_content_both_ways() { let doc = Document::new_empty(); diff --git a/crates/sign/src/pdf/revision_classification_tests.rs b/crates/sign/src/pdf/revision_classification_tests.rs index 3b63953a0d..ec95d1323b 100644 --- a/crates/sign/src/pdf/revision_classification_tests.rs +++ b/crates/sign/src/pdf/revision_classification_tests.rs @@ -359,3 +359,41 @@ fn a_signature_added_with_respelled_strings_is_signing() { let restyled = now("<656E2D5553>", "<2F48656C7620313220546620302067>"); assert_eq!(verdict(&restyled.open(), signed.open(), None), Modification::Allowed(vec![super::kind::FORM_FILL, super::kind::SIGNATURE])); } + +/// A signed widget whose /Rect was written with reals, filled and saved by PdfCraft's own writer, +/// which writes a whole real as an integer: the rectangle is the same numbers (ISO 32000-2 +/// §7.3.3, "wherever a real number is expected, an integer may be used instead"), so the save is a +/// form fill. A rectangle whose numbers did change is still a moved widget. +#[test] +fn a_widget_rect_saved_back_as_integers_is_a_form_fill() { + let field = "<< /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Ann) /Rect [20.0 20.0 120.0 40.0] /P 3 0 R >>"; + let signed = File::new().revision(&[ + (1, Row::Obj(0, 0, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>")), + (2, Row::Obj(0, 0, PAGES)), + (3, Row::Obj(0, 0, &page("/Annots [5 0 R]"))), + (4, Row::Obj(0, 0, &page("/Annots []"))), + (5, Row::Obj(0, 0, field)), + ]); + let save = |rect: Option| { + let mut doc = signed.open(); + doc.update_dict(r(5, 0), |w| { + w.set(b"V".to_vec(), Object::String(pdfcraft_cos::PdfString::literal("Bob"))); + if let Some(x) = rect { + w.set(b"Rect".to_vec(), Object::Array(vec![Object::Real(x), Object::Real(20.0), Object::Real(120.0), Object::Real(40.0)])); + } + }) + .unwrap(); + let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + Document::open(Arc::new(bytes)).unwrap() + }; + let filled = save(None); + let rect = filled.resolve(&Object::Ref(r(5, 0))).as_dict().and_then(|w| w.get(b"Rect").cloned()); + assert_eq!( + rect, + Some(Object::Array(vec![Object::Int(20), Object::Int(20), Object::Int(120), Object::Int(40)])), + "the writer respells whole reals" + ); + assert_eq!(verdict(&filled, signed.open(), Some(2)), Modification::Allowed(vec![super::kind::FORM_FILL])); + let moved = save(Some(20.5)); + assert_eq!(verdict(&moved, signed.open(), Some(2)), Modification::Disallowed(vec![super::kind::PAGE_CONTENT])); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0bb7b7cf5d..58f49d3449 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6535,8 +6535,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill", "crates/sign/tests/it/pdf.rs::the_catalogs_perms_do_not_change_under_no_changes", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_respelled_in_hex_is_no_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_whose_bytes_change_is_still_a_change"] -notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision. Under P=1 a change to the catalog's /Perms breaks the certification. Signed objects are compared by value: a string is its bytes, so one a later save rewrites from literal to hexadecimal (or back) is unchanged (ISO 32000-2 §7.3.4.1)." +evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill", "crates/sign/tests/it/pdf.rs::the_catalogs_perms_do_not_change_under_no_changes", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_respelled_in_hex_is_no_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_whose_bytes_change_is_still_a_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_widget_rect_saved_back_as_integers_is_a_form_fill"] +notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision. Under P=1 a change to the catalog's /Perms breaks the certification. Signed objects are compared by value: a string is its bytes, so one a later save rewrites from literal to hexadecimal (or back) is unchanged (ISO 32000-2 §7.3.4.1), and a number is its value, so a signed widget /Rect or page /MediaBox of whole reals saved back as integers is unchanged (§7.3.3)." [[feature]] id = "sign.view-signed-version"