mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 01:57:12 -06:00
Merge pull request #4244 from dylanmtaylor/fix/high-severity-redact-sign
fix(redact,sign,cos): close the still-open high-severity scan findings
This commit is contained in:
@@ -52,6 +52,12 @@ impl Op {
|
||||
pub fn name(&self, i: usize) -> Option<&[u8]> {
|
||||
self.operands.get(i).and_then(Object::as_name)
|
||||
}
|
||||
|
||||
/// The last operand as a name: the one `Do` and the other one-operand operators act on, since
|
||||
/// the renderer takes operands from the end and ignores any surplus before them.
|
||||
pub fn last_name(&self) -> Option<&[u8]> {
|
||||
self.operands.last().and_then(Object::as_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed stream.
|
||||
|
||||
@@ -178,7 +178,10 @@ fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandl
|
||||
Object::Stream(s) => {
|
||||
let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect();
|
||||
let ty = s.dict.name(b"Type");
|
||||
if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) {
|
||||
// Cross-reference streams are never encrypted (§7.5.8), but only reading may trust the
|
||||
// label: a stream a producer calls /XRef stays plaintext if writing believes it, and the
|
||||
// writer builds its own cross-reference streams outside this transform.
|
||||
if (decrypt && ty == Some(b"XRef")) || (ty == Some(b"Metadata") && !h.encrypts_metadata()) {
|
||||
return Object::Stream(Stream { dict, raw: s.raw.clone() });
|
||||
}
|
||||
let mut named = named_crypt_filter(&s.dict);
|
||||
|
||||
@@ -477,3 +477,20 @@ fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() {
|
||||
assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A content stream that calls itself `/Type /XRef` is still encrypted: only reading trusts the label.
|
||||
#[test]
|
||||
fn a_stream_labelled_xref_is_encrypted_when_saved() {
|
||||
let mut doc = Document::open(Arc::new(fixture_with(" /Type /XRef"))).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: Algorithm::Aes256,
|
||||
user_password: "u",
|
||||
owner_password: "o",
|
||||
permissions: -4,
|
||||
encrypt_metadata: true,
|
||||
seed: [9; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(SECRET.len()).any(|w| w == SECRET.as_bytes()), "the labelled stream was written in the clear");
|
||||
}
|
||||
|
||||
@@ -1239,8 +1239,17 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st
|
||||
|
||||
/// Delete a field: its widgets leave their pages and the field leaves the form.
|
||||
pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
delete_found_field(doc, f)
|
||||
}
|
||||
|
||||
/// [`delete_field`] for the field object `obj`, when separate fields share one name.
|
||||
pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.obj == obj).ok_or_else(|| FormError::NoSuchField(format!("{obj:?}")))?;
|
||||
delete_found_field(doc, f)
|
||||
}
|
||||
|
||||
fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
|
||||
let widgets: std::collections::HashSet<ObjRef> = f.widgets.iter().map(|w| w.obj).collect();
|
||||
for p in page_refs(doc) {
|
||||
let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue };
|
||||
|
||||
@@ -26,8 +26,8 @@ mod scripting;
|
||||
pub mod table;
|
||||
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, duplicate_field,
|
||||
look, redraw_field, set_button_icon, set_props,
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
|
||||
delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
|
||||
@@ -415,7 +415,7 @@ pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec<u8>], content: &[Vec<u8>]) {
|
||||
}
|
||||
let joined = Pieces::join(content);
|
||||
let ops = joined.parse().ops;
|
||||
let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.name(0) == Some(n));
|
||||
let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n));
|
||||
for n in replaced {
|
||||
if !still_drawn(n) {
|
||||
xo.remove(n);
|
||||
@@ -430,7 +430,7 @@ fn content_draws(doc: &Document, content: &[Vec<u8>], xo: &Dict, name: &[u8], de
|
||||
let joined = Pieces::join(content);
|
||||
let ops = joined.parse().ops;
|
||||
for op in ops.iter().filter(|op| op.is("Do")) {
|
||||
let Some(n) = op.name(0) else { continue };
|
||||
let Some(n) = op.last_name() else { continue };
|
||||
if n == name {
|
||||
return Some(true);
|
||||
}
|
||||
@@ -672,7 +672,7 @@ fn resource_ops(doc: &Document, res: &Dict, content: &[Vec<u8>]) -> Result<Vec<O
|
||||
while cursor < ops.len() {
|
||||
let op = &ops[cursor];
|
||||
if op.is("Do")
|
||||
&& let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref)
|
||||
&& let Some(r) = op.last_name().and_then(|n| xobjects.get(n)).and_then(Object::as_ref)
|
||||
&& seen.insert(r)
|
||||
&& let Object::Stream(s) = &*doc.get(r)
|
||||
&& s.dict.name(b"Subtype") == Some(b"Form")
|
||||
@@ -717,7 +717,7 @@ fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec<u8>]) ->
|
||||
let mut xo = res_dict(doc, res, b"XObject");
|
||||
let names: Vec<Vec<u8>> = xo.iter().map(|(n, _)| n.to_vec()).collect();
|
||||
for name in names {
|
||||
if !ops.iter().any(|op| op.is("Do") && op.name(0) == Some(&name)) {
|
||||
if !ops.iter().any(|op| op.is("Do") && op.last_name() == Some(&name)) {
|
||||
xo.remove(&name);
|
||||
}
|
||||
}
|
||||
@@ -1204,7 +1204,7 @@ fn xobject(
|
||||
xobjects: &mut Dict,
|
||||
out: &mut Output,
|
||||
) -> Option<Option<Op>> {
|
||||
let name = op.name(0)?.to_vec();
|
||||
let name = op.last_name()?.to_vec();
|
||||
let r = xobjects.get(&name)?.as_ref()?;
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
|
||||
+72
-10
@@ -185,7 +185,14 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
|
||||
let (mut out, mut unreadable) = (Vec::new(), Vec::new());
|
||||
for (pi, p) in pdfcraft_model::pages(doc).iter().enumerate() {
|
||||
for a in annots_of(doc, &p.dict) {
|
||||
let Some(r) = a.as_ref() else { continue };
|
||||
let Some(r) = a.as_ref() else {
|
||||
// A mark written in place has no object to find, apply or remove: refuse it
|
||||
// rather than apply the others as if it were not there.
|
||||
if doc.resolve(&a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Redact")) {
|
||||
unreadable.push(pi);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let obj = doc.get(r);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
if d.name(b"Subtype") != Some(b"Redact") {
|
||||
@@ -400,13 +407,14 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
report.pages = by_page.len();
|
||||
localize_resources(doc, &by_page)?;
|
||||
let mut doomed_fields: Vec<String> = Vec::new();
|
||||
let mut doomed_fields: Vec<ObjRef> = Vec::new();
|
||||
// The original image and form XObjects the rewrite drops or replaces, on any page.
|
||||
let mut doomed_xobjects: Vec<ObjRef> = Vec::new();
|
||||
let mut retired_properties = Vec::new();
|
||||
let widget_owner: Vec<(ObjRef, String)> =
|
||||
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>()).collect();
|
||||
let widget_owner: Vec<(ObjRef, ObjRef)> =
|
||||
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::<Vec<_>>()).collect();
|
||||
let listed_before = listed_annotations(doc);
|
||||
let mut removed_everywhere: HashSet<ObjRef> = HashSet::new();
|
||||
|
||||
for &(pi, page_marks) in &groups {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(pi);
|
||||
@@ -521,10 +529,10 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) {
|
||||
removed.push(r);
|
||||
if subtype == b"Widget" {
|
||||
if let Some((_, name)) = widget_owner.iter().find(|(w, _)| *w == r)
|
||||
&& !doomed_fields.contains(name)
|
||||
if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r)
|
||||
&& !doomed_fields.contains(field)
|
||||
{
|
||||
doomed_fields.push(name.clone());
|
||||
doomed_fields.push(*field);
|
||||
}
|
||||
} else {
|
||||
report.annotations += 1;
|
||||
@@ -557,6 +565,14 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
break;
|
||||
}
|
||||
}
|
||||
removed_everywhere.extend(removed.iter().copied());
|
||||
// Pop-ups dropped with their parent below count too: a reply on another page may answer one.
|
||||
for a in &annots {
|
||||
let Some(r) = a.as_ref() else { continue };
|
||||
if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) {
|
||||
removed_everywhere.insert(r);
|
||||
}
|
||||
}
|
||||
let kept: Vec<Object> = annots
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
@@ -578,10 +594,56 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
})?;
|
||||
}
|
||||
|
||||
// 2b. Replies and pop-ups of a removed annotation can be listed on another page than their
|
||||
// parent: their `/IRT` or `/Parent` would keep the removed comment reachable.
|
||||
loop {
|
||||
let mut grew = false;
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let annots = annots_of(doc, &p.dict);
|
||||
let mut kept = Vec::with_capacity(annots.len());
|
||||
for a in &annots {
|
||||
let Some(r) = a.as_ref() else {
|
||||
kept.push(a.clone());
|
||||
continue;
|
||||
};
|
||||
let obj = doc.get(r);
|
||||
let Some(d) = obj.as_dict() else {
|
||||
kept.push(a.clone());
|
||||
continue;
|
||||
};
|
||||
let is_reply =
|
||||
d.get(b"IRT").and_then(Object::as_ref).is_some_and(|x| removed_everywhere.contains(&x)) && d.name(b"Subtype") != Some(b"Widget");
|
||||
let is_popup = d.get(b"Parent").and_then(Object::as_ref).is_some_and(|x| removed_everywhere.contains(&x));
|
||||
if removed_everywhere.contains(&r) || is_reply || is_popup {
|
||||
if removed_everywhere.insert(r) {
|
||||
grew = true;
|
||||
if is_reply {
|
||||
report.annotations += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
kept.push(a.clone());
|
||||
}
|
||||
}
|
||||
if kept.len() != annots.len() {
|
||||
doc.update_dict(p.obj, |d| {
|
||||
if kept.is_empty() {
|
||||
d.remove(b"Annots");
|
||||
} else {
|
||||
d.set(b"Annots".to_vec(), Object::Array(kept));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if !grew {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Form fields with a widget under a mark (all their widgets go).
|
||||
for name in &doomed_fields {
|
||||
if pdfcraft_forms::fields(doc).iter().any(|f| &f.name == name) {
|
||||
pdfcraft_forms::delete_field(doc, name)?;
|
||||
for field in &doomed_fields {
|
||||
if pdfcraft_forms::fields(doc).iter().any(|f| f.obj == *field) {
|
||||
pdfcraft_forms::delete_field_object(doc, *field)?;
|
||||
report.fields += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
|
||||
_ => Dict::new(),
|
||||
};
|
||||
dict.remove(b"Length");
|
||||
// The source text of an added item (Edit ▸ Add content) would keep what was removed.
|
||||
dict.remove(b"PCAdded");
|
||||
new_list[i] = Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::flate(dict, &bytes))));
|
||||
changed = true;
|
||||
}
|
||||
@@ -505,13 +507,6 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1);
|
||||
let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?;
|
||||
let mut done = Vec::new();
|
||||
// Content first (it needs the layers and the form still in place).
|
||||
let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers));
|
||||
let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
|
||||
content_pass(doc, text, layers, true)?.1
|
||||
} else {
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
|
||||
// widgets included) leaves the structure tree too.
|
||||
let listed_before = listed_annotations(doc);
|
||||
@@ -529,6 +524,14 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
d.remove(b"AcroForm");
|
||||
})?;
|
||||
}
|
||||
// Content next (it needs the layers still in place). Flattened appearances are page content
|
||||
// by now, so hidden text or layers inside them are removed with the rest.
|
||||
let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers));
|
||||
let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) {
|
||||
content_pass(doc, text, layers, true)?.1
|
||||
} else {
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
// Annotations: comments, attachments, links, actions, and the widgets flattening kept (hidden
|
||||
// ones, and ones with nothing to draw), which still hold their field's value and parent.
|
||||
let drop_annot = |a: &Dict| -> bool {
|
||||
|
||||
@@ -2762,3 +2762,85 @@ fn a_failure_on_a_later_page_leaves_the_document_unchanged() {
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(content(&doc, 0), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_and_pop_up_on_another_page_go_with_the_removed_comment() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [6 0 R] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [7 0 R 8 0 R] >>".to_vec(),
|
||||
stream("", b"BT ET"),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (PARENT-NOTE) /Popup 8 0 R >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 6 0 R /Contents (OTHER-PAGE-REPLY) >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 50.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
let second = pdfcraft_model::pages(&doc).swap_remove(1);
|
||||
assert!(annots_of(&doc, &second.dict).is_empty(), "the reply and the pop-up leave page 2");
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(10).any(|w| w == b"PARENT-NOT"), "the removed comment survived the save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redact_mark_written_in_place_is_refused() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Redact /Rect [10 10 70 40] >>] >>"
|
||||
.to_vec(),
|
||||
stream("", b"BT ET"),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[100.0, 100.0, 120.0, 120.0]], "");
|
||||
assert!(matches!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_acts_on_the_operand_before_the_operator_like_the_renderer() {
|
||||
// `/Missing /Im Do`: the renderer draws /Im, so redaction must find the image there.
|
||||
let img = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"\x00");
|
||||
let mut doc = one_page(b"q 100 0 0 100 10 10 cm /Missing /Im Do Q", "/XObject << /Im 7 0 R >>", vec![img]);
|
||||
mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.images_removed, 1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_text_removal_drops_the_source_text_of_added_content() {
|
||||
let mut doc = one_page(b"BT /F1 12 Tf 3 Tr 20 20 Td (SECRET) Tj ET", "", vec![]);
|
||||
let content_ref = pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").and_then(Object::as_ref).unwrap();
|
||||
doc.update_dict(content_ref, |d| {
|
||||
d.set(b"PCAdded".to_vec(), Object::Dict(Dict::new()));
|
||||
})
|
||||
.unwrap();
|
||||
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap();
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
let (list, _) = page_streams(&doc, &p.dict, 0).unwrap();
|
||||
assert!(list.iter().all(|o| doc.resolve(o).as_dict().is_some_and(|d| !d.contains(b"PCAdded"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_on_another_page_to_a_removed_popup_goes_too() {
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [6 0 R 8 0 R] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Annots [7 0 R] >>".to_vec(),
|
||||
stream("", b"BT ET"),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (PARENT-NOTE) /Popup 8 0 R >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 8 0 R /Contents (POPUP-REPLY) >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R /Contents (POPUP-TEXT) >>".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 50.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
let second = pdfcraft_model::pages(&doc).swap_remove(1);
|
||||
assert!(annots_of(&doc, &second.dict).is_empty());
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
for gone in [&b"POPUP-REPLY"[..], b"POPUP-TEXT", b"PARENT-NOTE"] {
|
||||
assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived", String::from_utf8_lossy(gone));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1467,6 +1467,23 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
list.push(what);
|
||||
}
|
||||
}
|
||||
// An object the signed revision had and this one frees is a change too, and the loop above
|
||||
// only sees objects that still exist. File plumbing (cross-reference and object streams) is
|
||||
// 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);
|
||||
// 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;
|
||||
}
|
||||
let what = if signed_role { kind::PAGE_CONTENT } else { kind::OTHER };
|
||||
if !disallowed.contains(&what) {
|
||||
disallowed.push(what);
|
||||
}
|
||||
}
|
||||
if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) }
|
||||
}
|
||||
|
||||
@@ -1771,6 +1788,15 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>)
|
||||
// A later revision cannot make an existing object a signature by relabelling it: a signature
|
||||
// change is only a brand-new object, or one that was already a signature object when signed.
|
||||
let signature_ok = before.is_none_or(is_signature_dict);
|
||||
// A signature widget that already held a signature is what the signature shows: moving it or
|
||||
// swapping its appearance can hide the signed page under it. Judged by the signed revision,
|
||||
// so relabelling the widget now does not change that.
|
||||
if before.is_some_and(|b| {
|
||||
b.name(b"Subtype") == Some(b"Widget") && b.name(b"FT") == Some(b"Sig") && b.get(b"V").is_some_and(|v| old.resolve(v).as_dict().is_some())
|
||||
}) && before != Some(d)
|
||||
{
|
||||
return kind::PAGE_CONTENT;
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -2626,3 +2626,45 @@ fn a_timestamp_from_a_certificate_not_issued_for_time_stamping_is_not_trusted_ti
|
||||
assert_eq!(s.timestamp_time, None, "{:?}", s.details);
|
||||
assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details);
|
||||
}
|
||||
|
||||
/// A signed revision's objects can't be freed or its signature widget rewritten without the
|
||||
/// change being seen: the classifier used to look only at objects still present, and let a
|
||||
/// signature widget's geometry and appearance change count as a signature.
|
||||
#[test]
|
||||
fn freeing_a_signed_object_or_rewriting_the_signed_widget_is_a_page_content_change() {
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||||
let freed = check(&edit_after(&signed, |doc| doc.free(pdfcraft_cos::ObjRef { num: 5, generation: 0 })));
|
||||
assert_eq!(freed.status, Status::Invalid, "{:?}", freed.modification);
|
||||
assert!(matches!(&freed.modification, Modification::Disallowed(k) if k.contains(&kind::PAGE_CONTENT)), "{:?}", freed.modification);
|
||||
let widget = open(&signed)
|
||||
.object_numbers()
|
||||
.into_iter()
|
||||
.map(|num| pdfcraft_cos::ObjRef { num, generation: 0 })
|
||||
.find(|r| open(&signed).get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && d.contains(b"V")))
|
||||
.unwrap();
|
||||
let moved = check(&edit_after(&signed, |doc| {
|
||||
doc.update_dict(widget, |d| d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)])))
|
||||
.unwrap();
|
||||
}));
|
||||
assert_eq!(moved.status, Status::Invalid, "{:?}", moved.modification);
|
||||
assert!(matches!(&moved.modification, Modification::Disallowed(k) if k.contains(&kind::PAGE_CONTENT)), "{:?}", moved.modification);
|
||||
}
|
||||
|
||||
/// An existing signature field whose `/V` is null is unsigned: signing it rewrites its widget,
|
||||
/// and that is a signature, not a change to what an earlier signature shows.
|
||||
#[test]
|
||||
fn signing_a_field_with_a_null_value_after_another_signature_keeps_the_first_valid() {
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let first = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||||
let second = pdfcraft_sign::sign(&open(&first), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
|
||||
let all = signatures(&open(&second), &second, &trust);
|
||||
assert!(
|
||||
all.iter().filter(|s| s.signed).all(|s| s.status == Status::Valid),
|
||||
"{:?}",
|
||||
all.iter().map(|s| (&s.status, &s.modification)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user