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/// the caller is responsible for wrapping the output in `<![CDATA[...]]>`.
169///
170/// invalid XML characters are replaced with a space.
171///
172/// # examples
173///
174/// ```
175/// use contextual_encoder::for_cdata;
176///
177/// assert_eq!(for_cdata("safe text"), "safe text");
178/// assert_eq!(for_cdata("a]]>b"), "a]]]]><![CDATA[>b");
179/// assert_eq!(for_cdata("]]"), "]]");
180/// ```
181pub fn for_cdata(input: &str) -> String {
182    let mut out = String::with_capacity(input.len());
183    write_cdata(&mut out, input).expect("writing to string cannot fail");
184    out
185}
186
187/// writes the CDATA-encoded form of `input` to `out`.
188///
189/// see [`for_cdata`] for encoding rules.
190pub fn write_cdata<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
191    let mut bracket_count: u32 = 0;
192    encode_loop(
193        out,
194        input,
195        |c| c == ']' || c == '>' || is_invalid_for_xml(c),
196        |out, c, next| {
197            if c == ']' {
198                bracket_count += 1;
199                if next != Some(']') && next != Some('>') {
200                    bracket_count = 0;
201                }
202                out.write_char(']')
203            } else if c == '>' {
204                let split = bracket_count >= 2;
205                bracket_count = 0;
206                if split {
207                    out.write_str("]]><![CDATA[>")
208                } else {
209                    out.write_char('>')
210                }
211            } else {
212                bracket_count = 0;
213                out.write_char(' ')
214            }
215        },
216    )
217}
218
219const XML11_FULL: MarkupConfig = MarkupConfig {
220    encode_gt: true,
221    encode_quotes: true,
222    invalid: InvalidCharPolicy::Xml11Reference,
223};
224
225const XML11_CONTENT: MarkupConfig = MarkupConfig {
226    encode_gt: true,
227    encode_quotes: false,
228    invalid: InvalidCharPolicy::Xml11Reference,
229};
230
231const XML11_ATTRIBUTE: MarkupConfig = MarkupConfig {
232    encode_gt: false,
233    encode_quotes: true,
234    invalid: InvalidCharPolicy::Xml11Reference,
235};
236
237/// encodes `input` for safe embedding in XML 1.1 text content and quoted
238/// attributes.
239///
240/// like [`for_xml`] but encodes restricted characters as `&#xHH;` character
241/// references instead of replacing them with space. NUL (U+0000) and unicode
242/// non-characters are still replaced with space (they are invalid in XML 1.1).
243///
244/// NEL (U+0085) is **not** restricted in XML 1.1 and passes through unchanged.
245///
246/// # examples
247///
248/// ```
249/// use contextual_encoder::for_xml11;
250///
251/// assert_eq!(for_xml11("<b>"), "&lt;b&gt;");
252/// // control chars get character references instead of space
253/// assert_eq!(for_xml11("a\x01b"), "a&#x1;b");
254/// // NEL passes through in XML 1.1
255/// assert_eq!(for_xml11("a\u{0085}b"), "a\u{0085}b");
256/// ```
257pub fn for_xml11(input: &str) -> String {
258    let mut out = String::with_capacity(input.len());
259    write_xml11(&mut out, input).expect("writing to string cannot fail");
260    out
261}
262
263/// writes the XML-1.1-encoded form of `input` to `out`.
264///
265/// see [`for_xml11`] for encoding rules.
266pub fn write_xml11<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
267    write_markup(out, input, &XML11_FULL)
268}
269
270/// encodes `input` for safe embedding in XML 1.1 text content only.
271///
272/// like [`for_xml_content`] but encodes restricted characters as `&#xHH;`
273/// character references. does **not** encode quotes — not safe for attributes.
274///
275/// # examples
276///
277/// ```
278/// use contextual_encoder::for_xml11_content;
279///
280/// assert_eq!(for_xml11_content("a\x01b"), "a&#x1;b");
281/// assert_eq!(for_xml11_content(r#"a"b"#), r#"a"b"#);
282/// ```
283pub fn for_xml11_content(input: &str) -> String {
284    let mut out = String::with_capacity(input.len());
285    write_xml11_content(&mut out, input).expect("writing to string cannot fail");
286    out
287}
288
289/// writes the XML-1.1-content-encoded form of `input` to `out`.
290///
291/// see [`for_xml11_content`] for encoding rules.
292pub fn write_xml11_content<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
293    write_markup(out, input, &XML11_CONTENT)
294}
295
296/// encodes `input` for safe embedding in a quoted XML 1.1 attribute value.
297///
298/// like [`for_xml_attribute`] but encodes restricted characters as `&#xHH;`
299/// character references. does **not** encode `>`.
300///
301/// # examples
302///
303/// ```
304/// use contextual_encoder::for_xml11_attribute;
305///
306/// assert_eq!(for_xml11_attribute("a\x01b"), "a&#x1;b");
307/// assert_eq!(for_xml11_attribute("a>b"), "a>b");
308/// ```
309pub fn for_xml11_attribute(input: &str) -> String {
310    let mut out = String::with_capacity(input.len());
311    write_xml11_attribute(&mut out, input).expect("writing to string cannot fail");
312    out
313}
314
315/// writes the XML-1.1-attribute-encoded form of `input` to `out`.
316///
317/// see [`for_xml11_attribute`] for encoding rules.
318pub fn write_xml11_attribute<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
319    write_markup(out, input, &XML11_ATTRIBUTE)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    // -- XML 1.0 aliases --
327
328    #[test]
329    fn xml_aliases_match_html() {
330        let input = r#"<b attr="val">&amp;</b>"#;
331        assert_eq!(for_xml(input), crate::html::for_html(input));
332        assert_eq!(for_xml_content(input), crate::html::for_html_content(input));
333        assert_eq!(
334            for_xml_attribute(input),
335            crate::html::for_html_attribute(input)
336        );
337    }
338
339    // -- XML comment --
340
341    #[test]
342    fn comment_passthrough() {
343        assert_eq!(for_xml_comment("safe text"), "safe text");
344        assert_eq!(for_xml_comment(""), "");
345    }
346
347    #[test]
348    fn comment_double_hyphen() {
349        assert_eq!(for_xml_comment("a--b"), "a-~b");
350        assert_eq!(for_xml_comment("--"), "-~");
351        assert_eq!(for_xml_comment("---"), "-~~");
352        assert_eq!(for_xml_comment("----"), "-~-~");
353        assert_eq!(for_xml_comment("a--b--c"), "a-~b-~c");
354    }
355
356    #[test]
357    fn comment_trailing_hyphen() {
358        assert_eq!(for_xml_comment("trailing-"), "trailing~");
359        assert_eq!(for_xml_comment("-"), "~");
360    }
361
362    #[test]
363    fn comment_hyphen_not_paired_across_run() {
364        assert_eq!(for_xml_comment("-a-b"), "-a-b");
365        assert_eq!(for_xml_comment("a-b-c"), "a-b-c");
366    }
367
368    #[test]
369    fn comment_replaces_invalid_xml() {
370        assert_eq!(for_xml_comment("a\x01b"), "a b");
371        assert_eq!(for_xml_comment("a\x7Fb"), "a b");
372    }
373
374    #[test]
375    fn comment_preserves_non_ascii() {
376        assert_eq!(for_xml_comment("café"), "café");
377    }
378
379    #[test]
380    fn comment_writer_variant() {
381        let mut out = String::new();
382        write_xml_comment(&mut out, "a--b").unwrap();
383        assert_eq!(out, "a-~b");
384    }
385
386    // -- CDATA --
387
388    #[test]
389    fn cdata_passthrough() {
390        assert_eq!(for_cdata("safe text"), "safe text");
391        assert_eq!(for_cdata(""), "");
392    }
393
394    #[test]
395    fn cdata_splits_closing_delimiter() {
396        assert_eq!(for_cdata("a]]>b"), "a]]]]><![CDATA[>b");
397    }
398
399    #[test]
400    fn cdata_double_split() {
401        assert_eq!(for_cdata("a]]>b]]>c"), "a]]]]><![CDATA[>b]]]]><![CDATA[>c");
402    }
403
404    #[test]
405    fn cdata_brackets_without_gt() {
406        assert_eq!(for_cdata("]]"), "]]");
407        assert_eq!(for_cdata("]"), "]");
408        assert_eq!(for_cdata("]]a"), "]]a");
409    }
410
411    #[test]
412    fn cdata_bracket_run_reset_before_gt() {
413        assert_eq!(for_cdata("]]a>"), "]]a>");
414        assert_eq!(for_cdata("]] >"), "]] >");
415    }
416
417    #[test]
418    fn cdata_extra_brackets() {
419        // ]]]> → ] + ]]> split
420        assert_eq!(for_cdata("]]]>"), "]]]]]><![CDATA[>");
421    }
422
423    #[test]
424    fn cdata_replaces_invalid_xml() {
425        assert_eq!(for_cdata("a\x01b"), "a b");
426    }
427
428    #[test]
429    fn cdata_single_bracket_gt() {
430        // ]> is not ]]>, should pass through
431        assert_eq!(for_cdata("]>"), "]>");
432    }
433
434    #[test]
435    fn cdata_writer_variant() {
436        let mut out = String::new();
437        write_cdata(&mut out, "a]]>b").unwrap();
438        assert_eq!(out, "a]]]]><![CDATA[>b");
439    }
440
441    // -- XML 1.1 --
442
443    #[test]
444    fn xml11_encodes_entities() {
445        assert_eq!(for_xml11("<&>\"'"), "&lt;&amp;&gt;&#34;&#39;");
446    }
447
448    #[test]
449    fn xml11_controls_as_references() {
450        // C0 controls get &#xHH; instead of space
451        assert_eq!(for_xml11("a\x01b"), "a&#x1;b");
452        assert_eq!(for_xml11("a\x08b"), "a&#x8;b");
453        assert_eq!(for_xml11("a\x0Bb"), "a&#xb;b");
454        assert_eq!(for_xml11("a\x1Fb"), "a&#x1f;b");
455    }
456
457    #[test]
458    fn xml11_nel_passes_through() {
459        // NEL (U+0085) is NOT restricted in XML 1.1
460        assert_eq!(for_xml11("a\u{0085}b"), "a\u{0085}b");
461    }
462
463    #[test]
464    fn xml11_del_and_c1_as_references() {
465        assert_eq!(for_xml11("a\x7Fb"), "a&#x7f;b");
466        assert_eq!(for_xml11("a\u{0080}b"), "a&#x80;b");
467        assert_eq!(for_xml11("a\u{009F}b"), "a&#x9f;b");
468    }
469
470    #[test]
471    fn xml11_nul_replaced_with_space() {
472        assert_eq!(for_xml11("a\x00b"), "a b");
473    }
474
475    #[test]
476    fn xml11_nonchars_replaced_with_space() {
477        assert_eq!(for_xml11("a\u{FDD0}b"), "a b");
478    }
479
480    #[test]
481    fn xml11_preserves_tab_lf_cr() {
482        assert_eq!(for_xml11("a\tb\nc\rd"), "a\tb\nc\rd");
483    }
484
485    #[test]
486    fn xml11_content_no_quotes() {
487        assert_eq!(for_xml11_content(r#"a"b'c"#), r#"a"b'c"#);
488        assert_eq!(for_xml11_content("a\x01b"), "a&#x1;b");
489    }
490
491    #[test]
492    fn xml11_attribute_no_gt() {
493        assert_eq!(for_xml11_attribute("a>b"), "a>b");
494        assert_eq!(for_xml11_attribute("a\x01b"), "a&#x1;b");
495    }
496}