mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-07 13:22:42 +00:00
sign: classify changes by signed object role
Judge each object changed after signing by the role it held in the signed revision, keyed by object number, and compare a slot's accepted generations across revisions. Adds `Document::accepted_generations` to cos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9f04acccef
commit
ce3857c2e5
@@ -62,7 +62,9 @@ another. Edits never touch the original bytes.
|
||||
not expand compact xref ranges; the winning xref entry remains authoritative for free
|
||||
slots. Compressed objects require generation zero. Explicit `set` overlays accept only the
|
||||
supplied generation; `set_existing` and `update_dict` retain both accepted generations of a
|
||||
damaged live slot, including through snapshots, and require full rewrite. Headers beyond
|
||||
damaged live slot, including through snapshots, and require full rewrite.
|
||||
`accepted_generations(num)` lists the generations a slot accepts, so code comparing two
|
||||
revisions can tell whether a reference still reaches the same slot. Headers beyond
|
||||
this bounded probe are not additionally recovered. Materialized primary and hybrid rows are
|
||||
diagnosed before folding, so a newer row does not hide a retained disagreement.
|
||||
- **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing
|
||||
|
||||
@@ -175,6 +175,30 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The generations a reference to `num` may carry and still resolve (see [`Self::get`]),
|
||||
/// sorted: none for a free or unknown slot, else the slot's generation, and for a damaged
|
||||
/// file slot (or a repair-aware edit of one) also the generation its object header carries.
|
||||
/// Code comparing revisions (signature validation) uses it to tell whether the references
|
||||
/// that reached an object in one revision still reach the same slot in another.
|
||||
pub fn accepted_generations(&self, num: u32) -> Vec<u16> {
|
||||
// Mirrors `reference_matches`, which stays allocation-free for every lookup.
|
||||
let mut generations = match self.overlay.get(&num) {
|
||||
Some(Slot::Set(generation, _)) => vec![*generation],
|
||||
Some(Slot::RepairedSet(generation, alias, _)) => vec![*generation, *alias],
|
||||
Some(Slot::Freed(_)) => Vec::new(),
|
||||
None => match self.entries.lookup(num, &self.data) {
|
||||
Some(XrefEntry::InFile { offset, generation }) => {
|
||||
std::iter::once(generation).chain(self.indexed_header_reference(num, offset).map(|h| h.generation)).collect()
|
||||
}
|
||||
Some(XrefEntry::InStream { .. }) => vec![0],
|
||||
None | Some(XrefEntry::Free { .. }) => Vec::new(),
|
||||
},
|
||||
};
|
||||
generations.sort_unstable();
|
||||
generations.dedup();
|
||||
generations
|
||||
}
|
||||
|
||||
pub(super) fn try_get_reference(&self, r: ObjRef) -> Result<Arc<Object>, CosError> {
|
||||
if !self.reference_matches(r) {
|
||||
return Ok(Arc::new(Object::Null));
|
||||
|
||||
@@ -473,3 +473,38 @@ fn restoring_explicit_sets_removes_old_generation_aliases() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `accepted_generations` lists exactly the generations `get` resolves, for every kind of slot:
|
||||
/// a clean file slot, a damaged one (row and header disagree), a repair-aware edit, a plain
|
||||
/// edit, a freed slot, a free row, an object stream member and an unknown number.
|
||||
#[test]
|
||||
fn accepted_generations_are_the_generations_get_resolves() {
|
||||
let agrees = |doc: &Document, num: u32, expected: &[u16]| {
|
||||
assert_eq!(doc.accepted_generations(num), expected, "object {num}");
|
||||
for generation in 0..8 {
|
||||
let live = !matches!(*doc.get(ObjRef::new(num, generation)), Object::Null);
|
||||
assert_eq!(live, expected.contains(&generation), "object {num} generation {generation}");
|
||||
}
|
||||
};
|
||||
let doc = Document::open(contradictory_entry(false)).unwrap();
|
||||
agrees(&doc, 1, &[0]);
|
||||
agrees(&doc, 3, &[0, 1]);
|
||||
agrees(&doc, 9, &[]);
|
||||
let mut repaired = doc.clone();
|
||||
repaired.set_existing(ObjRef::new(3, 0), Object::Int(77)).unwrap();
|
||||
agrees(&repaired, 3, &[0, 1]);
|
||||
let mut narrowed = doc.clone();
|
||||
narrowed.set(ObjRef::new(3, 2), Object::Int(99));
|
||||
agrees(&narrowed, 3, &[2]);
|
||||
let mut freed = doc.clone();
|
||||
freed.free(ObjRef::new(3, 0));
|
||||
agrees(&freed, 3, &[]);
|
||||
agrees(&Document::open(contradictory_entry(true)).unwrap(), 3, &[]);
|
||||
let mut doc =
|
||||
Document::open(object_file(&[(1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>")])).unwrap();
|
||||
let member = doc.add(Object::Dict(Default::default()));
|
||||
doc.update_dict(ObjRef::new(1, 0), |c| c.set(b"Extra".to_vec(), Object::Ref(member))).unwrap();
|
||||
let packed = Document::open(Arc::new(write_full(&doc, &SaveOptions { object_streams: true, ..SaveOptions::default() }).unwrap())).unwrap();
|
||||
assert!(matches!(packed.xref_entry(member.num), Some(XrefEntry::InStream { .. })), "the member is in an object stream");
|
||||
agrees(&packed, member.num, &[0]);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ use validate::{DssEvidence, MAX_DSS_BYTES, MAX_DSS_ENTRIES};
|
||||
#[cfg(test)]
|
||||
#[path = "pdf_discovery_tests.rs"]
|
||||
mod discovery_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "pdf/revision_classification_tests.rs"]
|
||||
mod revision_classification_tests;
|
||||
|
||||
/// One signature field.
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
+105
-63
@@ -7,7 +7,11 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
|
||||
let Some(old) = old else {
|
||||
return Modification::Disallowed(vec![kind::UNREADABLE]);
|
||||
};
|
||||
let old_content: HashSet<ObjRef> = pdfcraft_annot::page_refs(&old)
|
||||
// Every role below is keyed by object number, not by the reference as written: a damaged
|
||||
// file's slot accepts two generations (#1143), and a page signed as `3 5 R` and edited as
|
||||
// `3 0 R` is still that page. The loop over changed objects refuses any slot whose accepted
|
||||
// generations changed, so a number names the same slot in both revisions.
|
||||
let old_content: HashSet<u32> = pdfcraft_annot::page_refs(&old)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.flat_map(|pg| {
|
||||
@@ -21,24 +25,34 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
|
||||
_ => Vec::new(),
|
||||
}
|
||||
})
|
||||
.map(|r| r.num)
|
||||
.collect();
|
||||
// The signed revision's document-level XMP stream.
|
||||
let old_metadata = old.root().and_then(|root| old.get(root).as_dict().and_then(|c| c.get(b"Metadata").and_then(Object::as_ref)));
|
||||
let old_metadata = old.root().and_then(|root| old.get(root).as_dict().and_then(|c| c.get(b"Metadata").and_then(Object::as_ref))).map(|r| r.num);
|
||||
// The signed revision's XFA data packets: filling the form rewrites them.
|
||||
let old_xfa_data = xfa_data_packets(&old);
|
||||
// What each object was in the signed revision: its pages, and its trailer /Info. A changed
|
||||
// object is judged by the role it held when signed, never by the labels or keys its new,
|
||||
// attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its
|
||||
// /Type and adding /ModDate, must not reclassify it away from page content).
|
||||
let signed_pages: HashSet<ObjRef> = pdfcraft_annot::page_refs(&old).unwrap_or_default().into_iter().collect();
|
||||
let signed_page_refs = pdfcraft_annot::page_refs(&old).unwrap_or_default();
|
||||
let signed_pages: HashSet<u32> = signed_page_refs.iter().map(|r| r.num).collect();
|
||||
// The signed pages' /Annots arrays that are objects of their own. Adding an annotation updates
|
||||
// such an array in place (the page dictionary does not change), so it is judged like a
|
||||
// page's inline /Annots: by the annotations it gained.
|
||||
let signed_annots: HashSet<ObjRef> =
|
||||
signed_pages.iter().filter_map(|pg| old.get(*pg).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref))).collect();
|
||||
let signed_annots: HashSet<u32> = signed_page_refs
|
||||
.iter()
|
||||
.filter_map(|pg| old.get(*pg).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref)))
|
||||
.map(|r| r.num)
|
||||
.collect();
|
||||
// The annotations the signed pages listed. One of them listed again elsewhere was moved or
|
||||
// copied there, not added.
|
||||
let placed_annots: HashSet<u32> =
|
||||
signed_page_refs.iter().flat_map(|pg| refs(&old, old.get(*pg).as_dict().and_then(|d| d.get(b"Annots")))).map(|r| r.num).collect();
|
||||
// What each signed page draws from, by the role it held when signed (#1787).
|
||||
let signed_resources = signed_page_resources(&old, &signed_pages);
|
||||
let old_info = old.trailer().get(b"Info").and_then(Object::as_ref);
|
||||
let signed_resources = signed_page_resources(&old, &signed_page_refs);
|
||||
let old_info = old.trailer().get(b"Info").and_then(Object::as_ref).map(|r| r.num);
|
||||
let signed_role = |num: u32| old_content.contains(&num) || signed_pages.contains(&num) || signed_resources.contains_key(&num);
|
||||
let (mut allowed, mut disallowed): (Vec<&'static str>, Vec<&'static str>) = (Vec::new(), Vec::new());
|
||||
let dss = dss_parts(doc, &old);
|
||||
// Stored at the same place in both (and not edited since): the same bytes, unchanged.
|
||||
@@ -61,8 +75,7 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
|
||||
// while every comparison here finds the signed object untouched. No writer has a reason to
|
||||
// add such an entry after signing: it is never a permitted change.
|
||||
for (_, num) in doc.xref_oddities().iter().filter(|o| !old.xref_oddities().contains(o)) {
|
||||
let r = ObjRef { num: *num, generation: doc.generation(*num) };
|
||||
let what = if old_content.contains(&r) || signed_pages.contains(&r) { kind::PAGE_CONTENT } else { kind::OTHER };
|
||||
let what = if old_content.contains(num) || signed_pages.contains(num) { kind::PAGE_CONTENT } else { kind::OTHER };
|
||||
if !disallowed.contains(&what) {
|
||||
disallowed.push(what);
|
||||
}
|
||||
@@ -80,6 +93,7 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
|
||||
old_xfa_data: &old_xfa_data,
|
||||
signed_pages: &signed_pages,
|
||||
signed_annots: &signed_annots,
|
||||
placed_annots: &placed_annots,
|
||||
signed_resources: &signed_resources,
|
||||
old_info,
|
||||
dss: &dss,
|
||||
@@ -88,6 +102,19 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
|
||||
if same_place(num) {
|
||||
continue;
|
||||
}
|
||||
// The signed revision's references reached this slot by the generations it accepted. If
|
||||
// it accepts others now, those references may reach nothing here while another reader
|
||||
// follows them back through /Prev to an intermediate revision, or to another alias, and
|
||||
// shows what that holds: the object compared below is not provably what is displayed.
|
||||
// No writer changes a signed object's generation.
|
||||
let signed_generations = old.accepted_generations(num);
|
||||
if !signed_generations.is_empty() && signed_generations != doc.accepted_generations(num) {
|
||||
let what = if signed_role(num) { kind::PAGE_CONTENT } else { kind::OTHER };
|
||||
if !disallowed.contains(&what) {
|
||||
disallowed.push(what);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let r = ObjRef { num, generation: doc.generation(num) };
|
||||
let new = doc.get(r);
|
||||
let before = old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null));
|
||||
@@ -124,9 +151,8 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
|
||||
// rewritten by every save and is not a change.
|
||||
let current: HashSet<u32> = doc.object_numbers().into_iter().collect();
|
||||
for num in old.object_numbers().into_iter().filter(|n| !current.contains(n)) {
|
||||
let r = ObjRef { num, generation: old.generation(num) };
|
||||
let was = old.get(r);
|
||||
let signed_role = old_content.contains(&r) || signed_pages.contains(&r) || signed_resources.contains_key(&r);
|
||||
let was = old.get(ObjRef { num, generation: old.generation(num) });
|
||||
let signed_role = signed_role(num);
|
||||
// A signed page's content or resource is judged by that role, whatever /Type its stream carried.
|
||||
if !signed_role && matches!(&*was, Object::Stream(s) if matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))) {
|
||||
continue;
|
||||
@@ -142,13 +168,14 @@ pub(super) fn classify_changes(doc: &Document, old: Option<Document>, p: Option<
|
||||
struct ChangedObjectClassifier<'a> {
|
||||
doc: &'a Document,
|
||||
old: &'a Document,
|
||||
old_content: &'a HashSet<ObjRef>,
|
||||
old_metadata: Option<ObjRef>,
|
||||
old_xfa_data: &'a HashSet<ObjRef>,
|
||||
signed_pages: &'a HashSet<ObjRef>,
|
||||
signed_annots: &'a HashSet<ObjRef>,
|
||||
signed_resources: &'a HashMap<ObjRef, bool>,
|
||||
old_info: Option<ObjRef>,
|
||||
old_content: &'a HashSet<u32>,
|
||||
old_metadata: Option<u32>,
|
||||
old_xfa_data: &'a HashSet<u32>,
|
||||
signed_pages: &'a HashSet<u32>,
|
||||
signed_annots: &'a HashSet<u32>,
|
||||
placed_annots: &'a HashSet<u32>,
|
||||
signed_resources: &'a HashMap<u32, bool>,
|
||||
old_info: Option<u32>,
|
||||
dss: &'a HashSet<u32>,
|
||||
}
|
||||
|
||||
@@ -170,7 +197,7 @@ impl ChangedObjectClassifier<'_> {
|
||||
}
|
||||
|
||||
fn resource_kind(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> {
|
||||
match (self.signed_resources.get(&r), new) {
|
||||
match (self.signed_resources.get(&r.num), new) {
|
||||
(Some(false), _) => Some(kind::PAGE_CONTENT),
|
||||
(Some(true), Object::Dict(dict)) if self.keeps_previous_entries(dict, before) => None,
|
||||
(Some(true), _) => Some(kind::PAGE_CONTENT),
|
||||
@@ -183,32 +210,40 @@ impl ChangedObjectClassifier<'_> {
|
||||
}
|
||||
|
||||
fn is_signed_annotation_array(&self, r: ObjRef, before: Option<&Object>) -> bool {
|
||||
self.signed_annots.contains(&r) && before.is_some_and(|object| object.as_array().is_some())
|
||||
self.signed_annots.contains(&r.num) && before.is_some_and(|object| object.as_array().is_some())
|
||||
}
|
||||
|
||||
fn annotation_array_change(&self, now: &[Object], before: Option<&Object>) -> &'static str {
|
||||
let was: HashSet<ObjRef> =
|
||||
before.and_then(Object::as_array).map(|items| items.iter().filter_map(Object::as_ref).collect()).unwrap_or_default();
|
||||
let kept: HashSet<ObjRef> = now.iter().filter_map(Object::as_ref).collect();
|
||||
let removed: Vec<ObjRef> = was.iter().filter(|reference| !kept.contains(reference)).copied().collect();
|
||||
if removed.is_empty() {
|
||||
let added: Vec<ObjRef> = now.iter().filter_map(Object::as_ref).filter(|reference| !was.contains(reference)).collect();
|
||||
added_annots(self.doc, &added)
|
||||
} else if removed.iter().any(|reference| self.old.get(*reference).as_dict().is_some_and(|dict| dict.name(b"Subtype") == Some(b"Widget"))) {
|
||||
// A form field or signature widget taken off the page: no permission covers it.
|
||||
let was: Vec<ObjRef> = before.and_then(Object::as_array).map(|items| items.iter().filter_map(Object::as_ref).collect()).unwrap_or_default();
|
||||
self.annots_change(&was, &now.iter().filter_map(Object::as_ref).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// A signed page's annotations going from `was` to `now`, compared by object number.
|
||||
fn annots_change(&self, was: &[ObjRef], now: &[ObjRef]) -> &'static str {
|
||||
let had: HashSet<u32> = was.iter().map(|r| r.num).collect();
|
||||
let kept: HashSet<u32> = now.iter().map(|r| r.num).collect();
|
||||
let removed: Vec<u32> = had.iter().filter(|num| !kept.contains(num)).copied().collect();
|
||||
let added: Vec<ObjRef> = now.iter().filter(|r| !had.contains(&r.num)).copied().collect();
|
||||
let was_widget = |num: u32| self.old.try_get(num).ok().is_some_and(|o| o.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget")));
|
||||
// A signed annotation listed on another page was moved or copied there.
|
||||
let moved: Vec<u32> = added.iter().map(|r| r.num).filter(|num| self.placed_annots.contains(num)).collect();
|
||||
if removed.iter().chain(&moved).any(|num| was_widget(*num)) {
|
||||
// A form field or signature widget taken off its page, or onto another: no permission covers it.
|
||||
kind::OTHER
|
||||
} else {
|
||||
// Deleting comments is a comment change (permitted at P 3 only).
|
||||
} else if !removed.is_empty() || !moved.is_empty() {
|
||||
// Deleting or moving comments is a comment change (permitted at P 3 only).
|
||||
kind::COMMENTS
|
||||
} else {
|
||||
added_annots(self.doc, &added)
|
||||
}
|
||||
}
|
||||
|
||||
fn dictionary_change(&self, r: ObjRef, dict: &Dict, before: Option<&Object>) -> &'static str {
|
||||
let previous = before.and_then(Object::as_dict);
|
||||
if self.signed_pages.contains(&r) {
|
||||
if self.signed_pages.contains(&r.num) {
|
||||
// A signed page is judged by what the page shows, whatever its new labels say.
|
||||
page_change_kind(self.doc, self.old, dict, previous)
|
||||
} else if previous.is_some() && Some(r) == self.old_info {
|
||||
self.page_change_kind(dict, previous)
|
||||
} else if previous.is_some() && Some(r.num) == self.old_info {
|
||||
// Only the signed revision's own /Info dictionary is a metadata change.
|
||||
kind::METADATA
|
||||
} else {
|
||||
@@ -216,17 +251,29 @@ impl ChangedObjectClassifier<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A changed object that was a page in the signed revision, judged by what the page shows
|
||||
/// rather than by any `/Type` its new version carries. Only its annotations may change (see
|
||||
/// [`Self::annots_change`]); any other difference (content, resources, a relabelled or dropped
|
||||
/// `/Type`) is a page content change.
|
||||
fn page_change_kind(&self, d: &Dict, before: Option<&Dict>) -> &'static str {
|
||||
match before {
|
||||
Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT,
|
||||
Some(b) => self.annots_change(&refs(self.old, b.get(b"Annots")), &refs(self.doc, d.get(b"Annots"))),
|
||||
None => kind::PAGE_CONTENT,
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_change(&self, r: ObjRef, stream: &pdfcraft_cos::Stream, before: Option<&Object>) -> Option<&'static str> {
|
||||
// File plumbing is the stream's role when signed; a later /Type label cannot change it.
|
||||
let was_plumbing = matches!(before, Some(Object::Stream(previous)) if matches!(previous.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")));
|
||||
if self.old_content.contains(&r) {
|
||||
if self.old_content.contains(&r.num) {
|
||||
Some(kind::PAGE_CONTENT)
|
||||
} else if before.is_none() || was_plumbing {
|
||||
None
|
||||
} else if stream.dict.name(b"Type") == Some(b"Metadata") && Some(r) == self.old_metadata {
|
||||
} else if stream.dict.name(b"Type") == Some(b"Metadata") && Some(r.num) == self.old_metadata {
|
||||
// Only the signed catalog's XMP stream is metadata.
|
||||
Some(kind::METADATA)
|
||||
} else if self.old_xfa_data.contains(&r) {
|
||||
} else if self.old_xfa_data.contains(&r.num) {
|
||||
// Signed XFA data and state packets are changed by a form fill.
|
||||
Some(kind::FORM_FILL)
|
||||
} else {
|
||||
@@ -238,7 +285,7 @@ impl ChangedObjectClassifier<'_> {
|
||||
/// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form`
|
||||
/// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one
|
||||
/// XFA stream holds its template too, and is not listed.
|
||||
fn xfa_data_packets(doc: &Document) -> HashSet<ObjRef> {
|
||||
fn xfa_data_packets(doc: &Document) -> HashSet<u32> {
|
||||
let Some(form) = acroform(doc) else { return HashSet::new() };
|
||||
let Some(xfa) = form.get(b"XFA").map(|x| doc.resolve(x)) else { return HashSet::new() };
|
||||
let Object::Array(xfa) = &*xfa else { return HashSet::new() };
|
||||
@@ -247,6 +294,7 @@ fn xfa_data_packets(doc: &Document) -> HashSet<ObjRef> {
|
||||
.iter()
|
||||
.filter(|[name, _]| doc.resolve(name).as_string().is_some_and(|s| matches!(s.to_text().as_str(), "datasets" | "form")))
|
||||
.filter_map(|[_, packet]| packet.as_ref())
|
||||
.map(|r| r.num)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -442,23 +490,24 @@ fn is_signature_dict(d: &Dict) -> bool {
|
||||
/// properties), where any change changes what the page shows. Resources are the page's own or
|
||||
/// inherited. Bounded by depth and by the number of objects visited; past the bound the rest is
|
||||
/// judged as before.
|
||||
fn signed_page_resources(old: &Document, pages: &HashSet<ObjRef>) -> HashMap<ObjRef, bool> {
|
||||
fn signed_page_resources(old: &Document, pages: &[ObjRef]) -> HashMap<u32, bool> {
|
||||
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
const MAX_VISITED: usize = 50_000;
|
||||
const MAX_DEPTH: u8 = 32;
|
||||
const MAX_INHERITANCE: usize = 64;
|
||||
|
||||
/// Everything reachable from `o` is content the page draws (a leaf role, overriding a
|
||||
/// container one reached by another path). A signed page or the catalog reached this way
|
||||
/// (a `/P` or `/Parent` back-reference) keeps the role of its own: the walk stops there.
|
||||
fn reach(old: &Document, o: &Object, depth: u8, stop: &HashSet<ObjRef>, roles: &mut HashMap<ObjRef, bool>) {
|
||||
/// container one reached by another path, under any generation alias). A signed page or the
|
||||
/// catalog reached this way (a `/P` or `/Parent` back-reference) keeps the role of its own:
|
||||
/// the walk stops there.
|
||||
fn reach(old: &Document, o: &Object, depth: u8, stop: &HashSet<u32>, roles: &mut HashMap<u32, bool>) {
|
||||
if roles.len() >= MAX_VISITED || depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let resolved;
|
||||
let o = match o {
|
||||
Object::Ref(r) => {
|
||||
if stop.contains(r) || roles.insert(*r, false).is_some() {
|
||||
if stop.contains(&r.num) || roles.insert(r.num, false).is_some() {
|
||||
return;
|
||||
}
|
||||
resolved = old.get(*r);
|
||||
@@ -490,14 +539,14 @@ fn signed_page_resources(old: &Document, pages: &HashSet<ObjRef>) -> HashMap<Obj
|
||||
}
|
||||
|
||||
let mut roles = HashMap::new();
|
||||
let mut stop = pages.clone();
|
||||
stop.extend(old.root());
|
||||
let mut stop: HashSet<u32> = pages.iter().map(|r| r.num).collect();
|
||||
stop.extend(old.root().map(|r| r.num));
|
||||
for page in pages {
|
||||
let Some(res) = resources(old, *page) else { continue };
|
||||
let container = |o: &Object, roles: &mut HashMap<ObjRef, bool>| -> Arc<Object> {
|
||||
let container = |o: &Object, roles: &mut HashMap<u32, bool>| -> Arc<Object> {
|
||||
match o {
|
||||
Object::Ref(r) => {
|
||||
roles.entry(*r).or_insert(true);
|
||||
roles.entry(r.num).or_insert(true);
|
||||
old.get(*r)
|
||||
}
|
||||
other => Arc::new(other.clone()),
|
||||
@@ -516,21 +565,6 @@ fn signed_page_resources(old: &Document, pages: &HashSet<ObjRef>) -> HashMap<Obj
|
||||
roles
|
||||
}
|
||||
|
||||
/// A changed object that was a page in the signed revision, judged by what the page shows rather
|
||||
/// than by any `/Type` its new version carries. Only added annotations are permitted; any other
|
||||
/// difference (content, resources, a relabelled or dropped `/Type`) is a page content change.
|
||||
fn page_change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str {
|
||||
match before {
|
||||
Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT,
|
||||
Some(b) => {
|
||||
let was = refs(old, b.get(b"Annots"));
|
||||
let added: Vec<ObjRef> = refs(doc, d.get(b"Annots")).into_iter().filter(|r| !was.contains(r)).collect();
|
||||
added_annots(doc, &added)
|
||||
}
|
||||
None => kind::PAGE_CONTENT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the catalog `d` keeps every developer extension the signed catalog `b` declared, each
|
||||
/// with the same value: it may only declare more.
|
||||
fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool {
|
||||
@@ -557,6 +591,14 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>)
|
||||
{
|
||||
return kind::PAGE_CONTENT;
|
||||
}
|
||||
// A signed form field or widget keeps that role: its value, appearance or signature may
|
||||
// change, but turning it into a comment, another kind of annotation or another type of
|
||||
// field is no form fill.
|
||||
if before.is_some_and(|b| {
|
||||
(b.name(b"Subtype") == Some(b"Widget") || b.contains(b"FT")) && (d.name(b"Subtype") != b.name(b"Subtype") || d.name(b"FT") != b.name(b"FT"))
|
||||
}) {
|
||||
return kind::OTHER;
|
||||
}
|
||||
match ty {
|
||||
// Only a new signature value or policy: rewriting a signed one is not a signature (#1788).
|
||||
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Changes made after signing are judged against what a reader resolves and by the role each
|
||||
//! object held when signed: a newer generation, a repaired generation alias, or a widget
|
||||
//! replaced, removed or moved never passes as a permitted change.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object};
|
||||
|
||||
use super::Modification;
|
||||
use super::changes::classify_changes;
|
||||
|
||||
/// One cross-reference row: an object written with header generation `.0` under row generation `.1`.
|
||||
enum Row<'a> {
|
||||
Obj(u16, u16, &'a str),
|
||||
}
|
||||
|
||||
/// A classic-xref file built one revision at a time; each later revision points back with /Prev.
|
||||
#[derive(Clone)]
|
||||
struct File {
|
||||
bytes: Vec<u8>,
|
||||
size: u32,
|
||||
xref: Option<usize>,
|
||||
}
|
||||
|
||||
impl File {
|
||||
fn new() -> Self {
|
||||
File { bytes: b"%PDF-1.7\n".to_vec(), size: 1, xref: None }
|
||||
}
|
||||
|
||||
fn revision(mut self, rows: &[(u32, Row<'_>)]) -> Self {
|
||||
let mut table = Vec::new();
|
||||
for (num, row) in rows {
|
||||
self.size = self.size.max(num + 1);
|
||||
let Row::Obj(header, generation, body) = row;
|
||||
table.push(format!("{num} 1\n{:010} {generation:05} n \n", self.bytes.len()));
|
||||
self.bytes.extend_from_slice(format!("{num} {header} obj\n{body}\nendobj\n").as_bytes());
|
||||
}
|
||||
let at = self.bytes.len();
|
||||
self.bytes.extend_from_slice(b"xref\n");
|
||||
if self.xref.is_none() {
|
||||
self.bytes.extend_from_slice(b"0 1\n0000000000 65535 f \n");
|
||||
}
|
||||
self.bytes.extend_from_slice(table.concat().as_bytes());
|
||||
let prev = self.xref.map(|p| format!(" /Prev {p}")).unwrap_or_default();
|
||||
self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R{prev} >>\nstartxref\n{at}\n%%EOF\n", self.size).as_bytes());
|
||||
self.xref = Some(at);
|
||||
self
|
||||
}
|
||||
|
||||
fn open(&self) -> Document {
|
||||
Document::open(Arc::new(self.bytes.clone())).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>";
|
||||
const TEXT_FIELD: &str = "<< /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Ann) /Rect [20 20 120 40] /P 3 0 R >>";
|
||||
const COMMENT: &str = "<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (note) >>";
|
||||
|
||||
fn page(extra: &str) -> String {
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] {extra} >>")
|
||||
}
|
||||
|
||||
fn r(num: u32, generation: u16) -> ObjRef {
|
||||
ObjRef { num, generation }
|
||||
}
|
||||
|
||||
fn assert_refused(doc: &Document, old: Document, p: u8) {
|
||||
let verdict = classify_changes(doc, Some(old), Some(p));
|
||||
assert!(matches!(verdict, Modification::Disallowed(_)), "P={p}: main returns {verdict:?}");
|
||||
}
|
||||
|
||||
/// Two pages and a text field on page 3, listed in /Fields and in the page's inline /Annots.
|
||||
fn form(page3: &str) -> File {
|
||||
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(page3))),
|
||||
(4, Row::Obj(0, 0, &page("/Annots []"))),
|
||||
(5, Row::Obj(0, 0, TEXT_FIELD)),
|
||||
(6, Row::Obj(0, 0, "[5 0 R]")),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_stream_intermediate_generation_cover() {
|
||||
let signed = File::new().revision(&[
|
||||
(1, Row::Obj(0, 0, "<< /Type /Catalog /Pages 2 0 R >>")),
|
||||
(2, Row::Obj(0, 0, PAGES)),
|
||||
(3, Row::Obj(0, 0, &page(""))),
|
||||
(4, Row::Obj(0, 0, &page("/Contents 5 0 R"))),
|
||||
(5, Row::Obj(0, 0, "<< /Length 3 >>\nstream\nq Q\nendstream")),
|
||||
]);
|
||||
let now = signed
|
||||
.clone()
|
||||
.revision(&[(5, Row::Obj(0, 0, "<< /Length 3 >>\nstream\nQ q\nendstream"))])
|
||||
.revision(&[(5, Row::Obj(1, 1, "<< /Length 3 >>\nstream\nq Q\nendstream"))]);
|
||||
let (doc, old) = (now.open(), signed.open());
|
||||
assert_eq!(doc.revisions().len(), 3);
|
||||
assert!(doc.xref_oddities().is_empty());
|
||||
assert_eq!(doc.get(r(5, 1)), old.get(r(5, 0)), "the newest generation holds the signed bytes");
|
||||
assert_refused(&doc, old, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_removed_from_inline_annots() {
|
||||
let signed = form("/Annots [5 0 R]");
|
||||
let mut doc = signed.open();
|
||||
doc.update_dict(r(3, 0), |p| p.set(b"Annots".to_vec(), Object::Array(Vec::new()))).unwrap();
|
||||
assert_refused(&doc, signed.open(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_moved_between_pages() {
|
||||
let signed = form("/Annots [5 0 R]");
|
||||
let mut doc = signed.open();
|
||||
doc.update_dict(r(3, 0), |p| p.set(b"Annots".to_vec(), Object::Array(Vec::new()))).unwrap();
|
||||
doc.update_dict(r(4, 0), |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(r(5, 0))]))).unwrap();
|
||||
doc.update_dict(r(5, 0), |w| w.set(b"P".to_vec(), Object::Ref(r(4, 0)))).unwrap();
|
||||
assert_refused(&doc, signed.open(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annots_moved_to_new_object_dropping_widget() {
|
||||
let signed = form("/Annots 6 0 R");
|
||||
let mut doc = signed.open();
|
||||
let empty = doc.add(Object::Array(Vec::new()));
|
||||
doc.update_dict(r(3, 0), |p| p.set(b"Annots".to_vec(), Object::Ref(empty))).unwrap();
|
||||
assert_refused(&doc, signed.open(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repaired_page_relabelled_as_field() {
|
||||
let signed = File::new().revision(&[
|
||||
(1, Row::Obj(0, 0, "<< /Type /Catalog /Pages 2 0 R >>")),
|
||||
(2, Row::Obj(0, 0, "<< /Type /Pages /Kids [3 5 R 4 0 R] /Count 2 >>")),
|
||||
(3, Row::Obj(5, 0, &page(""))),
|
||||
(4, Row::Obj(0, 0, &page(""))),
|
||||
]);
|
||||
let mut doc = signed.open();
|
||||
doc.update_dict(r(3, 0), |p| {
|
||||
p.remove(b"Type");
|
||||
p.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
p.set(b"Rotate".to_vec(), Object::Int(180));
|
||||
})
|
||||
.unwrap();
|
||||
assert_refused(&doc, signed.open(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_container_and_drawn_alias() {
|
||||
let signed = File::new().revision(&[
|
||||
(1, Row::Obj(0, 0, "<< /Type /Catalog /Pages 2 0 R >>")),
|
||||
(2, Row::Obj(0, 0, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")),
|
||||
(3, Row::Obj(0, 0, &page("/Contents 6 0 R /Resources << /Font 4 0 R /ExtGState << /GS1 4 5 R >> >>"))),
|
||||
(4, Row::Obj(5, 0, "<< /F1 5 0 R /CA 1 >>")),
|
||||
(5, Row::Obj(0, 0, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")),
|
||||
(6, Row::Obj(0, 0, "<< /Length 3 >>\nstream\nq Q\nendstream")),
|
||||
]);
|
||||
let mut doc = signed.open();
|
||||
doc.update_dict(r(4, 0), |d| d.set(b"FT".to_vec(), Object::name("Tx"))).unwrap();
|
||||
assert_refused(&doc, signed.open(), 2);
|
||||
}
|
||||
|
||||
/// A field signed as `4 5 R` / `4 0 R` (header 5, row 0) that a later revision rewrites.
|
||||
fn aliased_field(fields: &str, annots: &str) -> File {
|
||||
File::new().revision(&[
|
||||
(1, Row::Obj(0, 0, &format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{fields}] >> >>"))),
|
||||
(2, Row::Obj(0, 0, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>")),
|
||||
(3, Row::Obj(0, 0, &page(&format!("/Annots [{annots}]")))),
|
||||
(4, Row::Obj(5, 0, TEXT_FIELD)),
|
||||
])
|
||||
}
|
||||
|
||||
fn set_annots(doc: &mut Document, annot: ObjRef) {
|
||||
doc.update_dict(r(3, 0), |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annot)]))).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repaired_alias_set_changes_widget_to_comment() {
|
||||
let signed = aliased_field("4 0 R", "4 0 R");
|
||||
let now = signed.clone().revision(&[(4, Row::Obj(7, 0, COMMENT))]);
|
||||
let mut doc = now.open();
|
||||
set_annots(&mut doc, r(4, 7));
|
||||
assert_refused(&doc, signed.open(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repaired_slot_becomes_disjoint_generation() {
|
||||
let signed = aliased_field("4 0 R", "4 0 R");
|
||||
let now = signed.clone().revision(&[(4, Row::Obj(7, 7, COMMENT))]);
|
||||
let mut doc = now.open();
|
||||
set_annots(&mut doc, r(4, 7));
|
||||
assert_refused(&doc, signed.open(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_shared_alias_form_fill() {
|
||||
let signed = aliased_field("4 5 R", "4 0 R");
|
||||
let filled = TEXT_FIELD.replace("(Ann)", "(Bob)");
|
||||
let now = signed.clone().revision(&[(4, Row::Obj(5, 1, &filled))]);
|
||||
let mut doc = now.open();
|
||||
set_annots(&mut doc, r(4, 1));
|
||||
// At P=3 a relisted annotation is permitted whatever it is: only the generations refuse it.
|
||||
for p in [2, 3] {
|
||||
assert_refused(&doc, signed.open(), p);
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ mod cms;
|
||||
mod crypto;
|
||||
mod encrypted_signing;
|
||||
mod pdf;
|
||||
mod revision_classification;
|
||||
mod store_keys;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! A certification signature stays valid only while what a reader shows is what was signed:
|
||||
//! revisions appended by hand that reach back to an older generation, or that replace a signed
|
||||
//! form field in place, are refused.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
use pdfcraft_sign::{Modification, SignOptions, Status, TrustStore, pkcs12, signatures};
|
||||
|
||||
fn data(name: &str) -> Vec<u8> {
|
||||
std::fs::read(format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
|
||||
}
|
||||
|
||||
fn open(b: &[u8]) -> Document {
|
||||
Document::open(Arc::new(b.to_vec())).unwrap()
|
||||
}
|
||||
|
||||
/// A classic-xref PDF of `objs`, numbered from 1, with object 1 as the catalog.
|
||||
fn write_pdf(objs: &[&str]) -> Vec<u8> {
|
||||
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
|
||||
}
|
||||
|
||||
/// One row of a hand-written revision: `num header obj body` under row generation `row`, or a
|
||||
/// free row with its next generation.
|
||||
enum Row<'a> {
|
||||
Obj { num: u32, header: u16, row: u16, body: &'a str },
|
||||
Free { num: u32, next: u16 },
|
||||
}
|
||||
|
||||
/// `base` with one more revision of `rows`, written as a classic table pointing back with /Prev.
|
||||
fn append(base: &[u8], rows: &[Row<'_>]) -> Vec<u8> {
|
||||
let doc = open(base);
|
||||
let size = doc.trailer().get(b"Size").and_then(Object::as_int).unwrap();
|
||||
let root = doc.root().unwrap();
|
||||
let tail = String::from_utf8_lossy(&base[base.len().saturating_sub(64)..]).into_owned();
|
||||
let prev: usize = tail.rsplit("startxref").next().unwrap().split_whitespace().next().unwrap().parse().unwrap();
|
||||
let mut out = base.to_vec();
|
||||
let mut table = String::new();
|
||||
for row in rows {
|
||||
match row {
|
||||
Row::Obj { num, header, row, body } => {
|
||||
table.push_str(&format!("{num} 1\n{:010} {row:05} n \n", out.len()));
|
||||
out.extend_from_slice(format!("{num} {header} obj\n{body}\nendobj\n").as_bytes());
|
||||
}
|
||||
Row::Free { num, next } => table.push_str(&format!("{num} 1\n0000000000 {next:05} f \n")),
|
||||
}
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(
|
||||
format!("xref\n{table}trailer\n<< /Size {size} /Root {} {} R /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n", root.num, root.generation)
|
||||
.as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn opts(certify: u8) -> SignOptions {
|
||||
SignOptions {
|
||||
page: 0,
|
||||
rect: Some([20.0, 100.0, 220.0, 150.0]),
|
||||
date: "D:20261002120000+01'00'".into(),
|
||||
certify: Some(certify),
|
||||
..SignOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The certification signature of `bytes`, which must be refused.
|
||||
fn assert_refused(bytes: &[u8]) {
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let s = signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert!(matches!(s.modification, Modification::Disallowed(_)) && s.status != Status::Valid, "main returns {:?}, {:?}", s.status, s.modification);
|
||||
}
|
||||
|
||||
fn certify(unsigned: &[u8], p: u8) -> Vec<u8> {
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
pdfcraft_sign::sign(&open(unsigned), &id, &opts(p)).unwrap()
|
||||
}
|
||||
|
||||
const PAGE2: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 7 0 R /Resources << /Font << /F1 6 0 R >> >> >>";
|
||||
|
||||
/// Revision 2 rotates page 2 (`4 0 obj`); revision 3 writes the signed page back as `4 1 obj`.
|
||||
/// The page tree still says `4 0 R`.
|
||||
fn intermediate_generation_cover(p: u8) {
|
||||
let unsigned = write_pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>",
|
||||
PAGE2,
|
||||
"<< /Length 41 >>\nstream\nBT /F1 14 Tf 20 250 Td (Page one) Tj ET\nendstream",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"<< /Length 41 >>\nstream\nBT /F1 14 Tf 20 250 Td (Page two) Tj ET\nendstream",
|
||||
]);
|
||||
let signed = certify(&unsigned, p);
|
||||
let rotated = PAGE2.replace(" >> >> >>", " >> >> /Rotate 180 >>");
|
||||
let two = append(&signed, &[Row::Obj { num: 4, header: 0, row: 0, body: &rotated }]);
|
||||
let three = append(&two, &[Row::Obj { num: 4, header: 1, row: 1, body: PAGE2 }]);
|
||||
let doc = open(&three);
|
||||
assert!(doc.xref_oddities().is_empty());
|
||||
assert_eq!(doc.revisions().len(), open(&signed).revisions().len() + 2);
|
||||
assert_eq!(doc.get(pdfcraft_cos::ObjRef { num: 4, generation: 1 }), open(&signed).get(pdfcraft_cos::ObjRef { num: 4, generation: 0 }));
|
||||
assert_refused(&three);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intermediate_generation_cover_p1() {
|
||||
intermediate_generation_cover(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intermediate_generation_cover_p2() {
|
||||
intermediate_generation_cover(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intermediate_generation_cover_p3() {
|
||||
intermediate_generation_cover(3);
|
||||
}
|
||||
|
||||
const COMMENT: &str = "<< /Type /Annot /Subtype /Text /Rect [20 200 40 220] /Contents (note) >>";
|
||||
|
||||
/// A page with text field `4 0 R`, listed in /Fields and in the page's inline /Annots, certified
|
||||
/// allowing comments.
|
||||
fn certified_form() -> Vec<u8> {
|
||||
certify(
|
||||
&write_pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Ann) /Rect [20 200 120 220] /P 3 0 R >>",
|
||||
]),
|
||||
3,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_replaced_in_place_by_comment() {
|
||||
let signed = certified_form();
|
||||
assert_refused(&append(&signed, &[Row::Obj { num: 4, header: 0, row: 0, body: COMMENT }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_freed_then_reused_as_comment() {
|
||||
let signed = certified_form();
|
||||
let freed = append(&signed, &[Row::Free { num: 4, next: 1 }]);
|
||||
assert_refused(&append(&freed, &[Row::Obj { num: 4, header: 0, row: 0, body: COMMENT }]));
|
||||
}
|
||||
Reference in New Issue
Block a user