You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
It appears that the whitespace-preserving changes added to the Deserializer in v0.38.0 also unintentionally (and inconsistently) "normalize" some whitespace on deserialization - but not on serialization.
Minimal reproducer with quick-xml = { version = "0.41", features = ["serialize"] }:
use quick_xml::de::from_str;use quick_xml::se::to_string;use serde::{Deserialize,Serialize};#[derive(Debug,Deserialize,PartialEq,Serialize)]#[serde(rename = "value")]structValue{#[serde(rename = "$value")]value:String,}fnmain(){let string = "<value>hello\rworld</value>";let value = Value{value:String::from("hello\rworld")};assert_eq!(to_string(&value).unwrap(), string);assert_eq!(from_str::<Value>(string).unwrap(), value);}
The first assertion passes (whitespace preserved as-is), the second assertion fails (\r rewritten to \n on deserialization), so it looks like serializing preserves existing whitespace, while deserializing rewrites some existing whitespace characters.
Yes, I know that a lone \r (case 1) does not make much sense here, but the same can also be reproduced when using "hello\r\nworld" using Windows-style CRLF line endings (case 2): This value serializes as expected, but deserializes as "hello\nworld".
I think the \r character should be preserved in either case, and not get inconsistently rewritten (case 1) or dropped (case 2).
XML parsed entities are often stored in computer files which, for editing convenience, are organized into lines. These lines are typically separated by some combination of the characters CARRIAGE RETURN (#xD) and LINE FEED (#xA).
To simplify the tasks of applications, the XML processor MUST behave as if it normalized all line breaks in external parsed entities (including the document entity) on input, before parsing, by translating both the two-character sequence #xD #xA and any #xD that is not followed by #xA to a single #xA character.
So I believe it would be expected and normal for the lone /r to be dropped and replaced with /n - any compliant XML parser would do that.
The actual bug, arguably, is that the serializer should either escape \r to a character reference in order to preserve it or do the same normalization on the output. I need to think about it a bit, I'm recovering from a cold so my brain isn't at 100%.
Either way the normalization is fundamentally "lossy" and I don't think you can expect in == out in the literal byte sense 100% of the time. From the perspective of an XML processor, hello\rworld and hello\nworld and hello\r\nworld are equivalent.
Thanks, that context somewhat explains what's happening.
It would still be great if strings like that could - at least in some limited* sense - be round-trip safe.
limited*: applying some normalization myself to ensure round-trip-ability would be fine, but I don't want to have to replicate whatever logic happens in the internals of quick_xml::de::from_str for this :(
Does encoding (escaping) the return as a character reference (
) in the input work? (that doesn't "round-trip" exactly, under the precise definition, but it should preserve the \r through the deserialization step)
Yes, deserialization normalizes EOLs as that is required by the XML specification, as @dralley pointed. That was implemented in #884. The serialization part was never be fully checked for correctness.
I don't think this is the case, at least not entirely ...
Running the reproducer code snippet from above with quick-xml 0.37.5, there is no failure, and the \r is preserved as-is, both during de- and serialization. So the problem specifically started happening between 0.37.5 and 0.38.0. Issue #670 is much older.
Does encoding (escaping) the return as a character reference (
) in the input work? (that doesn't "round-trip" exactly, under the precise definition, but it should preserve the \r through the deserialization step)
Yes, this works for deserialization (i.e. let string = "<value>hello
world</value>"; in the reproducer code).
Should \r get escaped in QuoteLevel::Full serialization mode maybe? That would partly of the mismatch betwen de- and serialization behaviour. (I dropped this from my minimal reproducer because it wasn't necessary to trigger the issue I've reported, but in my actual code I use QuoteLevel::Full).
Interestingly, python's xml.etree.ElementTree does not escape \r when serializing, but lxml does.
importxml.etree.ElementTreeasET# Build an element with a carriage return in its textroot=ET.Element('value')
root.text='hello\rworld'# Serialize itxml_bytes=ET.tostring(root, encoding='unicode')
print('Serialized:', repr(xml_bytes))
# Parse it backparsed=ET.fromstring(xml_bytes)
print('Parsed text:', repr(parsed.text))
# Now try without escaping (manual XML with literal \r)manual_xml='<value>hello\rworld</value>'parsed2=ET.fromstring(manual_xml)
print('Parsed literal CR:', repr(parsed2.text))
# And CRLFmanual_xml3='<value>hello\r\nworld</value>'parsed3=ET.fromstring(manual_xml3)
print('Parsed literal CRLF:', repr(parsed3.text))
I'm generally comfortable with assuming that libxml2 (lxml) is correct here and that \r needs to be escaped when serializing. I'll put up a PR later that addresses both this issue and #670
fromlxmlimportetreeprint('=== TEXT CONTENT ===')
print()
# CR in textroot=etree.Element('value')
root.text='hello\rworld'xml=etree.tostring(root, encoding='unicode')
print(f'CR in text - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'CR in text - parsed: {parsed.text!r}')
print(f'CR in text - roundtrip: {parsed.text==root.text}')
print()
# CRLF in textroot=etree.Element('value')
root.text='hello\r\nworld'xml=etree.tostring(root, encoding='unicode')
print(f'CRLF in text - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'CRLF in text - parsed: {parsed.text!r}')
print(f'CRLF in text - roundtrip: {parsed.text==root.text}')
print()
# LF in text (should NOT be escaped)root=etree.Element('value')
root.text='hello\nworld'xml=etree.tostring(root, encoding='unicode')
print(f'LF in text - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'LF in text - parsed: {parsed.text!r}')
print(f'LF in text - roundtrip: {parsed.text==root.text}')
print()
# Tab in text (should NOT be escaped)root=etree.Element('value')
root.text='col1\tcol2'xml=etree.tostring(root, encoding='unicode')
print(f'TAB in text - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'TAB in text - parsed: {parsed.text!r}')
print(f'TAB in text - roundtrip: {parsed.text==root.text}')
print()
print('=== ATTRIBUTE VALUES ===')
print()
# CR in attrroot=etree.Element('root')
root.set('value', 'new\rline')
xml=etree.tostring(root, encoding='unicode')
print(f'CR in attr - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'CR in attr - parsed: {parsed.get(\"value\")!r}')
print(f'CR in attr - roundtrip: {parsed.get(\"value\") == root.get(\"value\")}')
print()
# CRLF in attrroot=etree.Element('root')
root.set('value', 'new\r\nline')
xml=etree.tostring(root, encoding='unicode')
print(f'CRLF in attr - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'CRLF in attr - parsed: {parsed.get(\"value\")!r}')
print(f'CRLF in attr - roundtrip: {parsed.get(\"value\") == root.get(\"value\")}')
print()
# LF in attrroot=etree.Element('root')
root.set('value', 'new\nline')
xml=etree.tostring(root, encoding='unicode')
print(f'LF in attr - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'LF in attr - parsed: {parsed.get(\"value\")!r}')
print(f'LF in attr - roundtrip: {parsed.get(\"value\") == root.get(\"value\")}')
print()
# TAB in attrroot=etree.Element('root')
root.set('value', 'col1\tcol2')
xml=etree.tostring(root, encoding='unicode')
print(f'TAB in attr - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'TAB in attr - parsed: {parsed.get(\"value\")!r}')
print(f'TAB in attr - roundtrip: {parsed.get(\"value\") == root.get(\"value\")}')
print()
print('=== CDATA ===')
print()
# CR in CDATAroot=etree.Element('value')
root.text=etree.CDATA('hello\rworld')
xml=etree.tostring(root, encoding='unicode')
print(f'CR in CDATA - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'CR in CDATA - parsed: {parsed.text!r}')
print()
# CRLF in CDATAroot=etree.Element('value')
root.text=etree.CDATA('hello\r\nworld')
xml=etree.tostring(root, encoding='unicode')
print(f'CRLF in CDATA - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'CRLF in CDATA - parsed: {parsed.text!r}')
print()
# LF in CDATAroot=etree.Element('value')
root.text=etree.CDATA('hello\nworld')
xml=etree.tostring(root, encoding='unicode')
print(f'LF in CDATA - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'LF in CDATA - parsed: {parsed.text!r}')
print()
# Space in text (should NOT be escaped)root=etree.Element('value')
root.text='with spaces'xml=etree.tostring(root, encoding='unicode')
print(f'Space in text - serialized: {xml!r}')
# Space in attr (should NOT be escaped)root=etree.Element('root')
root.set('value', 'with spaces')
xml=etree.tostring(root, encoding='unicode')
print(f'Space in attr - serialized: {xml!r}')
parsed=etree.fromstring(xml)
print(f'Space in attr - parsed: {parsed.get(\"value\")!r}')
print(f'Space in attr - roundtrip: {parsed.get(\"value\") == root.get(\"value\")}')
=== TEXT CONTENT ===
CR in text - serialized: '<value>hello world</value>'
CR in text - parsed: 'hello\rworld'
CR in text - roundtrip: True
CRLF in text - serialized: '<value>hello \nworld</value>'
CRLF in text - parsed: 'hello\r\nworld'
CRLF in text - roundtrip: True
LF in text - serialized: '<value>hello\nworld</value>'
LF in text - parsed: 'hello\nworld'
LF in text - roundtrip: True
TAB in text - serialized: '<value>col1\tcol2</value>'
TAB in text - parsed: 'col1\tcol2'
TAB in text - roundtrip: True
=== ATTRIBUTE VALUES ===
CR in attr - serialized: '<root value="new line"/>'
CR in attr - parsed: 'new\rline'
CR in attr - roundtrip: True
CRLF in attr - serialized: '<root value="new line"/>'
CRLF in attr - parsed: 'new\r\nline'
CRLF in attr - roundtrip: True
LF in attr - serialized: '<root value="new line"/>'
LF in attr - parsed: 'new\nline'
LF in attr - roundtrip: True
TAB in attr - serialized: '<root value="col1	col2"/>'
TAB in attr - parsed: 'col1\tcol2'
TAB in attr - roundtrip: True
=== CDATA ===
CR in CDATA - serialized: '<value><![CDATA[hello\rworld]]></value>'
CR in CDATA - parsed: 'hello\nworld'
CRLF in CDATA - serialized: '<value><![CDATA[hello\r\nworld]]></value>'
CRLF in CDATA - parsed: 'hello\nworld'
LF in CDATA - serialized: '<value><![CDATA[hello\nworld]]></value>'
LF in CDATA - parsed: 'hello\nworld'
Space in text - serialized: '<value>with spaces</value>'
Space in attr - serialized: '<root value="with spaces"/>'
Space in attr - parsed: 'with spaces'
Space in attr - roundtrip: True
Activity
dralley commentedon Jul 30, 2026
So, the XML specification (https://www.w3.org/TR/xml/#sec-line-ends) says the following
So I believe it would be expected and normal for the lone
/rto be dropped and replaced with/n- any compliant XML parser would do that.The actual bug, arguably, is that the serializer should either escape
\rto a character reference in order to preserve it or do the same normalization on the output. I need to think about it a bit, I'm recovering from a cold so my brain isn't at 100%.Either way the normalization is fundamentally "lossy" and I don't think you can expect in == out in the literal byte sense 100% of the time. From the perspective of an XML processor,
hello\rworldandhello\nworldandhello\r\nworldare equivalent.decathorpe commentedon Jul 30, 2026
Thanks, that context somewhat explains what's happening.
It would still be great if strings like that could - at least in some limited* sense - be round-trip safe.
limited*: applying some normalization myself to ensure round-trip-ability would be fine, but I don't want to have to replicate whatever logic happens in the internals of
quick_xml::de::from_strfor this :(dralley commentedon Jul 30, 2026
Does encoding (escaping) the return as a character reference (

) in the input work? (that doesn't "round-trip" exactly, under the precise definition, but it should preserve the\rthrough the deserialization step)dralley commentedon Jul 30, 2026
@decathorpe On an entirely unrelated note - you may be interested in this - I've been getting the Rust bindings for librpm (https://github.com/rpm-software-management/librpm.rs/) up to parity with (and beyond in some cases) the Python bindings.
And a few other projects at that intersection with respect to packaging, dependency solving, and working with repo metadata. In Rust.
Mingun commentedon Jul 30, 2026
Yes, deserialization normalizes EOLs as that is required by the XML specification, as @dralley pointed. That was implemented in #884. The serialization part was never be fully checked for correctness.
It seems to me that this is duplicate of #670.
decathorpe commentedon Jul 30, 2026
I don't think this is the case, at least not entirely ...
Running the reproducer code snippet from above with quick-xml 0.37.5, there is no failure, and the
\ris preserved as-is, both during de- and serialization. So the problem specifically started happening between 0.37.5 and 0.38.0. Issue #670 is much older.Oh, interesting. I hadn't realized that this project was getting attention again :)
decathorpe commentedon Jul 30, 2026
Yes, this works for deserialization (i.e.
let string = "<value>hello
world</value>";in the reproducer code).Should
\rget escaped inQuoteLevel::Fullserialization mode maybe? That would partly of the mismatch betwen de- and serialization behaviour. (I dropped this from my minimal reproducer because it wasn't necessary to trigger the issue I've reported, but in my actual code I useQuoteLevel::Full).dralley commentedon Jul 31, 2026
Interestingly, python's
xml.etree.ElementTreedoes not escape\rwhen serializing, butlxmldoes.dralley commentedon Jul 31, 2026
I agree it's not an exact duplicate of #670, it's a very similar issue but different contexts, #670 is wr/r/t attribute values.
dralley commentedon Jul 31, 2026
I'm generally comfortable with assuming that libxml2 (lxml) is correct here and that \r needs to be escaped when serializing. I'll put up a PR later that addresses both this issue and #670
Escape \r in text and \r/\n/\t in attributes during serde serialization
dralley commentedon Aug 5, 2026
A more comprehensive comparison:
Escape \r in text and \r/\n/\t in attributes during serde serialization
Escape \r in text and \r/\n/\t in attributes during serde serialization
Escape \r in text and \r/\n/\t in attributes during serde serialization
Escape \r in text and \r/\n/\t in attributes during serde serialization
Escape \r in text and \r/\n/\t in attributes during serde serialization
Escape \r in text and \r/\n/\t in attributes during serde serialization
Escape \r in text and \r/\n/\t in attributes during serde serialization
Escape \r in text and \r/\n/\t in attributes during serde serialization