1use memchr::{memchr2, memchr3};
2
3use crate::render::parts_of;
4use crate::syntax::{Attribute, Expression, LocatedError};
5
6const SAFE_SCHEMES: [&str; 4] = ["http", "https", "mailto", "tel"];
8
9const UNSAFE_URL_REPLACEMENT: &str = "#";
10
11pub fn push_escaped(out: &mut String, text: &str) {
12 let bytes = text.as_bytes();
13 let markup = memchr3(b'&', b'<', b'>', bytes);
14 let Some(first) = markup.into_iter().chain(memchr2(b'"', b'\'', bytes)).min() else {
15 out.push_str(text);
16 return;
17 };
18 out.push_str(text.get(..first).unwrap_or_default());
19 push_escaped_bytewise(out, text.get(first..).unwrap_or_default());
20}
21
22fn push_escaped_bytewise(out: &mut String, text: &str) {
23 let mut chunk_start = 0;
24 for (index, byte) in text.bytes().enumerate() {
25 let replacement = match byte {
26 b'&' => "&",
27 b'<' => "<",
28 b'>' => ">",
29 b'"' => """,
30 b'\'' => "'",
31 _ => continue,
32 };
33 out.push_str(text.get(chunk_start..index).unwrap_or_default());
34 out.push_str(replacement);
35 chunk_start = index + 1;
36 }
37 out.push_str(text.get(chunk_start..).unwrap_or_default());
38}
39
40pub(crate) fn push_url(out: &mut String, url: &str, is_authored: bool) {
41 if !is_safe_url(url) {
42 out.push_str(UNSAFE_URL_REPLACEMENT);
43 } else if is_authored {
44 out.push_str(url);
45 } else {
46 push_escaped(out, url);
47 }
48}
49
50fn is_safe_url(url: &str) -> bool {
52 let Some(end) = url.find([':', '/', '?', '#']) else {
53 return true;
54 };
55 if !url.get(end..).is_some_and(|rest| rest.starts_with(':')) {
56 return true;
57 }
58 let scheme = url
59 .get(..end)
60 .unwrap_or_default()
61 .bytes()
62 .filter(|byte| *byte > b' ')
63 .map(|byte| byte.to_ascii_lowercase());
64 SAFE_SCHEMES
65 .iter()
66 .any(|safe| scheme.clone().eq(safe.bytes()))
67}
68
69pub(crate) fn check_authored_url(attribute: &Attribute) -> Result<(), LocatedError> {
72 let authored = parts_of(&attribute.value)
73 .iter()
74 .filter_map(|part| match part {
75 Expression::Authored(text) => Some(text.as_str()),
76 _ => None,
77 });
78 for text in authored {
79 match text.find(['&', '/', '?', '#']) {
80 Some(found) if text.as_bytes()[found] == b'&' => {
81 return Err(LocatedError::new(
82 attribute.position,
83 format!(
84 "`{}` has `&` before its first `/`, `?` or `#`, where a character reference could spell a scheme; write the characters themselves",
85 attribute.name
86 ),
87 ));
88 }
89 Some(_) => return Ok(()),
90 None => {}
91 }
92 }
93 Ok(())
94}
95
96#[cfg(any(feature = "islands", feature = "clients"))]
98pub(crate) fn push_script_json(html: &mut String, json: &str) {
99 let mut run_start = 0;
100 for (index, character) in json.char_indices() {
101 let replacement = match character {
102 '<' => "\\u003c",
103 '>' => "\\u003e",
104 '&' => "\\u0026",
105 '\u{2028}' => "\\u2028",
106 '\u{2029}' => "\\u2029",
107 _ => continue,
108 };
109 html.push_str(json.get(run_start..index).unwrap_or_default());
110 html.push_str(replacement);
111 run_start = index + character.len_utf8();
112 }
113 html.push_str(json.get(run_start..).unwrap_or_default());
114}
115
116#[cfg(test)]
117mod tests {
118 use super::{is_safe_url, push_escaped};
119
120 #[cfg(any(feature = "islands", feature = "clients"))]
121 #[test]
122 fn script_json_escapes_what_could_end_the_element_and_keeps_the_rest() {
123 let mut html = String::from("x");
124 super::push_script_json(
125 &mut html,
126 &serde_json::json!("é</script>\u{2028}ü&").to_string(),
127 );
128 assert_eq!(html, "x\"é\\u003c/script\\u003e\\u2028ü\\u0026\"");
129 }
130
131 #[test]
132 fn escapes_the_five_html_characters() {
133 let mut out = String::from("x");
134 push_escaped(&mut out, "<a href=\"1\">'é' & b</a>");
135 assert_eq!(
136 out,
137 "x<a href="1">'é' & b</a>"
138 );
139 }
140
141 #[test]
142 fn escapes_from_the_first_special_character_of_either_kind() {
143 for (text, escaped) in [
144 ("plain text, nothing to do", "plain text, nothing to do"),
145 ("it's", "it's"),
146 ("a \"b\" <c>", "a "b" <c>"),
147 ("é & ü", "é & ü"),
148 ("", ""),
149 ] {
150 let mut out = String::new();
151 push_escaped(&mut out, text);
152 assert_eq!(out, escaped, "{text:?}");
153 }
154 }
155
156 #[test]
157 fn allows_relative_and_web_urls() {
158 for url in [
159 "/orders/1",
160 "orders?a=b:c",
161 "#top",
162 "https://x.test",
163 "MAILTO:a@b.test",
164 "",
165 ] {
166 assert!(is_safe_url(url), "{url}");
167 }
168 }
169
170 #[test]
171 fn rejects_script_and_data_urls_in_any_disguise() {
172 for url in [
173 "javascript:alert(1)",
174 " JaVaScRiPt:x",
175 "java\tscript:x",
176 "\u{1}javascript:x",
177 "data:text/html,x",
178 "vbscript:x",
179 ] {
180 assert!(!is_safe_url(url), "{url:?}");
181 }
182 }
183}