Escape tab/newline/CR in XML attribute values

Found while testing crates/interchange/src/xml.rs:7 (escape, used by start_tag for attributes)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-20 14:58:22 +02:00
co-authored by Claude Haiku 5.5
parent cdf43fd366
commit ae313a8801
2 changed files with 43 additions and 12 deletions
+15 -11
View File
@@ -673,8 +673,8 @@ pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report:
out.push_str(r);
}
out.push_str(" </resources>\n");
out.push_str(&format!(" <library>\n <event name=\"{}\">\n", crate::xml::escape(&p.name)));
out.push_str(&format!(" <project name=\"{}\">\n", crate::xml::escape(&name)));
out.push_str(&format!(" <library>\n <event name=\"{}\">\n", crate::xml::escape_attr(&p.name)));
out.push_str(&format!(" <project name=\"{}\">\n", crate::xml::escape_attr(&name)));
out.push_str(&body);
out.push_str(" </project>\n </event>\n </library>\n</fcpxml>\n");
Ok(out)
@@ -728,7 +728,7 @@ impl Exp<'_, '_> {
let start = m.info.start_timecode.map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO);
let mut a = format!(
" <asset id=\"{id}\" name=\"{}\" start=\"{}\" duration=\"{}\" hasVideo=\"{}\" hasAudio=\"{}\"",
crate::xml::escape(&name),
crate::xml::escape_attr(&name),
fmt_time(start, mrate),
fmt_time(m.info.duration, mrate),
m.info.video.is_some() as u8,
@@ -742,7 +742,7 @@ impl Exp<'_, '_> {
}
let path = item_path(self.p, item).unwrap_or("");
let src = if crate::common::is_absolute(path) { path_to_file_url(path, false) } else { path.to_string() };
a.push_str(&format!(">\n <media-rep kind=\"original-media\" src=\"{}\"/>\n </asset>\n", crate::xml::escape(&src)));
a.push_str(&format!(">\n <media-rep kind=\"original-media\" src=\"{}\"/>\n </asset>\n", crate::xml::escape_attr(&src)));
self.res_out.push(a);
self.assets.insert(item, Res { id: id.clone() });
let _ = file_name;
@@ -764,7 +764,7 @@ impl Exp<'_, '_> {
let body = self.sequence_xml(item, &fmt, 3);
self.building.pop();
let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default();
self.res_out.push(format!(" <media id=\"{id}\" name=\"{}\">\n{body} </media>\n", crate::xml::escape(&name)));
self.res_out.push(format!(" <media id=\"{id}\" name=\"{}\">\n{body} </media>\n", crate::xml::escape_attr(&name)));
id
}
@@ -773,7 +773,11 @@ impl Exp<'_, '_> {
return r.id.clone();
}
let id = self.rid();
self.res_out.push(format!(" <effect id=\"{id}\" name=\"{}\" uid=\"filmcraft.{}\"/>\n", crate::xml::escape(name), crate::xml::escape(id_hint)));
self.res_out.push(format!(
" <effect id=\"{id}\" name=\"{}\" uid=\"filmcraft.{}\"/>\n",
crate::xml::escape_attr(name),
crate::xml::escape_attr(id_hint)
));
self.effects.insert(name.to_string(), Res { id: id.clone() });
id
}
@@ -934,7 +938,7 @@ impl Exp<'_, '_> {
"<transition name=\"{n}\" offset=\"{}\" duration=\"{}\"><filter-video ref=\"{r}\" name=\"{n}\"/></transition>",
fmt_time(tc0 + tr.start, rate),
fmt_time(tr.duration, rate),
n = crate::xml::escape(&name)
n = crate::xml::escape_attr(&name)
)
}
@@ -946,7 +950,7 @@ impl Exp<'_, '_> {
let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_)));
let generator = generator_of(self.p, base).cloned();
let lane_attr = lane.map(|l| format!(" lane=\"{l}\"")).unwrap_or_default();
let name = crate::xml::escape(&it.name);
let name = crate::xml::escape_attr(&it.name);
if let Some(g) = generator {
self.report.warn(format!("generator \"{}\" is not supported in FCPXML; written as a gap", g.label()));
return El {
@@ -1043,7 +1047,7 @@ impl Exp<'_, '_> {
let n = e.def().map(|d| d.name).unwrap_or(&e.effect).to_string();
let r = self.effect(&n, &e.effect);
let tag = if kind == TrackKind::Video { "filter-video" } else { "filter-audio" };
let mut s = format!("<{tag} ref=\"{r}\" name=\"{}\"", crate::xml::escape(&n));
let mut s = format!("<{tag} ref=\"{r}\" name=\"{}\"", crate::xml::escape_attr(&n));
if !e.enabled {
s.push_str(" enabled=\"0\"");
}
@@ -1169,8 +1173,8 @@ fn kparam_xml(
fn marker_xml(m: &Marker, local: Tick, rate: FrameRate) -> String {
let tag = if m.kind == MarkerKind::Chapter { "chapter-marker" } else { "marker" };
let dur = if m.duration > Tick::ZERO { m.duration } else { rate.frame_duration() };
let note = if m.comment.is_empty() { String::new() } else { format!(" note=\"{}\"", crate::xml::escape(&m.comment)) };
format!("<{tag} start=\"{}\" duration=\"{}\" value=\"{}\"{note}/>", fmt_time(local, rate), fmt_time(dur, rate), crate::xml::escape(&m.name))
let note = if m.comment.is_empty() { String::new() } else { format!(" note=\"{}\"", crate::xml::escape_attr(&m.comment)) };
format!("<{tag} start=\"{}\" duration=\"{}\" value=\"{}\"{note}/>", fmt_time(local, rate), fmt_time(dur, rate), crate::xml::escape_attr(&m.name))
}
#[cfg(test)]
+28 -1
View File
@@ -20,6 +20,21 @@ pub(crate) fn escape(s: &str) -> String {
o
}
/// Like `escape`, but also encodes whitespace that attribute-value
/// normalization would otherwise turn into spaces.
pub(crate) fn escape_attr(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' => o.push_str("&#10;"),
'\r' => o.push_str("&#13;"),
'\t' => o.push_str("&#9;"),
c => o.push_str(&escape(c.encode_utf8(&mut [0; 4]))),
}
}
o
}
/// Pretty-printing XML writer (two-space indent, one element per line).
pub(crate) struct XmlWriter {
out: String,
@@ -46,7 +61,7 @@ impl XmlWriter {
self.out.push('<');
self.out.push_str(name);
for (k, v) in attrs {
let _ = write!(self.out, " {k}=\"{}\"", escape(v));
let _ = write!(self.out, " {k}=\"{}\"", escape_attr(v));
}
}
@@ -169,4 +184,16 @@ mod tests {
assert_eq!(num(-0.0), "0");
assert_eq!(num(0.1 + 0.2).parse::<f64>().unwrap(), 0.1 + 0.2);
}
#[test]
fn attr_whitespace_roundtrip() {
let mut w = XmlWriter::new(None);
w.open("a", &[("name", "x\ny\tz\r")]);
w.text("b", "a\nb");
let s = w.finish();
let d = parse(&s).unwrap();
let a = d.root_element();
assert_eq!(a.attribute("name"), Some("x\ny\tz\r"));
assert_eq!(child(a, "b").and_then(|b| b.text()), Some("a\nb"));
}
}