Skip to main content

rullst_core/
html.rs

1use std::borrow::Cow;
2
3/// Trait implemented by types that can be rendered safely into HTML.
4pub trait HtmlEscape {
5    /// Escapes characters in `self` so they can be rendered safely as HTML.
6    fn escape_html(&self) -> Cow<'_, str>;
7}
8
9/// A wrapper to mark a string as safe raw HTML that should NOT be escaped.
10pub struct RawHtml(pub String);
11
12impl RawHtml {
13    /// Wraps a raw string as HTML that is trusted not to be escaped.
14    pub fn new<S: Into<String>>(s: S) -> Self {
15        RawHtml(s.into())
16    }
17}
18
19impl HtmlEscape for RawHtml {
20    fn escape_html(&self) -> Cow<'_, str> {
21        Cow::Borrowed(&self.0)
22    }
23}
24
25#[cfg_attr(mutants, mutants::skip)]
26impl HtmlEscape for String {
27    fn escape_html(&self) -> Cow<'_, str> {
28        escape_str(self)
29    }
30}
31
32impl HtmlEscape for &str {
33    fn escape_html(&self) -> Cow<'_, str> {
34        escape_str(self)
35    }
36}
37
38impl<'a> HtmlEscape for Cow<'a, str> {
39    fn escape_html(&self) -> Cow<'_, str> {
40        escape_str(self.as_ref())
41    }
42}
43
44#[cfg_attr(mutants, mutants::skip)]
45impl<T: HtmlEscape + ?Sized> HtmlEscape for &T {
46    fn escape_html(&self) -> Cow<'_, str> {
47        (*self).escape_html()
48    }
49}
50
51// Automatically treat numbers and booleans as safe since they cannot contain HTML characters
52macro_rules! impl_safe_primitives {
53    ($($t:ty),*) => {
54        $(
55            impl HtmlEscape for $t {
56                fn escape_html(&self) -> Cow<'_, str> {
57                    Cow::Owned(self.to_string())
58                }
59            }
60        )*
61    };
62}
63
64impl_safe_primitives!(
65    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool
66);
67
68/// Helper function to escape standard strings
69#[cfg_attr(mutants, mutants::skip)]
70#[inline(always)]
71pub fn escape_str(s: &str) -> Cow<'_, str> {
72    let bytes = s.as_bytes();
73    let mut last_pos = 0;
74    let mut escaped = String::with_capacity(0);
75
76    for (i, &b) in bytes.iter().enumerate() {
77        let replacement = match b {
78            b'<' => "&lt;",
79            b'>' => "&gt;",
80            b'&' => "&amp;",
81            b'"' => "&quot;",
82            b'\'' => "&#x27;",
83            _ => continue,
84        };
85
86        if last_pos == 0 {
87            escaped.reserve_exact(s.len() + 16);
88        }
89
90        escaped.push_str(&s[last_pos..i]);
91        escaped.push_str(replacement);
92        last_pos = i + 1;
93    }
94
95    if last_pos == 0 {
96        Cow::Borrowed(s)
97    } else {
98        escaped.push_str(&s[last_pos..]);
99        Cow::Owned(escaped)
100    }
101}
102
103/// The core escape function invoked by the `html!` macro
104#[inline(always)]
105pub fn escape<T: HtmlEscape + ?Sized>(val: &T) -> Cow<'_, str> {
106    val.escape_html()
107}
108
109/// Helper function to escape attribute values
110#[inline(always)]
111pub fn escape_attr<T: HtmlEscape + ?Sized>(val: &T) -> Cow<'_, str> {
112    val.escape_html()
113}
114
115#[cfg(test)]
116#[allow(clippy::unwrap_used, clippy::expect_used)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_escape_complex_javascript_types() {
122        let js = r#"<script>let a = {"b": 1, c: '2', d: [1,2,3]}; alert(a);</script>"#;
123        let expected = "&lt;script&gt;let a = {&quot;b&quot;: 1, c: &#x27;2&#x27;, d: [1,2,3]}; alert(a);&lt;/script&gt;";
124        assert_eq!(escape_str(js), expected);
125    }
126
127    #[test]
128    fn test_escape_attr() {
129        let text = "<script>alert('xss')</script>";
130        let expected = "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;";
131        assert_eq!(escape_attr(&text), expected);
132    }
133
134    #[test]
135    fn test_escape_str_edge_cases() {
136        assert_eq!(escape_str(""), "");
137        assert_eq!(escape_str("Café & croissant"), "Café &amp; croissant");
138        assert_eq!(escape_str("<script>"), "&lt;script&gt;");
139        assert_eq!(escape_str("\"'"), "&quot;&#x27;");
140    }
141
142    #[test]
143    fn test_raw_html_new() {
144        let raw = RawHtml::new("<b>bold</b>");
145        assert_eq!(raw.0, "<b>bold</b>");
146    }
147}
148
149#[cfg(test)]
150#[path = "html_macro_tests.rs"]
151mod macro_tests;
152
153#[cfg(kani)]
154#[cfg_attr(mutants, mutants::skip)]
155mod kani_proofs {
156    use super::*;
157
158    /// Proves panic freedom and the length bound for every valid UTF-8 value
159    /// represented by exactly four symbolic bytes.
160    #[kani::proof]
161    #[kani::unwind(6)]
162    fn proof_escape_str_safety_and_bounds() {
163        let bytes: [u8; 4] = kani::any();
164
165        if let Ok(s) = std::str::from_utf8(&bytes) {
166            let escaped = escape_str(s);
167            assert!(escaped.len() >= s.len());
168        }
169    }
170
171    /// Exhaustively proves the exact output for every single-byte ASCII input.
172    /// Keeping exact substitution separate from the four-byte safety proof
173    /// avoids an output-search loop whose SAT formula exhausted hosted runners.
174    #[kani::proof]
175    #[kani::unwind(3)]
176    fn proof_escape_ascii_byte_exact() {
177        let byte: u8 = kani::any();
178        kani::assume(byte.is_ascii());
179        let input = [byte];
180
181        if let Ok(s) = std::str::from_utf8(&input) {
182            let escaped = escape_str(s);
183            let output = escaped.as_bytes();
184
185            match byte {
186                b'<' => {
187                    assert!(output.len() == 4);
188                    assert!(output[0] == b'&');
189                    assert!(output[1] == b'l');
190                    assert!(output[2] == b't');
191                    assert!(output[3] == b';');
192                }
193                b'>' => {
194                    assert!(output.len() == 4);
195                    assert!(output[0] == b'&');
196                    assert!(output[1] == b'g');
197                    assert!(output[2] == b't');
198                    assert!(output[3] == b';');
199                }
200                b'&' => {
201                    assert!(output.len() == 5);
202                    assert!(output[0] == b'&');
203                    assert!(output[1] == b'a');
204                    assert!(output[2] == b'm');
205                    assert!(output[3] == b'p');
206                    assert!(output[4] == b';');
207                }
208                b'"' => {
209                    assert!(output.len() == 6);
210                    assert!(output[0] == b'&');
211                    assert!(output[1] == b'q');
212                    assert!(output[2] == b'u');
213                    assert!(output[3] == b'o');
214                    assert!(output[4] == b't');
215                    assert!(output[5] == b';');
216                }
217                b'\'' => {
218                    assert!(output.len() == 6);
219                    assert!(output[0] == b'&');
220                    assert!(output[1] == b'#');
221                    assert!(output[2] == b'x');
222                    assert!(output[3] == b'2');
223                    assert!(output[4] == b'7');
224                    assert!(output[5] == b';');
225                }
226                _ => {
227                    assert!(output.len() == 1);
228                    assert!(output[0] == byte);
229                }
230            }
231        }
232    }
233}