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
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/// identical to [`for_css_string`] except parentheses `(` and `)` are
68/// **not** encoded (they are part of the `url()` syntax, not the value).
69///
70/// the URL **must be validated** before encoding (e.g., ensure the scheme
71/// is allowed). encoding only prevents syntax breakout, not malicious URLs.
72///
73/// # examples
74///
75/// ```
76/// use contextual_encoder::for_css_url;
77///
78/// assert_eq!(for_css_url("image.png"), "image.png");
79/// // b is a hex digit, so trailing space after \27
80/// assert_eq!(for_css_url("a'b"), r"a\27 b");
81/// assert_eq!(for_css_url("a(b)"), "a(b)");
82/// ```
83pub fn for_css_url(input: &str) -> String {
84    let mut out = String::with_capacity(input.len());
85    write_css_url(&mut out, input).expect("writing to string cannot fail");
86    out
87}
88
89/// writes the CSS-url-encoded form of `input` to `out`.
90///
91/// see [`for_css_url`] for encoding rules.
92pub fn write_css_url<W: fmt::Write>(out: &mut W, input: &str) -> fmt::Result {
93    encode_loop(out, input, needs_css_url_encoding, write_css_encoded)
94}
95
96fn needs_css_url_encoding(c: char) -> bool {
97    needs_css_common_encoding(c)
98    // parentheses NOT encoded in url context
99}
100
101fn needs_css_common_encoding(c: char) -> bool {
102    let cp = c as u32;
103    cp <= 0x1F
104        || matches!(c, '"' | '\'' | '\\' | '<' | '&' | '/' | '>')
105        || (0x7F..=0x9F).contains(&cp) // DEL + C1 controls
106        || cp == 0x2028
107        || cp == 0x2029
108        || is_unicode_noncharacter(cp)
109}
110
111fn write_css_encoded<W: fmt::Write>(out: &mut W, c: char, next: Option<char>) -> fmt::Result {
112    let cp = c as u32;
113
114    // non-characters → underscore
115    if is_unicode_noncharacter(cp) {
116        return out.write_char('_');
117    }
118
119    // hex escape: shortest representation, no zero-padding
120    write!(out, "\\{:x}", cp)?;
121
122    // append a space if the next character could extend the hex value
123    if needs_css_separator(next) {
124        out.write_char(' ')?;
125    }
126
127    Ok(())
128}
129
130/// returns true if a trailing space is needed after a CSS hex escape
131/// to prevent ambiguous parsing with the next character.
132fn needs_css_separator(next: Option<char>) -> bool {
133    match next {
134        Some(c) => c.is_ascii_hexdigit() || matches!(c, ' ' | '\t' | '\n' | '\x0C' | '\r'),
135        None => false,
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    // -- for_css_string --
144
145    #[test]
146    fn css_string_no_encoding_needed() {
147        assert_eq!(for_css_string("hello"), "hello");
148        assert_eq!(for_css_string(""), "");
149    }
150
151    #[test]
152    fn css_string_encodes_double_quote() {
153        // " (0x22) → \22, followed by space because 'b' is a hex digit
154        assert_eq!(for_css_string(r#"a"b"#), r"a\22 b");
155        // " at end → no trailing space
156        assert_eq!(for_css_string(r#"a""#), r"a\22");
157    }
158
159    #[test]
160    fn css_string_encodes_single_quote() {
161        // ' (0x27) → \27, 'z' is not a hex digit → no space
162        assert_eq!(for_css_string("a'z"), r"a\27z");
163        // ' (0x27) → \27, '1' is a hex digit → space
164        assert_eq!(for_css_string("a'1"), r"a\27 1");
165    }
166
167    #[test]
168    fn css_string_encodes_backslash() {
169        assert_eq!(for_css_string(r"a\b"), r"a\5c b");
170    }
171
172    #[test]
173    fn css_string_encodes_angle_brackets() {
174        // x is not a hex digit, so no trailing space after \3c
175        assert_eq!(for_css_string("<x>"), r"\3cx\3e");
176    }
177
178    #[test]
179    fn css_string_encodes_ampersand() {
180        assert_eq!(for_css_string("a&b"), r"a\26 b");
181    }
182
183    #[test]
184    fn css_string_encodes_parens() {
185        assert_eq!(for_css_string("a(b)"), r"a\28 b\29");
186    }
187
188    #[test]
189    fn css_string_encodes_slash() {
190        assert_eq!(for_css_string("a/b"), r"a\2f b");
191    }
192
193    #[test]
194    fn css_string_encodes_control_chars() {
195        assert_eq!(for_css_string("\x00"), r"\0");
196        assert_eq!(for_css_string("\x01x"), r"\1x");
197        assert_eq!(for_css_string("\x1F"), r"\1f");
198    }
199
200    #[test]
201    fn css_string_encodes_del() {
202        assert_eq!(for_css_string("\x7F"), r"\7f");
203    }
204
205    #[test]
206    fn css_string_encodes_c1_controls() {
207        assert_eq!(for_css_string("\u{0080}"), r"\80");
208        assert_eq!(for_css_string("\u{0085}"), r"\85");
209        assert_eq!(for_css_string("\u{009F}"), r"\9f");
210        // next char is hex digit → trailing space
211        assert_eq!(for_css_string("\u{0080}a"), r"\80 a");
212        // next char is not hex → no trailing space
213        assert_eq!(for_css_string("\u{0080}z"), r"\80z");
214    }
215
216    #[test]
217    fn css_string_encodes_line_separators() {
218        assert_eq!(for_css_string("\u{2028}"), r"\2028");
219        assert_eq!(for_css_string("\u{2029}"), r"\2029");
220    }
221
222    #[test]
223    fn css_string_replaces_nonchars_with_underscore() {
224        assert_eq!(for_css_string("\u{FDD0}"), "_");
225        assert_eq!(for_css_string("\u{FFFE}"), "_");
226        assert_eq!(for_css_string("\u{FFFF}"), "_");
227    }
228
229    #[test]
230    fn css_string_separator_before_whitespace() {
231        // \27 followed by space → needs separator → \27 + space + space
232        // first space is the separator, second is the content space
233        assert_eq!(for_css_string("' "), r"\27  ");
234    }
235
236    #[test]
237    fn css_string_preserves_non_ascii() {
238        assert_eq!(for_css_string("café"), "café");
239    }
240
241    #[test]
242    fn css_string_writer_variant() {
243        let mut out = String::new();
244        // b is a hex digit, so trailing space after \27
245        write_css_string(&mut out, "a'b").unwrap();
246        assert_eq!(out, r"a\27 b");
247    }
248
249    // -- for_css_url --
250
251    #[test]
252    fn css_url_does_not_encode_parens() {
253        assert_eq!(for_css_url("a(b)c"), "a(b)c");
254    }
255
256    #[test]
257    fn css_url_encodes_quotes() {
258        // b is a hex digit, so trailing space after \27
259        assert_eq!(for_css_url("a'b"), r"a\27 b");
260    }
261
262    #[test]
263    fn css_url_encodes_backslash() {
264        assert_eq!(for_css_url(r"a\b"), r"a\5c b");
265    }
266
267    #[test]
268    fn css_url_encodes_c1_controls() {
269        assert_eq!(for_css_url("\u{0080}"), r"\80");
270        assert_eq!(for_css_url("\u{0085}"), r"\85");
271        assert_eq!(for_css_url("\u{009F}"), r"\9f");
272    }
273}