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