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