1pub const MAX_SUBRESOURCES: usize = 64;
25
26const FETCHED_RELS: [&str; 5] = ["stylesheet", "icon", "preload", "modulepreload", "prefetch"];
31
32pub fn scan(html: &[u8], max: usize) -> Vec<String> {
34 let mut out: Vec<String> = Vec::new();
35 let mut i = 0;
36
37 while i < html.len() && out.len() < max {
38 if html[i] != b'<' {
39 i += 1;
40 continue;
41 }
42 if html[i..].starts_with(b"<!--") {
44 i = match find(html, i + 4, b"-->") {
45 Some(at) => at + 3,
46 None => break,
47 };
48 continue;
49 }
50
51 let (name, after_name) = tag_name(html, i + 1);
52 if name.is_empty() {
53 i += 1;
54 continue;
55 }
56 let (attrs, after_tag) = attributes(html, after_name);
57
58 match name.as_str() {
59 "link" => {
60 let fetched = value(&attrs, "rel").is_some_and(|rel| {
63 rel.split_whitespace()
64 .any(|r| FETCHED_RELS.contains(&r.to_ascii_lowercase().as_str()))
65 });
66 if fetched {
67 push(&mut out, value(&attrs, "href"));
68 }
69 }
70 "script" | "img" | "source" | "audio" | "video" | "iframe" | "embed" => {
71 push(&mut out, value(&attrs, "src"));
72 }
73 _ => {}
74 }
75
76 i = match name.as_str() {
79 "script" | "style" => {
80 find_close(html, after_tag, name.as_bytes()).unwrap_or(html.len())
81 }
82 _ => after_tag,
83 };
84 }
85
86 out
87}
88
89fn push(out: &mut Vec<String>, raw: Option<&str>) {
90 if let Some(url) = raw.and_then(usable) {
91 if !out.contains(&url) {
92 out.push(url);
93 }
94 }
95}
96
97fn usable(raw: &str) -> Option<String> {
99 let cut = raw.find(['?', '#']).unwrap_or(raw.len());
100 let url = raw[..cut].trim();
101 if url.is_empty() {
102 return None;
103 }
104 if url.starts_with("//") {
106 return None;
107 }
108 if let Some(colon) = url.find(':') {
111 if !url[..colon].contains('/') {
112 return None;
113 }
114 }
115 Some(url.to_string())
116}
117
118fn find(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
119 if from >= haystack.len() || needle.len() > haystack.len() - from {
120 return None;
121 }
122 haystack[from..]
123 .windows(needle.len())
124 .position(|w| w == needle)
125 .map(|at| from + at)
126}
127
128fn find_close(html: &[u8], from: usize, name: &[u8]) -> Option<usize> {
130 let mut i = from;
131 while i + 2 + name.len() <= html.len() {
132 if html[i] == b'<'
133 && html[i + 1] == b'/'
134 && html[i + 2..i + 2 + name.len()].eq_ignore_ascii_case(name)
135 {
136 return Some(i);
137 }
138 i += 1;
139 }
140 None
141}
142
143fn skip_space(html: &[u8], from: usize) -> usize {
145 let mut i = from;
146 while i < html.len() && html[i].is_ascii_whitespace() {
147 i += 1;
148 }
149 i
150}
151
152fn tag_name(html: &[u8], from: usize) -> (String, usize) {
154 let mut end = from;
155 while end < html.len() && html[end].is_ascii_alphanumeric() {
156 end += 1;
157 }
158 let name = String::from_utf8_lossy(&html[from..end]).to_ascii_lowercase();
159 (name, end)
160}
161
162fn attributes(html: &[u8], from: usize) -> (Vec<(String, String)>, usize) {
164 let mut attrs = Vec::new();
165 let mut i = from;
166
167 while i < html.len() {
168 while i < html.len() && (html[i].is_ascii_whitespace() || html[i] == b'/') {
169 i += 1;
170 }
171 if i >= html.len() || html[i] == b'>' {
172 break;
173 }
174
175 let start = i;
176 while i < html.len() && !html[i].is_ascii_whitespace() && html[i] != b'=' && html[i] != b'>'
177 {
178 i += 1;
179 }
180 let name = String::from_utf8_lossy(&html[start..i]).to_ascii_lowercase();
181
182 i = skip_space(html, i);
183 if i >= html.len() || html[i] != b'=' {
184 attrs.push((name, String::new()));
186 continue;
187 }
188 i += 1;
189 i = skip_space(html, i);
190 if i >= html.len() {
191 break;
192 }
193
194 let (raw, next) = match html[i] {
195 q @ (b'"' | b'\'') => {
196 let start = i + 1;
197 let end = find(html, start, &[q]).unwrap_or(html.len());
198 (&html[start..end], (end + 1).min(html.len()))
199 }
200 _ => {
201 let start = i;
202 let mut end = i;
203 while end < html.len() && !html[end].is_ascii_whitespace() && html[end] != b'>' {
204 end += 1;
205 }
206 (&html[start..end], end)
207 }
208 };
209 attrs.push((name, String::from_utf8_lossy(raw).into_owned()));
210 i = next;
211 }
212
213 (attrs, (i + 1).min(html.len()))
214}
215
216fn value<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> {
217 attrs
218 .iter()
219 .find(|(n, _)| n == name)
220 .map(|(_, v)| v.as_str())
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 const MANY: usize = 1000;
228
229 #[test]
230 fn finds_the_three_references_a_generated_page_actually_has() {
231 let html = br#"<!doctype html><html><head>
232 <link rel="stylesheet" href="style.css">
233 <script src="app.js" defer></script>
234 </head><body><img src="plot.png" alt="a plot"></body></html>"#;
235 assert_eq!(scan(html, MANY), ["style.css", "app.js", "plot.png"]);
236 }
237
238 #[test]
239 fn reads_single_quoted_and_unquoted_values() {
240 let html = br#"<img src='a.png'><img src=b.png><img src = "c.png">"#;
241 assert_eq!(scan(html, MANY), ["a.png", "b.png", "c.png"]);
242 }
243
244 #[test]
245 fn tag_and_attribute_names_are_matched_regardless_of_case() {
246 let html = br#"<LINK REL="Stylesheet" HREF="a.css"><IMG SRC="b.png">"#;
247 assert_eq!(scan(html, MANY), ["a.css", "b.png"]);
248 }
249
250 #[test]
253 fn a_link_the_browser_would_not_fetch_is_left_alone() {
254 let html = br#"<link rel="stylesheet" href="yes.css">
255 <link rel="alternate" href="no.pdf">
256 <link rel="canonical" href="no.html">
257 <link href="no-rel.css">
258 <link rel="icon" href="yes.ico">
259 <link rel="preload modulepreload" href="yes.mjs">"#;
260 assert_eq!(scan(html, MANY), ["yes.css", "yes.ico", "yes.mjs"]);
261 }
262
263 #[test]
266 fn ordinary_links_are_not_subresources() {
267 let html = br#"<a href="other.html">other</a><form action="post.cgi"></form>"#;
268 assert!(scan(html, MANY).is_empty());
269 }
270
271 #[test]
272 fn references_to_somewhere_else_are_skipped() {
273 let html = br##"<img src="https://example.com/a.png">
275 <img src="//example.com/b.png">
276 <img src="data:image/gif;base64,R0lGOD">
277 <script src="http://example.com/c.js"></script>
278 <img src="">
279 <img src="#">"##;
280 assert!(scan(html, MANY).is_empty());
281 }
282
283 #[test]
285 fn a_query_or_fragment_is_cut_off() {
286 let html = br#"<link rel="stylesheet" href="style.css?v=3">
287 <img src="sprite.svg#icon">"#;
288 assert_eq!(scan(html, MANY), ["style.css", "sprite.svg"]);
289 }
290
291 #[test]
295 fn a_colon_after_a_slash_is_not_a_scheme() {
296 let html = br#"<img src="plots/t:0.5.png">"#;
297 assert_eq!(scan(html, MANY), ["plots/t:0.5.png"]);
298 }
299
300 #[test]
303 fn a_script_body_is_not_read_as_markup() {
304 let html = br#"<script src="a.js">if (x<y) { var s = "<img src=fake.png>"; }</script>
305 <img src="real.png">"#;
306 assert_eq!(scan(html, MANY), ["a.js", "real.png"]);
307 }
308
309 #[test]
310 fn a_style_body_is_not_read_as_markup() {
311 let html = br#"<style>a::before { content: "<img src=fake.png>"; }</style>
312 <img src="real.png">"#;
313 assert_eq!(scan(html, MANY), ["real.png"]);
314 }
315
316 #[test]
317 fn a_comment_cannot_smuggle_a_reference() {
318 let html = br#"<!-- <img src="commented.png"> --><img src="real.png">"#;
319 assert_eq!(scan(html, MANY), ["real.png"]);
320 }
321
322 #[test]
323 fn the_same_reference_is_only_returned_once() {
324 let html = br#"<img src="a.png"><img src="a.png"><img src="a.png">"#;
325 assert_eq!(scan(html, MANY), ["a.png"]);
326 }
327
328 #[test]
329 fn the_cap_is_honoured() {
330 let html: Vec<u8> = (0..100)
331 .map(|i| format!("<img src=\"{i}.png\">"))
332 .collect::<String>()
333 .into_bytes();
334 assert_eq!(scan(&html, 10).len(), 10);
335 assert_eq!(scan(&html, MAX_SUBRESOURCES).len(), MAX_SUBRESOURCES);
336 }
337
338 #[test]
341 fn truncated_markup_does_not_panic() {
342 for html in [
343 &b"<img src=\"a.png"[..],
344 b"<img src=",
345 b"<img",
346 b"<",
347 b"<!--",
348 b"<!-- <img src=\"a.png\">",
349 b"<script src=\"a.js\">unclosed",
350 b"<link rel=",
351 b"<img src='",
352 ] {
353 let _ = scan(html, MANY);
354 }
355 }
356
357 #[test]
358 fn an_empty_document_yields_nothing() {
359 assert!(scan(b"", MANY).is_empty());
360 assert!(scan(b"no markup at all", MANY).is_empty());
361 }
362}