Skip to main content

contextual_encoder/
xml.rs

1//! XML-specific contextual output encoders.
2//!
3//! provides XML aliases for the HTML encoders, plus XML-only contexts:
4//!
5//! ## XML 1.0 aliases
6//!
7//! - [`for_xml`] — alias for [`crate::for_html`]
8//! - [`for_xml_content`] — alias for [`crate::for_html_content`]
9//! - [`for_xml_attribute`] — alias for [`crate::for_html_attribute`]
10//!
11//! ## XML-only contexts
12//!
13//! - [`for_xml_comment`] — safe for XML comment content
14//! - [`for_cdata`] — safe for CDATA section content
15//!
16//! ## XML 1.1
17//!
18//! - [`for_xml11`] — XML 1.1 content + attributes
19//! - [`for_xml11_content`] — XML 1.1 content only
20//! - [`for_xml11_attribute`] — XML 1.1 attributes only
21//!
22//! # security notes
23//!
24//! - `for_xml_comment` is **not safe for HTML comments**. HTML comments have
25//!   vendor-specific extensions (e.g., `<!--[if IE]>`) that make safe encoding
26//!   impractical. this encoder is for XML comments only.
27//! - `for_cdata` splits CDATA sections to prevent premature closing. the
28//!   caller is responsible for wrapping the output in `<![CDATA[...]]>`.
29
30use std::fmt;
31
32use crate::engine::{encode_loop, is_invalid_for_xml, is_unicode_noncharacter};
33
34/// encodes `input` for safe embedding in XML text content and quoted attributes.
35///
36/// this is an alias for [`crate::for_html`] — the encoding rules are identical.
37///
38/// # examples
39///
40/// ```
41/// use contextual_encoder::for_xml;
42///
43/// assert_eq!(for_xml("<root attr=\"val\">"), "&lt;root attr=&#34;val&#34;&gt;");
44/// ```
45pub fn for_xml(input: &str) -> String {
46    crate::html::for_html(input)
47}
48
49/// writes the XML-encoded form of `input` to `out`.
50///
51/// see [`for_xml`] for encoding rules.
52pub fn write_xml<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
53    crate::html::write_html(out, input)
54}
55
56/// encodes `input` for safe embedding in XML text content only.
57///
58/// this is an alias for [`crate::for_html_content`] — the encoding rules are
59/// identical. **not safe for attributes** (does not encode quotes).
60///
61/// # examples
62///
63/// ```
64/// use contextual_encoder::for_xml_content;
65///
66/// assert_eq!(for_xml_content("a < b & c"), "a &lt; b &amp; c");
67/// ```
68pub fn for_xml_content(input: &str) -> String {
69    crate::html::for_html_content(input)
70}
71
72/// writes the XML-content-encoded form of `input` to `out`.
73///
74/// see [`for_xml_content`] for encoding rules.
75pub fn write_xml_content<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
76    crate::html::write_html_content(out, input)
77}
78
79/// encodes `input` for safe embedding in a quoted XML attribute value.
80///
81/// this is an alias for [`crate::for_html_attribute`] — the encoding rules
82/// are identical. **not safe for text content** (does not encode `>`).
83///
84/// # examples
85///
86/// ```
87/// use contextual_encoder::for_xml_attribute;
88///
89/// assert_eq!(for_xml_attribute("a\"b"), "a&#34;b");
90/// ```
91pub fn for_xml_attribute(input: &str) -> String {
92    crate::html::for_html_attribute(input)
93}
94
95/// writes the XML-attribute-encoded form of `input` to `out`.
96///
97/// see [`for_xml_attribute`] for encoding rules.
98pub fn write_xml_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
99    crate::html::write_html_attribute(out, input)
100}
101
102/// encodes `input` for safe embedding in an XML comment (`<!-- ... -->`).
103///
104/// the XML specification forbids `--` inside comments and a trailing `-`
105/// (which would form `--->` with the closing delimiter). this encoder
106/// replaces the second hyphen in any `--` sequence with `~`, and replaces
107/// a trailing `-` with `~`.
108///
109/// invalid XML characters are replaced with a space.
110///
111/// # security warning
112///
113/// this encoder is **not safe for HTML comments**. browsers interpret
114/// vendor-specific extensions like `<!--[if IE]>` that cannot be neutralized
115/// by encoding. never embed untrusted data in HTML comments.
116///
117/// # examples
118///
119/// ```
120/// use contextual_encoder::for_xml_comment;
121///
122/// assert_eq!(for_xml_comment("safe text"), "safe text");
123/// assert_eq!(for_xml_comment("a--b"), "a-~b");
124/// assert_eq!(for_xml_comment("trailing-"), "trailing~");
125/// ```
126pub fn for_xml_comment(input: &str) -> String {
127    let mut out = String::with_capacity(input.len());
128    write_xml_comment(&mut out, input).expect("writing to string cannot fail");
129    out
130}
131
132/// writes the XML-comment-encoded form of `input` to `out`.
133///
134/// see [`for_xml_comment`] for encoding rules.
135pub fn write_xml_comment<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
136    let mut last_was_hyphen = false;
137    let mut chars = input.chars().peekable();
138
139    while let Some(c) = chars.next() {
140        if c == '-' {
141            if last_was_hyphen {
142                // second hyphen in -- sequence → replace with ~
143                out.write_char('~')?;
144                last_was_hyphen = false;
145            } else if chars.peek().is_none() {
146                // trailing hyphen → replace with ~
147                out.write_char('~')?;
148            } else {
149                out.write_char('-')?;
150                last_was_hyphen = true;
151            }
152        } else if is_invalid_for_xml(c) {
153            out.write_char(' ')?;
154            last_was_hyphen = false;
155        } else {
156            out.write_char(c)?;
157            last_was_hyphen = false;
158        }
159    }
160
161    Ok(())
162}
163
164/// encodes `input` for safe embedding in an XML CDATA section.
165///
166/// the CDATA closing delimiter `]]>` cannot appear in CDATA content. when
167/// this sequence is found, the encoder splits it by closing the current
168/// CDATA section and immediately opening a new one:
169///
170/// `]]>` → `]]]]><![CDATA[>`
171///
172/// the caller is responsible for wrapping the output in `<![CDATA[...]]>`.
173///
174/// invalid XML characters are replaced with a space.
175///
176/// # examples
177///
178/// ```
179/// use contextual_encoder::for_cdata;
180///
181/// assert_eq!(for_cdata("safe text"), "safe text");
182/// assert_eq!(for_cdata("a]]>b"), "a]]]]><![CDATA[>b");
183/// assert_eq!(for_cdata("]]"), "]]");
184/// ```
185pub fn for_cdata(input: &str) -> String {
186    let mut out = String::with_capacity(input.len());
187    write_cdata(&mut out, input).expect("writing to string cannot fail");
188    out
189}
190
191/// writes the CDATA-encoded form of `input` to `out`.
192///
193/// see [`for_cdata`] for encoding rules.
194pub fn write_cdata<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
195    let mut bracket_count: u32 = 0;
196
197    for c in input.chars() {
198        if c == ']' {
199            bracket_count += 1;
200        } else if c == '>' && bracket_count >= 2 {
201            // found ]]> — flush extra brackets, then split
202            for _ in 0..(bracket_count - 2) {
203                out.write_char(']')?;
204            }
205            out.write_str("]]]]><![CDATA[>")?;
206            bracket_count = 0;
207        } else {
208            // flush buffered brackets
209            for _ in 0..bracket_count {
210                out.write_char(']')?;
211            }
212            bracket_count = 0;
213
214            if is_invalid_for_xml(c) {
215                out.write_char(' ')?;
216            } else {
217                out.write_char(c)?;
218            }
219        }
220    }
221
222    // flush remaining brackets
223    for _ in 0..bracket_count {
224        out.write_char(']')?;
225    }
226
227    Ok(())
228}
229
230/// encodes `input` for safe embedding in XML 1.1 text content and quoted
231/// attributes.
232///
233/// like [`for_xml`] but encodes restricted characters as `&#xHH;` character
234/// references instead of replacing them with space. NUL (U+0000) and unicode
235/// non-characters are still replaced with space (they are invalid in XML 1.1).
236///
237/// NEL (U+0085) is **not** restricted in XML 1.1 and passes through unchanged.
238///
239/// # examples
240///
241/// ```
242/// use contextual_encoder::for_xml11;
243///
244/// assert_eq!(for_xml11("<b>"), "&lt;b&gt;");
245/// // control chars get character references instead of space
246/// assert_eq!(for_xml11("a\x01b"), "a&#x1;b");
247/// // NEL passes through in XML 1.1
248/// assert_eq!(for_xml11("a\u{0085}b"), "a\u{0085}b");
249/// ```
250pub fn for_xml11(input: &str) -> String {
251    let mut out = String::with_capacity(input.len());
252    write_xml11(&mut out, input).expect("writing to string cannot fail");
253    out
254}
255
256/// writes the XML-1.1-encoded form of `input` to `out`.
257///
258/// see [`for_xml11`] for encoding rules.
259pub fn write_xml11<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
260    encode_loop(out, input, needs_xml11_encoding, write_xml11_encoded)
261}
262
263/// encodes `input` for safe embedding in XML 1.1 text content only.
264///
265/// like [`for_xml_content`] but encodes restricted characters as `&#xHH;`
266/// character references. does **not** encode quotes — not safe for attributes.
267///
268/// # examples
269///
270/// ```
271/// use contextual_encoder::for_xml11_content;
272///
273/// assert_eq!(for_xml11_content("a\x01b"), "a&#x1;b");
274/// assert_eq!(for_xml11_content(r#"a"b"#), r#"a"b"#);
275/// ```
276pub fn for_xml11_content(input: &str) -> String {
277    let mut out = String::with_capacity(input.len());
278    write_xml11_content(&mut out, input).expect("writing to string cannot fail");
279    out
280}
281
282/// writes the XML-1.1-content-encoded form of `input` to `out`.
283///
284/// see [`for_xml11_content`] for encoding rules.
285pub fn write_xml11_content<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
286    encode_loop(
287        out,
288        input,
289        needs_xml11_content_encoding,
290        write_xml11_content_encoded,
291    )
292}
293
294/// encodes `input` for safe embedding in a quoted XML 1.1 attribute value.
295///
296/// like [`for_xml_attribute`] but encodes restricted characters as `&#xHH;`
297/// character references. does **not** encode `>`.
298///
299/// # examples
300///
301/// ```
302/// use contextual_encoder::for_xml11_attribute;
303///
304/// assert_eq!(for_xml11_attribute("a\x01b"), "a&#x1;b");
305/// assert_eq!(for_xml11_attribute("a>b"), "a>b");
306/// ```
307pub fn for_xml11_attribute(input: &str) -> String {
308    let mut out = String::with_capacity(input.len());
309    write_xml11_attribute(&mut out, input).expect("writing to string cannot fail");
310    out
311}
312
313/// writes the XML-1.1-attribute-encoded form of `input` to `out`.
314///
315/// see [`for_xml11_attribute`] for encoding rules.
316pub fn write_xml11_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
317    encode_loop(
318        out,
319        input,
320        needs_xml11_attribute_encoding,
321        write_xml11_attribute_encoded,
322    )
323}
324
325/// returns true if the character is restricted in XML 1.1.
326///
327/// restricted characters are: U+0001-U+0008, U+000B-U+000C, U+000E-U+001F,
328/// U+007F-U+0084, U+0086-U+009F. note that NUL (U+0000) is not restricted
329/// but is *invalid* (not in the Char production). NEL (U+0085) is NOT
330/// restricted in XML 1.1.
331fn is_xml11_restricted_or_invalid(c: char) -> bool {
332    let cp = c as u32;
333    cp == 0
334        || (0x01..=0x08).contains(&cp)
335        || cp == 0x0B
336        || cp == 0x0C
337        || (0x0E..=0x1F).contains(&cp)
338        || (0x7F..=0x84).contains(&cp)
339        || (0x86..=0x9F).contains(&cp)
340        || is_unicode_noncharacter(cp)
341}
342
343fn needs_xml11_encoding(c: char) -> bool {
344    matches!(c, '&' | '<' | '>' | '"' | '\'') || is_xml11_restricted_or_invalid(c)
345}
346
347fn write_xml11_encoded<W: fmt::Write>(out: &mut W, c: char, _next: Option<char>) -> fmt::Result {
348    match c {
349        '&' => out.write_str("&amp;"),
350        '<' => out.write_str("&lt;"),
351        '>' => out.write_str("&gt;"),
352        '"' => out.write_str("&#34;"),
353        '\'' => out.write_str("&#39;"),
354        '\0' => out.write_char(' '),
355        c if is_unicode_noncharacter(c as u32) => out.write_char(' '),
356        // restricted controls → hex character reference
357        c => write!(out, "&#x{:x};", c as u32),
358    }
359}
360
361fn needs_xml11_content_encoding(c: char) -> bool {
362    matches!(c, '&' | '<' | '>') || is_xml11_restricted_or_invalid(c)
363}
364
365fn write_xml11_content_encoded<W: fmt::Write>(
366    out: &mut W,
367    c: char,
368    _next: Option<char>,
369) -> fmt::Result {
370    match c {
371        '&' => out.write_str("&amp;"),
372        '<' => out.write_str("&lt;"),
373        '>' => out.write_str("&gt;"),
374        '\0' => out.write_char(' '),
375        c if is_unicode_noncharacter(c as u32) => out.write_char(' '),
376        c => write!(out, "&#x{:x};", c as u32),
377    }
378}
379
380fn needs_xml11_attribute_encoding(c: char) -> bool {
381    matches!(c, '&' | '<' | '"' | '\'') || is_xml11_restricted_or_invalid(c)
382}
383
384fn write_xml11_attribute_encoded<W: fmt::Write>(
385    out: &mut W,
386    c: char,
387    _next: Option<char>,
388) -> fmt::Result {
389    match c {
390        '&' => out.write_str("&amp;"),
391        '<' => out.write_str("&lt;"),
392        '"' => out.write_str("&#34;"),
393        '\'' => out.write_str("&#39;"),
394        '\0' => out.write_char(' '),
395        c if is_unicode_noncharacter(c as u32) => out.write_char(' '),
396        c => write!(out, "&#x{:x};", c as u32),
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    // -- XML 1.0 aliases --
405
406    #[test]
407    fn xml_aliases_match_html() {
408        let input = r#"<b attr="val">&amp;</b>"#;
409        assert_eq!(for_xml(input), crate::html::for_html(input));
410        assert_eq!(for_xml_content(input), crate::html::for_html_content(input));
411        assert_eq!(
412            for_xml_attribute(input),
413            crate::html::for_html_attribute(input)
414        );
415    }
416
417    // -- XML comment --
418
419    #[test]
420    fn comment_passthrough() {
421        assert_eq!(for_xml_comment("safe text"), "safe text");
422        assert_eq!(for_xml_comment(""), "");
423    }
424
425    #[test]
426    fn comment_double_hyphen() {
427        assert_eq!(for_xml_comment("a--b"), "a-~b");
428        assert_eq!(for_xml_comment("--"), "-~");
429        assert_eq!(for_xml_comment("---"), "-~~");
430        assert_eq!(for_xml_comment("----"), "-~-~");
431        assert_eq!(for_xml_comment("a--b--c"), "a-~b-~c");
432    }
433
434    #[test]
435    fn comment_trailing_hyphen() {
436        assert_eq!(for_xml_comment("trailing-"), "trailing~");
437        assert_eq!(for_xml_comment("-"), "~");
438    }
439
440    #[test]
441    fn comment_replaces_invalid_xml() {
442        assert_eq!(for_xml_comment("a\x01b"), "a b");
443        assert_eq!(for_xml_comment("a\x7Fb"), "a b");
444    }
445
446    #[test]
447    fn comment_preserves_non_ascii() {
448        assert_eq!(for_xml_comment("café"), "café");
449    }
450
451    #[test]
452    fn comment_writer_variant() {
453        let mut out = String::new();
454        write_xml_comment(&mut out, "a--b").unwrap();
455        assert_eq!(out, "a-~b");
456    }
457
458    // -- CDATA --
459
460    #[test]
461    fn cdata_passthrough() {
462        assert_eq!(for_cdata("safe text"), "safe text");
463        assert_eq!(for_cdata(""), "");
464    }
465
466    #[test]
467    fn cdata_splits_closing_delimiter() {
468        assert_eq!(for_cdata("a]]>b"), "a]]]]><![CDATA[>b");
469    }
470
471    #[test]
472    fn cdata_double_split() {
473        assert_eq!(for_cdata("a]]>b]]>c"), "a]]]]><![CDATA[>b]]]]><![CDATA[>c");
474    }
475
476    #[test]
477    fn cdata_brackets_without_gt() {
478        assert_eq!(for_cdata("]]"), "]]");
479        assert_eq!(for_cdata("]"), "]");
480        assert_eq!(for_cdata("]]a"), "]]a");
481    }
482
483    #[test]
484    fn cdata_extra_brackets() {
485        // ]]]> → ] + ]]> split
486        assert_eq!(for_cdata("]]]>"), "]]]]]><![CDATA[>");
487    }
488
489    #[test]
490    fn cdata_replaces_invalid_xml() {
491        assert_eq!(for_cdata("a\x01b"), "a b");
492    }
493
494    #[test]
495    fn cdata_single_bracket_gt() {
496        // ]> is not ]]>, should pass through
497        assert_eq!(for_cdata("]>"), "]>");
498    }
499
500    #[test]
501    fn cdata_writer_variant() {
502        let mut out = String::new();
503        write_cdata(&mut out, "a]]>b").unwrap();
504        assert_eq!(out, "a]]]]><![CDATA[>b");
505    }
506
507    // -- XML 1.1 --
508
509    #[test]
510    fn xml11_encodes_entities() {
511        assert_eq!(for_xml11("<&>\"'"), "&lt;&amp;&gt;&#34;&#39;");
512    }
513
514    #[test]
515    fn xml11_controls_as_references() {
516        // C0 controls get &#xHH; instead of space
517        assert_eq!(for_xml11("a\x01b"), "a&#x1;b");
518        assert_eq!(for_xml11("a\x08b"), "a&#x8;b");
519        assert_eq!(for_xml11("a\x0Bb"), "a&#xb;b");
520        assert_eq!(for_xml11("a\x1Fb"), "a&#x1f;b");
521    }
522
523    #[test]
524    fn xml11_nel_passes_through() {
525        // NEL (U+0085) is NOT restricted in XML 1.1
526        assert_eq!(for_xml11("a\u{0085}b"), "a\u{0085}b");
527    }
528
529    #[test]
530    fn xml11_del_and_c1_as_references() {
531        assert_eq!(for_xml11("a\x7Fb"), "a&#x7f;b");
532        assert_eq!(for_xml11("a\u{0080}b"), "a&#x80;b");
533        assert_eq!(for_xml11("a\u{009F}b"), "a&#x9f;b");
534    }
535
536    #[test]
537    fn xml11_nul_replaced_with_space() {
538        assert_eq!(for_xml11("a\x00b"), "a b");
539    }
540
541    #[test]
542    fn xml11_nonchars_replaced_with_space() {
543        assert_eq!(for_xml11("a\u{FDD0}b"), "a b");
544    }
545
546    #[test]
547    fn xml11_preserves_tab_lf_cr() {
548        assert_eq!(for_xml11("a\tb\nc\rd"), "a\tb\nc\rd");
549    }
550
551    #[test]
552    fn xml11_content_no_quotes() {
553        assert_eq!(for_xml11_content(r#"a"b'c"#), r#"a"b'c"#);
554        assert_eq!(for_xml11_content("a\x01b"), "a&#x1;b");
555    }
556
557    #[test]
558    fn xml11_attribute_no_gt() {
559        assert_eq!(for_xml11_attribute("a>b"), "a>b");
560        assert_eq!(for_xml11_attribute("a\x01b"), "a&#x1;b");
561    }
562}