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