1use std::borrow::Cow;
2
3pub trait HtmlEscape {
5 fn escape_html(&self) -> Cow<'_, str>;
7}
8
9pub struct RawHtml(pub String);
11
12impl RawHtml {
13 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
51macro_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#[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'<' => "<",
79 b'>' => ">",
80 b'&' => "&",
81 b'"' => """,
82 b'\'' => "'",
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#[inline(always)]
105pub fn escape<T: HtmlEscape + ?Sized>(val: &T) -> Cow<'_, str> {
106 val.escape_html()
107}
108
109#[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 = "<script>let a = {"b": 1, c: '2', d: [1,2,3]}; alert(a);</script>";
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 = "<script>alert('xss')</script>";
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é & croissant");
138 assert_eq!(escape_str("<script>"), "<script>");
139 assert_eq!(escape_str("\"'"), ""'");
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 #[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 #[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}