fix(sign): a removed annotation is not hidden behind an added signature

Review follow-up: an indirect /Annots array that drops a signed
annotation while gaining a signature widget was judged by the widget
alone, so a form-fill certification let a signed comment be deleted.
A removed comment is now a comment change, and a removed widget is a
change no permission covers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ffishman
2026-10-22 17:03:37 -03:00
co-authored by Claude Opus 5.5
parent 5856c12eb3
commit c829a1ad44
2 changed files with 38 additions and 3 deletions
+14 -3
View File
@@ -1338,12 +1338,23 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
(Some(k), _) => k,
// The Document Security Store's own arrays and dictionaries, when they only grew.
(_, _) if dss.contains(&num) => kind::SECURITY_STORE,
// A signed page's indirect /Annots array, still an array: what it gained.
// A signed page's indirect /Annots array, still an array: what it lost, then what it
// gained. Dropping a signed annotation is never hidden behind an added signature.
(_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => {
let was: HashSet<ObjRef> =
before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default();
let added: Vec<ObjRef> = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect();
added_annots(doc, &added)
let kept: HashSet<ObjRef> = now.iter().filter_map(Object::as_ref).collect();
let removed: Vec<ObjRef> = was.iter().filter(|r| !kept.contains(r)).copied().collect();
if removed.is_empty() {
let added: Vec<ObjRef> = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect();
added_annots(doc, &added)
} else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) {
// A form field or signature widget taken off the page: no permission covers it.
kind::OTHER
} else {
// Deleting comments is a comment change (permitted at P 3 only).
kind::COMMENTS
}
}
(_, Object::Dict(d)) => {
let was = before.as_deref().and_then(Object::as_dict);
+24
View File
@@ -178,6 +178,30 @@ fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() {
let certified = pdfcraft_sign::sign(&open(&unsigned), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
let s = check(&edit_after(&certified, comment));
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS])));
// Under that certification, a signed comment swapped for a new signature widget is still a
// deleted comment, and a form field taken off the page is permitted by no level.
let commented = pdfcraft_sign::sign(&open(&edit_after(&unsigned, comment)), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap();
let swap = |drop: &dyn Fn(&Document, &Object) -> bool| {
edit_after(&commented, |doc| {
let mut w = pdfcraft_cos::Dict::new();
w.set(b"Type".to_vec(), Object::name("Annot"));
w.set(b"Subtype".to_vec(), Object::name("Widget"));
w.set(b"FT".to_vec(), Object::name("Sig"));
w.set(b"T".to_vec(), PdfString::text("Later"));
w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0); 4]));
let w = doc.add(Object::Dict(w));
let mut a = doc.get(annots).as_array().cloned().unwrap();
a.retain(|o| !drop(doc, o));
a.push(Object::Ref(w));
doc.set(annots, Object::Array(a));
})
};
let subtype =
|doc: &Document, o: &Object, t: &[u8]| o.as_ref().is_some_and(|r| doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(t)));
let s = check(&swap(&|doc, o| subtype(doc, o, b"Text")));
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS])));
let s = check(&swap(&|doc, o| subtype(doc, o, b"Widget") && o.as_ref().is_some_and(|r| r.num == 6)));
assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER])));
}
/// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned: