Merge PR #2144: Strip XML 1.0-forbidden characters from XMP sidecar output

This commit is contained in:
Brandon Thomas committed 2026-10-18 09:20:19 -04:00
commit 70100a34c5
1 file changed
+36 -5
+36 -5
View File
@@ -185,11 +185,22 @@ fn entity(name: &str) -> Option<char> {
Some(h) => u32::from_str_radix(h, 16).ok()?,
None => n.parse().ok()?,
};
char::from_u32(v)?
char::from_u32(v).filter(|&c| is_xml_char(c))?
}
})
}
/// The XML 1.0 `Char` production (the surrogate range can't occur in a `char`).
fn is_xml_char(c: char) -> bool {
matches!(c, '\t' | '\n' | '\r') || (c >= '\u{20}' && c != '\u{FFFE}' && c != '\u{FFFF}')
}
/// Drop the characters XML 1.0 forbids, which strict parsers (Adobe XMP Toolkit, expat, libxml2) reject the
/// whole document for.
fn xml_safe(s: &str) -> String {
s.chars().filter(|&c| is_xml_char(c)).collect()
}
/// Unescape attribute values (predefined + numeric references; unknown references kept verbatim).
fn unescape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
@@ -620,12 +631,12 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
}
x.push_str(">\n");
for (k, v) in &simple {
x.push_str(&format!(" <{k}>{}</{k}>\n", escape(v.as_str())));
x.push_str(&format!(" <{k}>{}</{k}>\n", escape(xml_safe(v))));
}
for (k, v) in lc {
if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
// element content: quotes needn't be escaped, which keeps the JSON readable
x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(*v)));
x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(xml_safe(v))));
}
}
let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| {
@@ -635,9 +646,9 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String {
x.push_str(&format!(" <{k}>\n <rdf:{kind}>\n"));
for it in items {
if lang {
x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(it.as_str())));
x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(xml_safe(it))));
} else {
x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(it.as_str())));
x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(xml_safe(it))));
}
}
x.push_str(&format!(" </rdf:{kind}>\n </{k}>\n"));
@@ -1054,4 +1065,24 @@ mod tests {
a.overlay_user_fields(&b);
assert_eq!(a.regions, vec![with_region]);
}
#[test]
fn writer_drops_characters_xml_1_0_forbids() {
let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh";
let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() };
let x = write_xmp_lc(&m, &[("settings", bad)]);
let illegal: Vec<char> =
x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect();
assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}");
assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}");
}
#[test]
fn numeric_references_to_forbidden_characters_are_not_decoded() {
assert_eq!(entity("#1"), None);
assert_eq!(entity("#0"), None);
assert_eq!(entity("#xFFFE"), None);
assert_eq!(entity("#x41"), Some('A'));
assert_eq!(entity("#9"), Some('\t'));
}
}