Skip to content

Escape \r in text and \r/\n/\t in attributes during serde serialization - #1001

Draft
dralley wants to merge 1 commit into
tafia:masterfrom
dralley:end-of-line
Draft

Escape \r in text and \r/\n/\t in attributes during serde serialization#1001
dralley wants to merge 1 commit into
tafia:masterfrom
dralley:end-of-line

Conversation

@dralley

@dralley dralley commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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 in text content at all QuoteLevels, and escapes \r/\n/\t as / / 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 #670
closes #990

Assisted-By: Claude Opus 4.6

@dralley
dralley force-pushed the end-of-line branch 2 times, most recently from 4c98756 to 3a3a675 Compare August 5, 2026 03:29
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
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 56.02%. Comparing base (e00ae5c) to head (301b3a7).
⚠️ Report is 35 commits behind head on master.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1001      +/-   ##
==========================================
- Coverage   57.31%   56.02%   -1.29%     
==========================================
  Files          46       47       +1     
  Lines       18197    18387     +190     
==========================================
- Hits        10429    10302     -127     
- Misses       7768     8085     +317     
Flag Coverage Δ
unittests 56.02% <100.00%> (-1.29%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dralley

dralley commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Output from the serde API now matches libxml2 and roundtrips correctly

use quick_xml::de::from_str;
use quick_xml::se::{to_string, Serializer};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename = "value")]
struct TextValue {
    #[serde(rename = "$value")]
    value: String,
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
struct Root {
    #[serde(rename = "@value")]
    value: String,
}

fn main() {
    println!("=== TEXT CONTENT ===\n");

    // CR in text
    let v = TextValue { value: "hello\rworld".into() };
    let xml = to_string(&v).unwrap();
    println!("CR in text - serialized: {:?}", xml);
    let parsed: TextValue = from_str(&xml).unwrap();
    println!("CR in text - parsed:     {:?}", parsed.value);
    println!("CR in text - roundtrip:  {}\n", parsed == v);

    // CRLF in text
    let v = TextValue { value: "hello\r\nworld".into() };
    let xml = to_string(&v).unwrap();
    println!("CRLF in text - serialized: {:?}", xml);
    let parsed: TextValue = from_str(&xml).unwrap();
    println!("CRLF in text - parsed:     {:?}", parsed.value);
    println!("CRLF in text - roundtrip:  {}\n", parsed == v);

    // LF in text
    let v = TextValue { value: "hello\nworld".into() };
    let xml = to_string(&v).unwrap();
    println!("LF in text - serialized: {:?}", xml);
    let parsed: TextValue = from_str(&xml).unwrap();
    println!("LF in text - parsed:     {:?}", parsed.value);
    println!("LF in text - roundtrip:  {}\n", parsed == v);

    // TAB in text
    let v = TextValue { value: "col1\tcol2".into() };
    let xml = to_string(&v).unwrap();
    println!("TAB in text - serialized: {:?}", xml);
    let parsed: TextValue = from_str(&xml).unwrap();
    println!("TAB in text - parsed:     {:?}", parsed.value);
    println!("TAB in text - roundtrip:  {}\n", parsed == v);

    println!("=== ATTRIBUTE VALUES ===\n");

    // CR in attr
    let v = Root { value: "new\rline".into() };
    let xml = to_string(&v).unwrap();
    println!("CR in attr - serialized: {:?}", xml);
    let parsed: Root = from_str(&xml).unwrap();
    println!("CR in attr - parsed:     {:?}", parsed.value);
    println!("CR in attr - roundtrip:  {}\n", parsed == v);

    // CRLF in attr
    let v = Root { value: "new\r\nline".into() };
    let xml = to_string(&v).unwrap();
    println!("CRLF in attr - serialized: {:?}", xml);
    let parsed: Root = from_str(&xml).unwrap();
    println!("CRLF in attr - parsed:     {:?}", parsed.value);
    println!("CRLF in attr - roundtrip:  {}\n", parsed == v);

    // LF in attr
    let v = Root { value: "new\nline".into() };
    let xml = to_string(&v).unwrap();
    println!("LF in attr - serialized: {:?}", xml);
    let parsed: Root = from_str(&xml).unwrap();
    println!("LF in attr - parsed:     {:?}", parsed.value);
    println!("LF in attr - roundtrip:  {}\n", parsed == v);

    // TAB in attr
    let v = Root { value: "col1\tcol2".into() };
    let xml = to_string(&v).unwrap();
    println!("TAB in attr - serialized: {:?}", xml);
    let parsed: Root = from_str(&xml).unwrap();
    println!("TAB in attr - parsed:     {:?}", parsed.value);
    println!("TAB in attr - roundtrip:  {}\n", parsed == v);

    println!("=== CDATA ===\n");

    // CR in CDATA
    let v = TextValue { value: "hello\rworld".into() };
    let mut buffer = String::new();
    let mut ser = Serializer::with_root(&mut buffer, Some("value")).unwrap();
    ser.text_format(quick_xml::se::TextFormat::CData);
    v.serialize(ser).unwrap();
    println!("CR in CDATA - serialized: {:?}", buffer);
    let parsed: TextValue = from_str(&buffer).unwrap();
    println!("CR in CDATA - parsed:     {:?}\n", parsed.value);

    // CRLF in CDATA
    let v = TextValue { value: "hello\r\nworld".into() };
    let mut buffer = String::new();
    let mut ser = Serializer::with_root(&mut buffer, Some("value")).unwrap();
    ser.text_format(quick_xml::se::TextFormat::CData);
    v.serialize(ser).unwrap();
    println!("CRLF in CDATA - serialized: {:?}", buffer);
    let parsed: TextValue = from_str(&buffer).unwrap();
    println!("CRLF in CDATA - parsed:     {:?}\n", parsed.value);

    // LF in CDATA
    let v = TextValue { value: "hello\nworld".into() };
    let mut buffer = String::new();
    let mut ser = Serializer::with_root(&mut buffer, Some("value")).unwrap();
    ser.text_format(quick_xml::se::TextFormat::CData);
    v.serialize(ser).unwrap();
    println!("LF in CDATA - serialized: {:?}", buffer);
    let parsed: TextValue = from_str(&buffer).unwrap();
    println!("LF in CDATA - parsed:     {:?}\n", parsed.value);

    // Space in text
    let v = TextValue { value: "with spaces".into() };
    let xml = to_string(&v).unwrap();
    println!("Space in text - serialized: {:?}", xml);

    // Space in attr
    let v = Root { value: "with spaces".into() };
    let xml = to_string(&v).unwrap();
    println!("Space in attr - serialized: {:?}", xml);
    let parsed: Root = from_str(&xml).unwrap();
    println!("Space in attr - parsed:     {:?}", parsed.value);
    println!("Space in attr - roundtrip:  {}", parsed == v);
}
=== TEXT CONTENT ===

CR in text - serialized: "<value>hello&#13;world</value>"
CR in text - parsed:     "hello\rworld"
CR in text - roundtrip:  true

CRLF in text - serialized: "<value>hello&#13;\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&#13;line\"/>"
CR in attr - parsed:     "new\rline"
CR in attr - roundtrip:  true

CRLF in attr - serialized: "<Root value=\"new&#13;&#10;line\"/>"
CRLF in attr - parsed:     "new\r\nline"
CRLF in attr - roundtrip:  true

LF in attr - serialized: "<Root value=\"new&#10;line\"/>"
LF in attr - parsed:     "new\nline"
LF in attr - roundtrip:  true

TAB in attr - serialized: "<Root value=\"col1&#9;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

@dralley

dralley commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

So, this fixes #670 and #990 for the serde API, but it does not yet fix it for the raw Event / Writer API.

For that, the escape(), partial_escape() and minimal_escape() functions are no longer sufficient because in attributes, \n and \t` need to be escaped when writing in order to roundtrip properly, so it's context-sensitive between Text and Attributes.

Still working on that part.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

deserializing inconsistently drops / rewrites whitespace characters (especially \r) since v0.38.0 xml serde roundtrip loses CR/LF encoding

2 participants