fix(preflight): identify PDF standards by XML namespace (#3364)

* fix(preflight): identify standards by XML namespace

* fix(preflight): bound XMP attribute parsing work

* fix(preflight): cap XMP during stream decoding

* Preflight: read the PDF/A and PDF/UA identification by its usual prefix when the XMP can't be parsed as XML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Earl Co
2026-10-19 08:48:40 +02:00
committed by GitHub
parent 236ce9a064
commit 7d9adcb8f1
6 changed files with 194 additions and 7 deletions
Generated
+2
View File
@@ -4263,9 +4263,11 @@ dependencies = [
name = "pdfcraft-preflight"
version = "0.9.0"
dependencies = [
"flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
"roxmltree",
]
[[package]]
+4
View File
@@ -14,3 +14,7 @@ workspace = true
pdfcraft-content = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
roxmltree = "0.20"
[dev-dependencies]
flate2 = { workspace = true }
+6 -6
View File
@@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option<String> {
let cat = catalog(doc);
let m = doc.resolve(cat.get(b"Metadata")?);
match &*m {
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
@@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option<String> {
pub fn declared(doc: &Document) -> Declared {
let mut d = Declared::default();
if let Some(x) = metadata(doc) {
let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok());
let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default();
let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok());
let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default();
d.pdfa = part.map(|p| (p, conf.to_uppercase()));
d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok());
d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok());
}
let cat = catalog(doc);
if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) {
@@ -423,8 +423,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
match metadata(doc) {
None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true),
Some(x) => {
let part = xmp::value(&x, "pdfaid:part");
let conf = xmp::value(&x, "pdfaid:conformance");
let part = xmp::identifier(&x, xmp::PDFA_ID, "part");
let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance");
if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") {
issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true);
}
+46
View File
@@ -371,3 +371,49 @@ fn device_colour_in_forms_patterns_and_glyphs_is_found() {
assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed);
}
}
#[test]
fn standards_identification_uses_namespace_not_prefix() {
fn with_xmp(xmp: &str) -> Document {
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "")
}
let attribute = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:a="http://www.aiim.org/pdfa/ns/id/" xmlns:u="http://www.aiim.org/pdfua/ns/id/" a:part="2" a:conformance="b" u:part="1"/></rdf:RDF>"#;
let element = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description><part xmlns="http://www.aiim.org/pdfa/ns/id/">2</part><x:conformance xmlns:x="http://www.aiim.org/pdfa/ns/id/" >B</x:conformance></rdf:Description></rdf:RDF>"#;
for xml in [attribute, element] {
let doc = with_xmp(xml);
assert_eq!(declared(&doc).pdfa, Some((2, "B".into())));
assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
}
assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1));
let wrong = r#"<root xmlns:pdfaid="urn:wrong" pdfaid:part="2" pdfaid:conformance="B"/>"#;
let doc = with_xmp(wrong);
assert_eq!(declared(&doc).pdfa, None);
assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
let nested = format!("{}{}", "<n>".repeat(1100), "</n>".repeat(1100));
assert_eq!(declared(&with_xmp(&nested)).pdfa, None);
}
#[test]
fn oversized_xmp_is_refused_while_decoding() {
let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1);
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
assert!(metadata(&doc).is_none());
assert_eq!(declared(&doc).pdfa, None);
use std::io::Write as _;
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(xmp.as_bytes()).unwrap();
let compressed = z.finish().unwrap();
let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect();
let data = format!("{hex}>");
let stream =
format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len());
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
let cat = catalog(&doc);
let raw = doc.resolve(cat.get(b"Metadata").unwrap());
let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") };
assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress");
assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding");
}
+135
View File
@@ -3,6 +3,116 @@
use pdfcraft_cos::Document;
/// The largest XMP packet read; a larger one is treated as missing.
pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024;
/// Namespace URIs of the PDF/A and PDF/UA identification schemas.
pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/";
pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/";
/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet
/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide
/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before.
pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option<String> {
if xmp.len() > MAX_XMP_BYTES {
return None;
}
match parsed_identifier(xmp, namespace, local) {
Ok(found) => found,
Err(()) => {
let prefix = match namespace {
PDFA_ID => "pdfaid",
PDFUA_ID => "pdfuaid",
_ => return None,
};
value(xmp, &format!("{prefix}:{local}"))
}
}
}
/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits.
fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result<Option<String>, ()> {
if !xml_limits_ok(xmp) {
return Err(());
}
let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000 };
let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?;
for node in doc.descendants().filter(roxmltree::Node::is_element) {
if let Some(value) = node.attribute((namespace, local)) {
return Ok(Some(value.trim().to_owned()));
}
if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local {
return Ok(node.text().map(|value| value.trim().to_owned()));
}
}
Ok(None)
}
/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates.
/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings.
/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`.
fn xml_limits_ok(text: &str) -> bool {
fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
(from..hay.len()).find(|&p| hay[p..].starts_with(needle))
}
let b = text.as_bytes();
let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize);
while i < b.len() {
if b[i] != b'<' {
i += 1;
} else if b[i..].starts_with(b"<!--") {
i = find(b, i + 4, b"-->").map_or(b.len(), |p| p + 3);
} else if b[i..].starts_with(b"<![CDATA[") {
i = find(b, i + 9, b"]]>").map_or(b.len(), |p| p + 3);
} else if b[i..].starts_with(b"<?") {
i = find(b, i + 2, b"?>").map_or(b.len(), |p| p + 2);
} else if b[i..].starts_with(b"<!") {
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
} else if b[i..].starts_with(b"</") {
depth = depth.saturating_sub(1);
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
} else {
depth += 1;
if depth > 1024 {
return false;
}
// Skip the rest of the start tag, over any quoted attribute value, and remember its last
// significant byte: a tag that ends in `/` closes itself, so it is one level deep while it
// is read rather than a level left open.
i += 1;
let mut quote = 0u8;
let mut last = 0u8;
let mut attributes = 0usize;
while i < b.len() {
let c = b[i];
if quote != 0 {
if c == quote {
quote = 0;
}
} else if c == b'"' || c == b'\'' {
quote = c;
} else if c == b'>' {
i += 1;
break;
} else if c == b'=' {
attributes += 1;
total_attributes += 1;
if attributes > 128 || total_attributes > 4096 {
return false;
}
last = c;
} else if !c.is_ascii_whitespace() {
last = c;
}
i += 1;
}
if last == b'/' {
depth -= 1;
}
}
}
true
}
/// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or
/// as an element (`<pdfaid:part>2</pdfaid:part>`, or inside an `rdf:Alt`/`rdf:Seq`).
pub fn value(xmp: &str, key: &str) -> Option<String> {
@@ -133,3 +243,28 @@ mod tests {
assert_eq!(iso_date("junk"), None);
}
}
#[cfg(test)]
mod limit_tests {
use super::*;
#[test]
fn attribute_and_namespace_budgets_bound_parser_work() {
let prefix = r#"<root xmlns:a="http://www.aiim.org/pdfa/ns/id/" a:part="2" "#;
let attrs = |count| (0..count).map(|i| format!("x{i}=\"v=1\" ")).collect::<String>();
let at_limit = format!("{prefix}{}/>", attrs(126));
assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2"));
let over_limit = format!("{prefix}{}/>", attrs(127));
assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None);
let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::<String>();
assert_eq!(identifier(&format!("<root {namespaces}/>"), PDFA_ID, "part"), None);
let many_elements = format!("<root>{}</root>", r#"<n a="v"/>"#.repeat(4097));
assert!(!xml_limits_ok(&many_elements));
// Not well-formed (the prefix is never declared): read by the conventional prefix, as before.
let undeclared = r#"<rdf:Description pdfaid:part="3" pdfaid:conformance="u"/>"#;
assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3"));
assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u"));
let over_bytes = format!("<root>{}</root>", " ".repeat(8 * 1024 * 1024));
assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None);
}
}
+1 -1
View File
@@ -7776,7 +7776,7 @@ milestone = "M11"
status = "partial"
commands = ["standards.pdfa"]
tools = ["pdfa_verify"]
evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/compare.rs::pdfa_dialog_verifies_and_converts"]
notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison."
[[feature]]