mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
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:
1 parent
715c310f5b
commit
44428b36d3
3 files changed
+98
-5
No files matched your search
@@ -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]));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user