Skip to main content

contextual_encoder/
css.rs

1//! CSS contextual output encoders.
2//!
3//! provides two encoding contexts:
4//!
5//! - [`for_css_string`] — safe for CSS string values (inside quotes)
6//! - [`for_css_url`] — safe for CSS `url()` values, quoted or unquoted
7//!
8//! both use CSS hex escape syntax (`\XX`) with a trailing space appended
9//! when the next character could be misinterpreted as part of the hex value,
10//! or when the escape ends the output.
11//!
12//! # security notes
13//!
14//! - CSS string values **must** be quoted. these encoders produce output safe
15//!   inside `"..."` or `'...'` delimiters.
16//! - these encoders do not validate CSS property names, selectors, or
17//!   expressions. encoding cannot make arbitrary CSS safe — validate the
18//!   structure separately.
19//! - for `url()` values, the URL itself must be validated (scheme whitelist,
20//!   etc.) before encoding. encoding only prevents syntax breakout.
21//! - both encoders escape the bidi formatting controls (U+202A-U+202E,
22//!   U+2066-U+2069), so a direction override in the data cannot reorder how the
23//!   surrounding stylesheet source reads.
24
25use std::fmt;
26
27use crate::engine::{encode_loop, is_unicode_noncharacter};
28
29/// encodes `input` for safe embedding in a quoted CSS string value.
30///
31/// uses CSS hex escape syntax (`\XX`) with shortest hex representation.
32/// a trailing space is appended after the hex escape when the next character
33/// is a hex digit or whitespace, and when the escape ends the output. a CSS
34/// escape consumes exactly one following whitespace character, so the space
35/// never changes the decoded value.
36///
37/// unicode non-characters are replaced with `_`.
38///
39/// # encoded characters
40///
41/// C0 controls (U+0000-U+001F), `"`, `'`, `\`, `<`, `&`, `(`, `)`, `/`,
42/// `>`, DEL (U+007F), C1 controls (U+0080-U+009F), U+2028, U+2029, and the
43/// bidi formatting controls (U+202A-U+202E, U+2066-U+2069).
44///
45/// # examples
46///
47/// ```
48/// use contextual_encoder::for_css_string;
49///
50/// assert_eq!(for_css_string("background"), "background");
51/// assert_eq!(for_css_string(r#"a"b"#), r"a\22 b");
52/// // z is not a hex digit, so no trailing space
53/// assert_eq!(for_css_string("a'z"), r"a\27z");
54/// // an escape at the end always gets one; the escape consumes it again
55/// assert_eq!(for_css_string(r#"a""#), r"a\22 ");
56/// assert_eq!(for_css_string("a\u{202E}b"), r"a\202e b");
57/// ```
58pub fn for_css_string(input: &str) -> String {
59    let mut out = String::with_capacity(input.len());
60    write_css_string(&mut out, input).expect("writing to string cannot fail");
61    out
62}
63
64/// writes the CSS-string-encoded form of `input` to `out`.
65///
66/// see [`for_css_string`] for encoding rules.
67pub fn write_css_string<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
68    encode_loop(out, input, needs_css_string_encoding, write_css_encoded)
69}
70
71fn needs_css_string_encoding(c: char) -> bool {
72    needs_css_common_encoding(c) || matches!(c, '(' | ')')
73}
74
75/// encodes `input` for safe embedding in a CSS `url()` value.
76///
77/// whatever the input, `url(<output>)` is exactly one url-token: nothing can
78/// terminate it early or turn it into a bad-url-token. the CSS parser unescapes
79/// the value before resolving it, so a URL that genuinely contains `(`, `)` or
80/// a space still points at the same resource.
81///
82/// the URL **must be validated** before encoding (e.g., ensure the scheme
83/// is allowed). encoding only prevents syntax breakout, not malicious URLs.
84///
85/// # encoded characters
86///
87/// everything [`for_css_string`] encodes, plus space (U+0020).
88///
89/// # examples
90///
91/// ```
92/// use contextual_encoder::for_css_url;
93///
94/// assert_eq!(for_css_url("image.png"), "image.png");
95/// // b is a hex digit, so trailing space after \27
96/// assert_eq!(for_css_url("a'b"), r"a\27 b");
97/// assert_eq!(for_css_url("a(b)"), r"a\28 b\29 ");
98/// ```
99pub fn for_css_url(input: &str) -> String {
100    let mut out = String::with_capacity(input.len());
101    write_css_url(&mut out, input).expect("writing to string cannot fail");
102    out
103}
104
105/// writes the CSS-url-encoded form of `input` to `out`.
106///
107/// see [`for_css_url`] for encoding rules.
108pub fn write_css_url<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
109    encode_loop(out, input, needs_css_url_encoding, write_css_encoded)
110}
111
112fn needs_css_url_encoding(c: char) -> bool {
113    // css whitespace is space, tab and newline; only space is not a C0 control
114    needs_css_string_encoding(c) || c == ' '
115}
116
117fn needs_css_common_encoding(c: char) -> bool {
118    let cp = c as u32;
119    cp <= 0x1F
120        || matches!(c, '"' | '\'' | '\\' | '<' | '&' | '/' | '>')
121        || (0x7F..=0x9F).contains(&cp) // DEL + C1 controls
122        || cp == 0x2028
123        || cp == 0x2029
124        || (0x202A..=0x202E).contains(&cp) // bidi embeddings and overrides
125        || (0x2066..=0x2069).contains(&cp) // bidi isolates
126        || is_unicode_noncharacter(cp)
127}
128
129fn write_css_encoded<W: fmt::Write>(out: &mut W, c: char, next: Option<char>) -> fmt::Result {
130    let cp = c as u32;
131
132    // non-characters → underscore
133    if is_unicode_noncharacter(cp) {
134        return out.write_char('_');
135    }
136
137    // hex escape: shortest representation, no zero-padding
138    write!(out, "\\{:x}", cp)?;
139
140    // append a space if the next character could extend the hex value
141    if needs_css_separator(next) {
142        out.write_char(' ')?;
143    }
144
145    Ok(())
146}
147
148/// returns true if a trailing space is needed after a CSS hex escape to
149/// prevent ambiguous parsing with the next character, or — at the end of the
150/// input — with whatever the caller appends.
151fn needs_css_separator(next: Option<char>) -> bool {
152    match next {
153        Some(c) => c.is_ascii_hexdigit() || matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r'),
154        None => true,
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    /// the bidi formatting controls, with their escaped forms.
163    const TEXT_DIRECTION: [(&str, &str); 9] = [
164        ("\u{202A}", r"\202a"),
165        ("\u{202B}", r"\202b"),
166        ("\u{202C}", r"\202c"),
167        ("\u{202D}", r"\202d"),
168        ("\u{202E}", r"\202e"),
169        ("\u{2066}", r"\2066"),
170        ("\u{2067}", r"\2067"),
171        ("\u{2068}", r"\2068"),
172        ("\u{2069}", r"\2069"),
173    ];
174
175    // -- for_css_string --
176
177    #[test]
178    fn css_string_no_encoding_needed() {
179        assert_eq!(for_css_string("hello"), "hello");
180        assert_eq!(for_css_string(""), "");
181    }
182
183    #[test]
184    fn css_string_encodes_double_quote() {
185        // " (0x22) → \22, followed by space because 'b' is a hex digit
186        assert_eq!(for_css_string(r#"a"b"#), r"a\22 b");
187        // " at end → separator space, absorbed by the escape on decode
188        assert_eq!(for_css_string(r#"a""#), r"a\22 ");
189    }
190
191    #[test]
192    fn css_string_encodes_single_quote() {
193        // ' (0x27) → \27, 'z' is not a hex digit → no space
194        assert_eq!(for_css_string("a'z"), r"a\27z");
195        // ' (0x27) → \27, '1' is a hex digit → space
196        assert_eq!(for_css_string("a'1"), r"a\27 1");
197    }
198
199    #[test]
200    fn css_string_encodes_backslash() {
201        assert_eq!(for_css_string(r"a\b"), r"a\5c b");
202    }
203
204    #[test]
205    fn css_string_encodes_angle_brackets() {
206        // x is not a hex digit, so no trailing space after \3c
207        assert_eq!(for_css_string("<x>"), r"\3cx\3e ");
208    }
209
210    #[test]
211    fn css_string_encodes_ampersand() {
212        assert_eq!(for_css_string("a&b"), r"a\26 b");
213    }
214
215    #[test]
216    fn css_string_encodes_parens() {
217        assert_eq!(for_css_string("a(b)"), r"a\28 b\29 ");
218    }
219
220    #[test]
221    fn css_string_encodes_slash() {
222        assert_eq!(for_css_string("a/b"), r"a\2f b");
223    }
224
225    #[test]
226    fn css_string_encodes_control_chars() {
227        assert_eq!(for_css_string("\x00"), r"\0 ");
228        assert_eq!(for_css_string("\x01x"), r"\1x");
229        assert_eq!(for_css_string("\x1F"), r"\1f ");
230    }
231
232    #[test]
233    fn css_string_encodes_del() {
234        assert_eq!(for_css_string("\x7F"), r"\7f ");
235    }
236
237    #[test]
238    fn css_string_encodes_c1_controls() {
239        assert_eq!(for_css_string("\u{0080}"), r"\80 ");
240        assert_eq!(for_css_string("\u{0085}"), r"\85 ");
241        assert_eq!(for_css_string("\u{009F}"), r"\9f ");
242        // next char is hex digit → trailing space
243        assert_eq!(for_css_string("\u{0080}a"), r"\80 a");
244        // next char is not hex → no trailing space
245        assert_eq!(for_css_string("\u{0080}z"), r"\80z");
246    }
247
248    #[test]
249    fn css_string_encodes_line_separators() {
250        assert_eq!(for_css_string("\u{2028}"), r"\2028 ");
251        assert_eq!(for_css_string("\u{2029}"), r"\2029 ");
252    }
253
254    #[test]
255    fn css_string_encodes_text_direction_controls() {
256        for (raw, escaped) in TEXT_DIRECTION {
257            assert_eq!(for_css_string(&format!("a{raw}z")), format!("a{escaped}z"));
258        }
259    }
260
261    #[test]
262    fn css_string_text_direction_separator() {
263        for (raw, escaped) in TEXT_DIRECTION {
264            // b is a hex digit, z is not
265            assert_eq!(for_css_string(&format!("{raw}b")), format!("{escaped} b"));
266            assert_eq!(for_css_string(&format!("{raw}z")), format!("{escaped}z"));
267            assert_eq!(for_css_string(raw), format!("{escaped} "));
268        }
269    }
270
271    #[test]
272    fn css_string_replaces_nonchars_with_underscore() {
273        assert_eq!(for_css_string("\u{FDD0}"), "_");
274        assert_eq!(for_css_string("\u{FFFE}"), "_");
275        assert_eq!(for_css_string("\u{FFFF}"), "_");
276    }
277
278    #[test]
279    fn css_string_separator_before_whitespace() {
280        // \27 followed by space → needs separator → \27 + space + space
281        // first space is the separator, second is the content space
282        assert_eq!(for_css_string("' "), r"\27  ");
283    }
284
285    #[test]
286    fn css_string_preserves_non_ascii() {
287        assert_eq!(for_css_string("café"), "café");
288    }
289
290    #[test]
291    fn css_string_writer_variant() {
292        let mut out = String::new();
293        // b is a hex digit, so trailing space after \27
294        write_css_string(&mut out, "a'b").unwrap();
295        assert_eq!(out, r"a\27 b");
296    }
297
298    // -- for_css_url --
299
300    #[test]
301    fn css_url_encodes_parens() {
302        assert_eq!(for_css_url("a(b)c"), r"a\28 b\29 c");
303    }
304
305    #[test]
306    fn css_url_encodes_space() {
307        assert_eq!(for_css_url("a b"), r"a\20 b");
308        assert_eq!(for_css_url(" "), r"\20 ");
309    }
310
311    #[test]
312    fn css_url_preserves_unicode_spaces() {
313        assert_eq!(for_css_url("a\u{a0}b"), "a\u{a0}b");
314        assert_eq!(for_css_url("a\u{2003}b"), "a\u{2003}b");
315        assert_eq!(for_css_url("a\u{3000}b"), "a\u{3000}b");
316    }
317
318    #[test]
319    fn css_url_encodes_quotes() {
320        // b is a hex digit, so trailing space after \27
321        assert_eq!(for_css_url("a'b"), r"a\27 b");
322    }
323
324    #[test]
325    fn css_url_encodes_backslash() {
326        assert_eq!(for_css_url(r"a\b"), r"a\5c b");
327    }
328
329    #[test]
330    fn css_url_encodes_c1_controls() {
331        assert_eq!(for_css_url("\u{0080}"), r"\80 ");
332        assert_eq!(for_css_url("\u{0085}"), r"\85 ");
333        assert_eq!(for_css_url("\u{009F}"), r"\9f ");
334    }
335
336    #[test]
337    fn css_url_encodes_text_direction_controls() {
338        for (raw, escaped) in TEXT_DIRECTION {
339            assert_eq!(for_css_url(&format!("a{raw}z")), format!("a{escaped}z"));
340            assert_eq!(for_css_url(&format!("{raw}b")), format!("{escaped} b"));
341            assert_eq!(for_css_url(raw), format!("{escaped} "));
342        }
343    }
344}