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\">"), "<root attr="val">");
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 < b & 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"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>"), "<b>");
252/// // control chars get character references instead of space
253/// assert_eq!(for_xml11("a\x01b"), "ab");
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"), "ab");
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"), "ab");
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">&</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("<&>\"'"), "<&>"'");
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"), "ab");
452 assert_eq!(for_xml11("a\x08b"), "ab");
453 assert_eq!(for_xml11("a\x0Bb"), "ab");
454 assert_eq!(for_xml11("a\x1Fb"), "ab");
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"), "ab");
466 assert_eq!(for_xml11("a\u{0080}b"), "a€b");
467 assert_eq!(for_xml11("a\u{009F}b"), "aŸ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"), "ab");
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"), "ab");
495 }
496}