Skip to main content

contextual_encoder/
display.rs

1//! zero-allocation [`Display`](std::fmt::Display) wrappers for all encoding
2//! contexts.
3//!
4//! every `for_*` function allocates a `String`. when embedding encoded output
5//! in a larger format string (e.g., `format!("<p>{}</p>", for_html(s))`), the
6//! intermediate string is immediately consumed and discarded — a wasted
7//! allocation.
8//!
9//! the `display_*` functions return lightweight wrappers that implement
10//! [`Display`](std::fmt::Display) by delegating to the corresponding `write_*`
11//! function. this enables zero-allocation inline formatting:
12//!
13//! ```
14//! use contextual_encoder::display_html;
15//!
16//! let user_input = "<script>alert('xss')</script>";
17//! // one allocation (the final String), zero intermediate allocations
18//! let output = format!("<p>{}</p>", display_html(user_input));
19//! assert!(output.contains("&lt;script&gt;"));
20//! ```
21//!
22//! each `display_*` wrapper encodes identically to its `for_*` / `write_*`
23//! counterpart. see the corresponding `for_*` function for encoding rules.
24//!
25//! # formatting parameters
26//!
27//! width, fill/alignment and precision apply to the *encoded* output, exactly
28//! as `format!` applies them to the `String` returned by the `for_*`
29//! counterpart:
30//!
31//! ```
32//! use contextual_encoder::{display_html, for_html};
33//!
34//! assert_eq!(
35//!     format!("{:*^12}", display_html("a<b")),
36//!     format!("{:*^12}", for_html("a<b")),
37//! );
38//! ```
39//!
40//! a wrapper formatted with a width or a precision buffers the encoded output
41//! into one intermediate `String`; the bare `{}` case stays allocation-free.
42//!
43//! precision counts characters of the *encoded* output, so it can cut an
44//! escape or entity in half: `{:.4}` turns `a&lt;b` into `a&lt`, which an HTML
45//! parser can read back as `a<`. `for_*` truncates the same way. do not use
46//! precision to bound untrusted output — truncate the input before encoding it.
47
48use std::fmt;
49
50use crate::{css, html, javascript, json, rust, sql, uri, xml};
51
52macro_rules! display_fn {
53    (
54        $(#[$meta:meta])*
55        $name:ident => $module:ident :: $write_fn:ident
56    ) => {
57        $(#[$meta])*
58        pub fn $name(input: &str) -> impl fmt::Display + '_ {
59            struct W<'a>(&'a str);
60            impl fmt::Display for W<'_> {
61                fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62                    if f.width().is_none() && f.precision().is_none() {
63                        return $module::$write_fn(f, self.0);
64                    }
65                    let mut encoded = String::with_capacity(self.0.len());
66                    $module::$write_fn(&mut encoded, self.0)?;
67                    f.pad(&encoded)
68                }
69            }
70            W(input)
71        }
72    };
73}
74
75display_fn! {
76    /// zero-allocation display wrapper for [`for_html`](crate::for_html).
77    display_html => html::write_html
78}
79
80display_fn! {
81    /// zero-allocation display wrapper for [`for_html_content`](crate::for_html_content).
82    display_html_content => html::write_html_content
83}
84
85display_fn! {
86    /// zero-allocation display wrapper for [`for_html_attribute`](crate::for_html_attribute).
87    display_html_attribute => html::write_html_attribute
88}
89
90display_fn! {
91    /// zero-allocation display wrapper for
92    /// [`for_html_unquoted_attribute`](crate::for_html_unquoted_attribute).
93    display_html_unquoted_attribute => html::write_html_unquoted_attribute
94}
95
96display_fn! {
97    /// zero-allocation display wrapper for [`for_xml`](crate::for_xml).
98    display_xml => xml::write_xml
99}
100
101display_fn! {
102    /// zero-allocation display wrapper for [`for_xml_content`](crate::for_xml_content).
103    display_xml_content => xml::write_xml_content
104}
105
106display_fn! {
107    /// zero-allocation display wrapper for [`for_xml_attribute`](crate::for_xml_attribute).
108    display_xml_attribute => xml::write_xml_attribute
109}
110
111display_fn! {
112    /// zero-allocation display wrapper for [`for_xml_comment`](crate::for_xml_comment).
113    display_xml_comment => xml::write_xml_comment
114}
115
116display_fn! {
117    /// zero-allocation display wrapper for [`for_cdata`](crate::for_cdata).
118    display_cdata => xml::write_cdata
119}
120
121display_fn! {
122    /// zero-allocation display wrapper for [`for_xml11`](crate::for_xml11).
123    display_xml11 => xml::write_xml11
124}
125
126display_fn! {
127    /// zero-allocation display wrapper for [`for_xml11_content`](crate::for_xml11_content).
128    display_xml11_content => xml::write_xml11_content
129}
130
131display_fn! {
132    /// zero-allocation display wrapper for [`for_xml11_attribute`](crate::for_xml11_attribute).
133    display_xml11_attribute => xml::write_xml11_attribute
134}
135
136display_fn! {
137    /// zero-allocation display wrapper for [`for_javascript`](crate::for_javascript).
138    display_javascript => javascript::write_javascript
139}
140
141display_fn! {
142    /// zero-allocation display wrapper for
143    /// [`for_javascript_attribute`](crate::for_javascript_attribute).
144    display_javascript_attribute => javascript::write_javascript_attribute
145}
146
147display_fn! {
148    /// zero-allocation display wrapper for
149    /// [`for_javascript_block`](crate::for_javascript_block).
150    display_javascript_block => javascript::write_javascript_block
151}
152
153display_fn! {
154    /// zero-allocation display wrapper for
155    /// [`for_javascript_source`](crate::for_javascript_source).
156    display_javascript_source => javascript::write_javascript_source
157}
158
159display_fn! {
160    /// zero-allocation display wrapper for [`for_js_template`](crate::for_js_template).
161    display_js_template => javascript::write_js_template
162}
163
164display_fn! {
165    /// zero-allocation display wrapper for [`for_css_string`](crate::for_css_string).
166    display_css_string => css::write_css_string
167}
168
169display_fn! {
170    /// zero-allocation display wrapper for [`for_css_url`](crate::for_css_url).
171    display_css_url => css::write_css_url
172}
173
174display_fn! {
175    /// zero-allocation display wrapper for [`for_uri_component`](crate::for_uri_component).
176    display_uri_component => uri::write_uri_component
177}
178
179display_fn! {
180    /// zero-allocation display wrapper for [`for_uri_path`](crate::for_uri_path).
181    display_uri_path => uri::write_uri_path
182}
183
184display_fn! {
185    /// zero-allocation display wrapper for
186    /// [`for_form_urlencoded`](crate::for_form_urlencoded).
187    display_form_urlencoded => uri::write_form_urlencoded
188}
189
190display_fn! {
191    /// zero-allocation display wrapper for [`for_json`](crate::for_json).
192    display_json => json::write_json
193}
194
195display_fn! {
196    /// zero-allocation display wrapper for [`for_rust_string`](crate::for_rust_string).
197    display_rust_string => rust::write_rust_string
198}
199
200display_fn! {
201    /// zero-allocation display wrapper for [`for_rust_char`](crate::for_rust_char).
202    display_rust_char => rust::write_rust_char
203}
204
205display_fn! {
206    /// zero-allocation display wrapper for
207    /// [`for_rust_byte_string`](crate::for_rust_byte_string).
208    display_rust_byte_string => rust::write_rust_byte_string
209}
210
211display_fn! {
212    /// zero-allocation display wrapper for [`for_sql`](crate::for_sql).
213    display_sql => sql::write_sql
214}
215
216display_fn! {
217    /// zero-allocation display wrapper for [`for_sql_backslash`](crate::for_sql_backslash).
218    display_sql_backslash => sql::write_sql_backslash
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    // verify that every display_* wrapper formats identically to its for_* counterpart.
226
227    macro_rules! assert_fmt_parity {
228        ($fmt:literal, $display_fn:ident, $input:expr, $encoded:expr) => {
229            assert_eq!(
230                format!($fmt, $display_fn($input)),
231                format!($fmt, $encoded),
232                "mismatch for {:?} formatted as {:?} on {}",
233                $input,
234                $fmt,
235                stringify!($display_fn),
236            );
237        };
238    }
239
240    macro_rules! display_matches_for {
241        ($name:ident, $display_fn:ident, $for_fn:path) => {
242            #[test]
243            fn $name() {
244                for input in [
245                    "",
246                    "hello",
247                    "<script>alert('xss')</script>",
248                    "café",
249                    "世界",
250                    "😀",
251                    "a&b<c>d\"e'f",
252                    "\x00\x01\x1F\x7F",
253                    "\t\n\r",
254                    "\u{0080}\u{009F}",
255                    "\u{2028}\u{2029}",
256                    "a\\b/c",
257                    "key=val&foo=bar",
258                    "`${inject}`",
259                ] {
260                    let encoded = $for_fn(input);
261                    assert_fmt_parity!("{}", $display_fn, input, encoded);
262                    assert_fmt_parity!("{:>12}", $display_fn, input, encoded);
263                    assert_fmt_parity!("{:<12}", $display_fn, input, encoded);
264                    assert_fmt_parity!("{:*^12}", $display_fn, input, encoded);
265                    assert_fmt_parity!("{:.0}", $display_fn, input, encoded);
266                    assert_fmt_parity!("{:.4}", $display_fn, input, encoded);
267                    assert_fmt_parity!("{:.99}", $display_fn, input, encoded);
268                    assert_fmt_parity!("{:*>20.7}", $display_fn, input, encoded);
269                }
270            }
271        };
272    }
273
274    // html
275    display_matches_for!(html, display_html, crate::for_html);
276    display_matches_for!(html_content, display_html_content, crate::for_html_content);
277    display_matches_for!(
278        html_attribute,
279        display_html_attribute,
280        crate::for_html_attribute
281    );
282    display_matches_for!(
283        html_unquoted_attribute,
284        display_html_unquoted_attribute,
285        crate::for_html_unquoted_attribute
286    );
287
288    // xml
289    display_matches_for!(xml, display_xml, crate::for_xml);
290    display_matches_for!(xml_content, display_xml_content, crate::for_xml_content);
291    display_matches_for!(
292        xml_attribute,
293        display_xml_attribute,
294        crate::for_xml_attribute
295    );
296    display_matches_for!(xml_comment, display_xml_comment, crate::for_xml_comment);
297    display_matches_for!(cdata, display_cdata, crate::for_cdata);
298    display_matches_for!(xml11, display_xml11, crate::for_xml11);
299    display_matches_for!(
300        xml11_content,
301        display_xml11_content,
302        crate::for_xml11_content
303    );
304    display_matches_for!(
305        xml11_attribute,
306        display_xml11_attribute,
307        crate::for_xml11_attribute
308    );
309
310    // javascript
311    display_matches_for!(javascript, display_javascript, crate::for_javascript);
312    display_matches_for!(
313        javascript_attribute,
314        display_javascript_attribute,
315        crate::for_javascript_attribute
316    );
317    display_matches_for!(
318        javascript_block,
319        display_javascript_block,
320        crate::for_javascript_block
321    );
322    display_matches_for!(
323        javascript_source,
324        display_javascript_source,
325        crate::for_javascript_source
326    );
327    display_matches_for!(js_template, display_js_template, crate::for_js_template);
328
329    // css
330    display_matches_for!(css_string, display_css_string, crate::for_css_string);
331    display_matches_for!(css_url, display_css_url, crate::for_css_url);
332
333    // uri
334    display_matches_for!(
335        uri_component,
336        display_uri_component,
337        crate::for_uri_component
338    );
339    display_matches_for!(uri_path, display_uri_path, crate::for_uri_path);
340    display_matches_for!(
341        form_urlencoded,
342        display_form_urlencoded,
343        crate::for_form_urlencoded
344    );
345
346    // json
347    display_matches_for!(json, display_json, crate::for_json);
348
349    // rust
350    display_matches_for!(rust_string, display_rust_string, crate::for_rust_string);
351    display_matches_for!(rust_char, display_rust_char, crate::for_rust_char);
352    display_matches_for!(
353        rust_byte_string,
354        display_rust_byte_string,
355        crate::for_rust_byte_string
356    );
357
358    // sql
359    display_matches_for!(sql, display_sql, crate::for_sql);
360    display_matches_for!(
361        sql_backslash,
362        display_sql_backslash,
363        crate::for_sql_backslash
364    );
365
366    // -- usage pattern tests --
367
368    #[test]
369    fn inline_format_html() {
370        let input = "<b>bold</b>";
371        let result = format!("<p>{}</p>", display_html(input));
372        assert_eq!(result, "<p>&lt;b&gt;bold&lt;/b&gt;</p>");
373    }
374
375    #[test]
376    fn inline_format_nested_contexts() {
377        let query = "hello world & goodbye";
378        let href = format!("/search?q={}", display_uri_component(query));
379        let attr = format!(r#"<a href="{}">"#, display_html_attribute(&href));
380        assert!(attr.contains("/search?q=hello%20world%20%26%20goodbye"));
381    }
382
383    #[test]
384    fn write_macro_integration() {
385        use std::fmt::Write;
386        let mut buf = String::new();
387        write!(buf, "<p>{}</p>", display_html("a & b")).unwrap();
388        assert_eq!(buf, "<p>a &amp; b</p>");
389    }
390
391    #[test]
392    fn display_wrapper_is_reusable() {
393        let wrapper = display_html("<b>");
394        let first = format!("{wrapper}");
395        let second = format!("{wrapper}");
396        assert_eq!(first, second);
397        assert_eq!(first, "&lt;b&gt;");
398    }
399
400    #[derive(Default)]
401    struct ChunkCounter {
402        out: String,
403        chunks: usize,
404    }
405
406    impl fmt::Write for ChunkCounter {
407        fn write_str(&mut self, s: &str) -> fmt::Result {
408            if !s.is_empty() {
409                self.chunks += 1;
410            }
411            self.out.push_str(s);
412            Ok(())
413        }
414    }
415
416    #[test]
417    fn bare_spec_writes_through_without_buffering() {
418        use std::fmt::Write;
419        let mut sink = ChunkCounter::default();
420        write!(sink, "{}", display_html("a&b<c")).unwrap();
421        assert_eq!(sink.out, crate::for_html("a&b<c"));
422        assert!(
423            sink.chunks > 1,
424            "expected the encoder to write runs straight to the formatter, got {} chunk(s)",
425            sink.chunks
426        );
427    }
428
429    #[test]
430    fn format_spec_buffers_into_a_single_chunk() {
431        use std::fmt::Write;
432        let mut sink = ChunkCounter::default();
433        write!(sink, "{:.99}", display_html("a&b<c")).unwrap();
434        assert_eq!(sink.out, format!("{:.99}", crate::for_html("a&b<c")));
435        assert_eq!(sink.chunks, 1);
436    }
437}