M9: a signed number saved back as an integer is no change after signing (sign.changes-after-signing)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-07 12:46:10 +08:00
1 parent 715c310f5b
commit 44428b36d3
3 files changed
+98 -5

No files matched your search

+58 -3
View File
@@ -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();
@@ -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<f64>| {
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]));
}
+2 -2
View File
@@ -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"