fix(forms): a deleted field leaves the calculation order

Deleting a field took it off its pages and out of the field tree but left
its entry in the form's /CO array. That reference kept the field
dictionary, its value and its widgets reachable, so a full save after
applying redactions over the field still wrote the redacted value.
This commit is contained in:
aiongg authored and Dylan M. Taylor committed 2026-10-22 15:17:44 -04:00
1 parent 3e968c8aa2
commit f42fb0ad49
3 files changed
+54

No files matched your search

+25
View File
@@ -1280,6 +1280,31 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
}
}
}
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
// widgets reachable for a full save.
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
}
/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or
/// by reference.
fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
let Some(root) = doc.root() else { return Ok(()) };
let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) };
let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) };
let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default();
let kept: Vec<Object> = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect();
if kept.len() == co.len() {
return Ok(());
}
match (co_obj.as_ref(), af_obj.as_ref()) {
(Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)),
(_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?,
(_, None) => {
af.set(b"CO".to_vec(), Object::Array(kept));
doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?;
}
}
Ok(())
}
+1
View File
@@ -2462,6 +2462,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() {
}
mod property_retirement;
mod surviving_references;
/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still
/// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not
@@ -0,0 +1,28 @@
//! Redacted content must leave the file: no other reference may keep it reachable by a full save.
use super::*;
/// Whether `secret` survives a full save of `doc`, in the file's bytes or in a decoded stream.
fn full_save_holds(doc: &Document, secret: &[u8]) -> bool {
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
let found = |data: &[u8]| data.windows(secret.len()).any(|w| w == secret);
if found(&bytes) {
return true;
}
let saved = Document::open(Arc::new(bytes)).unwrap();
saved.scan_objects().into_iter().any(|(_, o)| matches!(&*o, Object::Stream(s) if s.decoded().is_ok_and(|d| found(&d))))
}
#[test]
fn a_redacted_field_listed_in_the_calculation_order_leaves_the_saved_file() {
let mut doc = one_page(b"", "", vec![]);
pdfcraft_forms::add_field(&mut doc, 0, [10.0, 50.0, 100.0, 70.0], &pdfcraft_forms::NewField::Text { multiline: false }, Some("ssn")).unwrap();
pdfcraft_forms::set_value(&mut doc, "ssn", &pdfcraft_forms::FieldValue::Text("SSN-078-05-1120".into())).unwrap();
let field = pdfcraft_forms::fields(&doc).swap_remove(0).obj;
let root = doc.root().unwrap();
let af = doc.get(root).as_dict().unwrap().reference(b"AcroForm").unwrap();
doc.update_dict(af, |d| d.set(b"CO".to_vec(), Object::Array(vec![Object::Ref(field)]))).unwrap();
mark(&mut doc, 0, &[[0.0, 40.0, 120.0, 80.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().fields, 1);
assert!(!full_save_holds(&doc, b"SSN-078-05-1120"), "the field's value is still reachable");
}