fix(audit): resolve an indirect /Subtype before skipping an object

The annotation scan and the form XObject walk read /Subtype only as a
direct name. A /Subtype stored as a reference to the name read as
none, so a /Redact annotation or a covering form named that way was
skipped as an unrelated object and the page reported clean. Both now
follow the reference, and a reference that cannot be loaded fails the
audit.
This commit is contained in:
aiongg
2026-10-22 14:46:34 +00:00
parent cc1086a862
commit 4c7ba85beb
3 changed files with 41 additions and 2 deletions
+1 -1
View File
@@ -259,7 +259,7 @@ impl<'a> Walker<'a> {
let resolved = self.doc.resolve(entry);
let Object::Stream(stream) = &*resolved else { return Ok(()) };
let dict = &stream.dict;
if dict.name(b"Subtype") != Some(b"Form") {
if crate::subtype(self.doc, dict)?.as_deref() != Some(b"Form") {
return Ok(());
}
let matrix = dict
+11 -1
View File
@@ -229,7 +229,7 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
continue;
}
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
match dict.name(b"Subtype") {
match subtype(doc, &dict)?.as_deref() {
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
Some(b"Square") => {
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
@@ -249,6 +249,16 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
Ok(out)
}
/// The `/Subtype` name of `dict`, following an indirect reference. A reference that cannot be
/// loaded fails the audit: the subtype decides whether the object is checked at all.
fn subtype(doc: &Document, dict: &Dict) -> Result<Option<Vec<u8>>, AuditError> {
let name = match dict.get(b"Subtype") {
Some(pdfcraft_cos::Object::Ref(r)) => doc.try_get(r.num)?.as_name().map(<[u8]>::to_vec),
other => other.and_then(pdfcraft_cos::Object::as_name).map(<[u8]>::to_vec),
};
Ok(name)
}
fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> {
let values: Vec<f64> = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::<Option<_>>()?;
if !values.iter().all(|v| v.is_finite()) {
+29
View File
@@ -602,3 +602,32 @@ fn rectangle_filled_after_restore_is_a_cover() {
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}");
}
/// A `/Subtype` may be an indirect reference to the name, like any other value: a `/Redact`
/// annotation and a covering form XObject named that way are still checked.
#[test]
fn indirect_subtype_is_resolved() {
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n");
b.add(FONT);
b.add("<< /Type /Annot /Subtype 7 0 R /Rect [95 685 305 725] >>");
b.add("/Redact");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>");
b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n");
b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype 7 0 R /BBox [0 0 612 792]");
b.add(FONT);
b.add("/Form");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::CoveredText);
}