Skip to content

Commit a05133f

Browse files
committed
Escape \r in text and \r/\n/\t in attributes during serde serialization
The serde serializer wrote literal \r into text content and \r, \n, \t into attribute values. These characters are silently modified by XML end-of-line normalization and attribute-value normalization respectively, causing data loss on round-trip. Now escape_list() escapes \r as &tafia#13; in text content at all QuoteLevels, and escapes \r/\n/\t as &tafia#13;/&tafia#10;/&tafia#9; in attribute values. This is consistent with the behavior of libxml2 / lxml. Make note of the fact that CDATA sections are inherently lossy for \r since character references are not permitted inside CDATA. closes tafia#670 closes tafia#990 Assisted-By: Claude Opus 4.6
1 parent 4e85c3d commit a05133f

6 files changed

Lines changed: 213 additions & 25 deletions

File tree

‎Changelog.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ The MSRV has been raised to 1.86.
6161

6262
### Bug Fixes
6363

64+
- [#670]: Serde serializer now escapes `\r`, `\n`, and `\t` in attribute values
65+
as `
`, `
`, and `	` respectively, preventing silent data loss from
66+
XML attribute-value normalization on round-trip.
6467
- [#989]: `Attributes::new` and `Attributes::html` now return empty iterators when
6568
their starting position is past the end of the input instead of panicking.
6669
- [#977]: `NamespaceResolver::push` (and hence every `NsReader` `Start`/`Empty`
@@ -76,6 +79,8 @@ The MSRV has been raised to 1.86.
7679
limit (default 128, matching `serde_json`). Deeply nested XML returns
7780
`DeError::TooDeeplyNested` instead of overflowing the native call stack.
7881
Use `Deserializer::recursion_limit()` to adjust.
82+
- [#990]: Serde serializer now escapes `\r` in text content as `
`, preventing
83+
silent conversion to `\n` from XML end-of-line normalization on round-trip.
7984

8085
### Misc Changes
8186

@@ -87,12 +92,14 @@ The MSRV has been raised to 1.86.
8792
`decode_and_unescape_value_with()`. Use `normalized_value()` and
8893
`normalized_value_with()` instead.
8994

95+
[#670]: https://github.com/tafia/quick-xml/issues/670
9096
[#963]: https://github.com/tafia/quick-xml/pull/963
9197
[#977]: https://github.com/tafia/quick-xml/issues/977
9298
[#980]: https://github.com/tafia/quick-xml/issues/980
9399
[#978]: https://github.com/tafia/quick-xml/issues/978
94100
[#983]: https://github.com/tafia/quick-xml/issues/983
95101
[#989]: https://github.com/tafia/quick-xml/issues/989
102+
[#990]: https://github.com/tafia/quick-xml/issues/990
96103

97104
## 0.41.0 -- 2026-06-29
98105

‎src/se/element.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,7 @@ mod tests {
743743
serialize_as!(seq_empty: Vec::<usize>::new() => "");
744744
serialize_as!(tuple: ("<\"&'>", "with\t\n\r spaces", 3usize)
745745
=> "<root>&lt;&quot;&amp;&apos;&gt;</root>\
746-
<root>with\t\n\r spaces</root>\
746+
<root>with\t\n&#13; spaces</root>\
747747
<root>3</root>");
748748
serialize_as!(tuple_struct: Tuple("first", 42)
749749
=> "<root>first</root>\
@@ -1457,7 +1457,7 @@ mod tests {
14571457
serialize_as!(seq_empty: Vec::<usize>::new() => "");
14581458
serialize_as!(tuple: ("<\"&'>", "with\t\n\r spaces", 3usize)
14591459
=> "<root>&lt;&quot;&amp;&apos;&gt;</root>\n\
1460-
<root>with\t\n\r spaces</root>\n\
1460+
<root>with\t\n&#13; spaces</root>\n\
14611461
<root>3</root>");
14621462
serialize_as!(tuple_struct: Tuple("first", 42)
14631463
=> "<root>first</root>\n\

‎src/se/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ where
322322
pub enum TextFormat {
323323
/// Serialize as regular text content with escaping
324324
Text,
325-
/// Serialize as CDATA section without escaping
325+
/// Serialize as CDATA section without escaping.
326326
CData,
327327
}
328328

‎src/se/simple_type.rs‎

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ where
130130
}
131131

132132
/// Escapes XSD simple type value
133+
///
134+
/// XML EOL normalization (Section 2.11) converts \r to \n before parsing, and
135+
/// attribute-value normalization (Section 3.3.3) converts \r, \n, \t to space.
136+
/// We escape these as character references (e.g. &#13;) which bypass normalization,
137+
/// preserving the original characters on round-trip as seen in other implementations
138+
/// such as libxml2.
133139
fn escape_list<W>(mut writer: W, value: &str, target: QuoteTarget, level: QuoteLevel) -> fmt::Result
134140
where
135141
W: Write,
@@ -146,31 +152,49 @@ where
146152
}
147153
Ok(())
148154
}
149-
(_, Full) => escape_into(writer, value, |ch| match ch {
155+
(Text, Full) => escape_into(writer, value, |ch| match ch {
156+
// XML EOL normalization (Section 2.11) would convert \r to \n
157+
b'\r' => true,
150158
// Required characters to escape
151159
b'&' | b'<' | b'>' | b'\'' | b'\"' => true,
152160
_ => false,
153161
}),
154-
//----------------------------------------------------------------------
155162
(Text, Partial) => escape_into(writer, value, |ch| match ch {
163+
// XML EOL normalization (Section 2.11) would convert \r to \n
164+
b'\r' => true,
156165
// Required characters to escape
157166
b'&' | b'<' | b'>' => true,
158167
_ => false,
159168
}),
160169
(Text, Minimal) => escape_into(writer, value, |ch| match ch {
170+
// XML EOL normalization (Section 2.11) would convert \r to \n
171+
b'\r' => true,
161172
// Required characters to escape
162173
b'&' | b'<' => true,
163174
_ => false,
164175
}),
165176
//----------------------------------------------------------------------
177+
(_, Full) => escape_into(writer, value, |ch| match ch {
178+
// XML EOL normalization (Section 2.11) and attribute-value normalization
179+
// (Section 3.3.3) would convert these to \n and space respectively
180+
b'\r' | b'\n' | b'\t' => true,
181+
// Required characters to escape
182+
b'&' | b'<' | b'>' | b'\'' | b'\"' => true,
183+
_ => false,
184+
}),
185+
//----------------------------------------------------------------------
166186
(DoubleQAttr, Partial) => escape_into(writer, value, |ch| match ch {
187+
// XML EOL / attribute-value normalization (Section 2.11, Section 3.3.3)
188+
b'\r' | b'\n' | b'\t' => true,
167189
// Required characters to escape
168190
b'&' | b'<' | b'>' => true,
169191
// Double quoted attribute should escape quote
170192
b'"' => true,
171193
_ => false,
172194
}),
173195
(DoubleQAttr, Minimal) => escape_into(writer, value, |ch| match ch {
196+
// XML EOL / attribute-value normalization (Section 2.11, Section 3.3.3)
197+
b'\r' | b'\n' | b'\t' => true,
174198
// Required characters to escape
175199
b'&' | b'<' => true,
176200
// Double quoted attribute should escape quote
@@ -179,13 +203,17 @@ where
179203
}),
180204
//----------------------------------------------------------------------
181205
(SingleQAttr, Partial) => escape_into(writer, value, |ch| match ch {
206+
// XML EOL / attribute-value normalization (Section 2.11, Section 3.3.3)
207+
b'\r' | b'\n' | b'\t' => true,
182208
// Required characters to escape
183209
b'&' | b'<' | b'>' => true,
184210
// Single quoted attribute should escape quote
185211
b'\'' => true,
186212
_ => false,
187213
}),
188214
(SingleQAttr, Minimal) => escape_into(writer, value, |ch| match ch {
215+
// XML EOL / attribute-value normalization (Section 2.11, Section 3.3.3)
216+
b'\r' | b'\n' | b'\t' => true,
189217
// Required characters to escape
190218
b'&' | b'<' => true,
191219
// Single quoted attribute should escape quote
@@ -893,7 +921,7 @@ mod tests {
893921
fn text() {
894922
assert_eq!(
895923
escape_list("text<\"'&> \t\n\rtext", QuoteTarget::Text, QuoteLevel::Full),
896-
"text&lt;&quot;&apos;&amp;&gt; \t\n\rtext"
924+
"text&lt;&quot;&apos;&amp;&gt; \t\n&#13;text"
897925
);
898926
}
899927

@@ -905,7 +933,7 @@ mod tests {
905933
QuoteTarget::DoubleQAttr,
906934
QuoteLevel::Full
907935
),
908-
"text&lt;&quot;&apos;&amp;&gt; \t\n\rtext"
936+
"text&lt;&quot;&apos;&amp;&gt; &#9;&#10;&#13;text"
909937
);
910938
}
911939

@@ -917,7 +945,7 @@ mod tests {
917945
QuoteTarget::SingleQAttr,
918946
QuoteLevel::Full
919947
),
920-
"text&lt;&quot;&apos;&amp;&gt; \t\n\rtext"
948+
"text&lt;&quot;&apos;&amp;&gt; &#9;&#10;&#13;text"
921949
);
922950
}
923951
}
@@ -934,7 +962,7 @@ mod tests {
934962
QuoteTarget::Text,
935963
QuoteLevel::Partial
936964
),
937-
"text&lt;\"'&amp;&gt; \t\n\rtext"
965+
"text&lt;\"'&amp;&gt; \t\n&#13;text"
938966
);
939967
}
940968

@@ -946,7 +974,7 @@ mod tests {
946974
QuoteTarget::DoubleQAttr,
947975
QuoteLevel::Partial
948976
),
949-
"text&lt;&quot;'&amp;&gt; \t\n\rtext"
977+
"text&lt;&quot;'&amp;&gt; &#9;&#10;&#13;text"
950978
);
951979
}
952980

@@ -958,7 +986,7 @@ mod tests {
958986
QuoteTarget::SingleQAttr,
959987
QuoteLevel::Partial
960988
),
961-
"text&lt;\"&apos;&amp;&gt; \t\n\rtext"
989+
"text&lt;\"&apos;&amp;&gt; &#9;&#10;&#13;text"
962990
);
963991
}
964992
}
@@ -975,7 +1003,7 @@ mod tests {
9751003
QuoteTarget::Text,
9761004
QuoteLevel::Minimal
9771005
),
978-
"text&lt;\"'&amp;> \t\n\rtext"
1006+
"text&lt;\"'&amp;> \t\n&#13;text"
9791007
);
9801008
}
9811009

@@ -987,7 +1015,7 @@ mod tests {
9871015
QuoteTarget::DoubleQAttr,
9881016
QuoteLevel::Minimal
9891017
),
990-
"text&lt;&quot;'&amp;> \t\n\rtext"
1018+
"text&lt;&quot;'&amp;> &#9;&#10;&#13;text"
9911019
);
9921020
}
9931021

@@ -999,7 +1027,7 @@ mod tests {
9991027
QuoteTarget::SingleQAttr,
10001028
QuoteLevel::Minimal
10011029
),
1002-
"text&lt;\"&apos;&amp;> \t\n\rtext"
1030+
"text&lt;\"&apos;&amp;> &#9;&#10;&#13;text"
10031031
);
10041032
}
10051033
}

‎tests/serde-issues.rs‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,65 @@ fn issue655() {
539539
);
540540
}
541541

542+
/// Regression tests for https://github.com/tafia/quick-xml/issues/670.
543+
///
544+
/// The serde serializer must escape `\r`, `\n`, and `\t` in attribute values so
545+
/// they survive XML attribute-value normalization (Section 3.3.3) on round-trip.
546+
mod issue670 {
547+
use super::*;
548+
use pretty_assertions::assert_eq;
549+
550+
#[derive(Debug, Deserialize, Serialize, PartialEq)]
551+
struct Root {
552+
#[serde(rename = "@value")]
553+
value: String,
554+
}
555+
556+
#[test]
557+
fn cr_lf_in_attribute() {
558+
let value = Root {
559+
value: "new\r\nline".into(),
560+
};
561+
let xml = to_string(&value).unwrap();
562+
assert_eq!(xml, r#"<Root value="new&#13;&#10;line"/>"#);
563+
let roundtripped: Root = from_str(&xml).unwrap();
564+
assert_eq!(roundtripped, value);
565+
}
566+
567+
#[test]
568+
fn cr_in_attribute() {
569+
let value = Root {
570+
value: "new\rline".into(),
571+
};
572+
let xml = to_string(&value).unwrap();
573+
assert_eq!(xml, r#"<Root value="new&#13;line"/>"#);
574+
let roundtripped: Root = from_str(&xml).unwrap();
575+
assert_eq!(roundtripped, value);
576+
}
577+
578+
#[test]
579+
fn lf_in_attribute() {
580+
let value = Root {
581+
value: "new\nline".into(),
582+
};
583+
let xml = to_string(&value).unwrap();
584+
assert_eq!(xml, r#"<Root value="new&#10;line"/>"#);
585+
let roundtripped: Root = from_str(&xml).unwrap();
586+
assert_eq!(roundtripped, value);
587+
}
588+
589+
#[test]
590+
fn tab_in_attribute() {
591+
let value = Root {
592+
value: "col1\tcol2".into(),
593+
};
594+
let xml = to_string(&value).unwrap();
595+
assert_eq!(xml, r#"<Root value="col1&#9;col2"/>"#);
596+
let roundtripped: Root = from_str(&xml).unwrap();
597+
assert_eq!(roundtripped, value);
598+
}
599+
}
600+
542601
/// Regression test for https://github.com/tafia/quick-xml/issues/674
543602
#[test]
544603
fn issue674() {
@@ -1238,3 +1297,99 @@ mod issue978 {
12381297
assert_eq!(result.unwrap().item.len(), 200);
12391298
}
12401299
}
1300+
1301+
/// Regression tests for https://github.com/tafia/quick-xml/issues/990.
1302+
///
1303+
/// The serde serializer must escape `\r` in text content so it survives
1304+
/// XML end-of-line normalization (Section 2.11) on round-trip.
1305+
///
1306+
/// This behavior is not stated directly in the spec, but it is exhibited
1307+
/// by implementations such as libxml2.
1308+
mod issue990 {
1309+
use super::*;
1310+
use pretty_assertions::assert_eq;
1311+
1312+
#[derive(Debug, Deserialize, Serialize, PartialEq)]
1313+
#[serde(rename = "value")]
1314+
struct Value {
1315+
#[serde(rename = "$value")]
1316+
value: String,
1317+
}
1318+
1319+
#[test]
1320+
fn cr_in_text_content() {
1321+
let value = Value {
1322+
value: "hello\rworld".into(),
1323+
};
1324+
let xml = to_string(&value).unwrap();
1325+
assert_eq!(xml, "<value>hello&#13;world</value>");
1326+
let roundtripped: Value = from_str(&xml).unwrap();
1327+
assert_eq!(roundtripped, value);
1328+
}
1329+
1330+
#[test]
1331+
fn cr_lf_in_text_content() {
1332+
let value = Value {
1333+
value: "hello\r\nworld".into(),
1334+
};
1335+
let xml = to_string(&value).unwrap();
1336+
assert_eq!(xml, "<value>hello&#13;\nworld</value>");
1337+
let roundtripped: Value = from_str(&xml).unwrap();
1338+
assert_eq!(roundtripped, value);
1339+
}
1340+
1341+
#[test]
1342+
fn lf_in_text_content_not_escaped() {
1343+
let value = Value {
1344+
value: "hello\nworld".into(),
1345+
};
1346+
let xml = to_string(&value).unwrap();
1347+
// \n is valid in text content and not subject to EOL normalization,
1348+
// so it should NOT be escaped
1349+
assert_eq!(xml, "<value>hello\nworld</value>");
1350+
let roundtripped: Value = from_str(&xml).unwrap();
1351+
assert_eq!(roundtripped, value);
1352+
}
1353+
1354+
/// CDATA sections cannot preserve `\r` because character references are
1355+
/// not permitted inside CDATA. This is an inherent XML limitation, the
1356+
/// round-trip is lossy in this case.
1357+
#[test]
1358+
fn cdata_cr_is_lossy() {
1359+
let mut buffer = String::new();
1360+
let mut ser = Serializer::with_root(&mut buffer, Some("value")).unwrap();
1361+
ser.text_format(quick_xml::se::TextFormat::CData);
1362+
1363+
let value = Value {
1364+
value: "hello\rworld".into(),
1365+
};
1366+
value.serialize(ser).unwrap();
1367+
1368+
// The serializer writes \r literally inside CDATA (it cannot escape it)
1369+
assert_eq!(buffer, "<value><![CDATA[hello\rworld]]></value>");
1370+
1371+
// But XML EOL normalization converts \r to \n on parse, so round-trip
1372+
// produces \n instead of \r
1373+
let parsed: Value = from_str(&buffer).unwrap();
1374+
assert_eq!(parsed.value, "hello\nworld");
1375+
assert_ne!(parsed, value);
1376+
}
1377+
1378+
/// CDATA with CRLF is also lossy: \r\n becomes \n.
1379+
#[test]
1380+
fn cdata_cr_lf_is_lossy() {
1381+
let mut buffer = String::new();
1382+
let mut ser = Serializer::with_root(&mut buffer, Some("value")).unwrap();
1383+
ser.text_format(quick_xml::se::TextFormat::CData);
1384+
1385+
let value = Value {
1386+
value: "hello\r\nworld".into(),
1387+
};
1388+
value.serialize(ser).unwrap();
1389+
1390+
assert_eq!(buffer, "<value><![CDATA[hello\r\nworld]]></value>");
1391+
1392+
let parsed: Value = from_str(&buffer).unwrap();
1393+
assert_eq!(parsed.value, "hello\nworld");
1394+
}
1395+
}

0 commit comments

Comments
 (0)