1use std::collections::HashSet;
2
3use crate::dom::{Dom, NodeId};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum ResourceType {
7 Stylesheet,
8 Script,
9 Image,
10 Font,
11 Media,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ResourceUrl {
16 pub resource_type: ResourceType,
17 pub url: String,
18}
19
20fn get_attr<'a>(attrs: &'a [crate::dom::Attribute], name: &str) -> Option<&'a str> {
21 attrs
22 .iter()
23 .find(|a| a.name.local.eq_ignore_ascii_case(name))
24 .map(|a| a.value.as_str())
25}
26
27fn extract_from_element(dom: &Dom, id: NodeId) -> Option<ResourceUrl> {
28 let node = dom.get(id)?;
29 let elem = node.as_element()?;
30 let tag = elem.name.local.as_str();
31 match tag {
32 "link" => {
33 let rel = get_attr(&elem.attrs, "rel").unwrap_or("");
34 let href = get_attr(&elem.attrs, "href")?;
35 if rel.eq_ignore_ascii_case("stylesheet") {
36 Some(ResourceUrl {
37 resource_type: ResourceType::Stylesheet,
38 url: href.to_owned(),
39 })
40 } else if rel.eq_ignore_ascii_case("preload") {
41 let as_type = get_attr(&elem.attrs, "as").unwrap_or("");
42 let rt = if as_type.eq_ignore_ascii_case("font") {
43 ResourceType::Font
44 } else if as_type.eq_ignore_ascii_case("style") {
45 ResourceType::Stylesheet
46 } else if as_type.eq_ignore_ascii_case("script") {
47 ResourceType::Script
48 } else if as_type.eq_ignore_ascii_case("image") {
49 ResourceType::Image
50 } else {
51 return None;
52 };
53 Some(ResourceUrl {
54 resource_type: rt,
55 url: href.to_owned(),
56 })
57 } else {
58 None
59 }
60 }
61 "script" => {
62 let src = get_attr(&elem.attrs, "src")?;
63 Some(ResourceUrl {
64 resource_type: ResourceType::Script,
65 url: src.to_owned(),
66 })
67 }
68 "img" => {
69 let src = get_attr(&elem.attrs, "src")?;
70 Some(ResourceUrl {
71 resource_type: ResourceType::Image,
72 url: src.to_owned(),
73 })
74 }
75 "video" | "audio" => {
76 let src = get_attr(&elem.attrs, "src")?;
77 Some(ResourceUrl {
78 resource_type: ResourceType::Media,
79 url: src.to_owned(),
80 })
81 }
82 _ => None,
83 }
84}
85
86pub fn extract_resource_urls(dom: &Dom) -> Vec<ResourceUrl> {
88 let mut results = Vec::new();
89 let mut seen = HashSet::new();
90 let mut stack = vec![NodeId::DOCUMENT];
91 let mut visited = HashSet::new();
92
93 while let Some(id) = stack.pop() {
94 if !visited.insert(id) {
95 continue;
96 }
97 if let Some(resource) = extract_from_element(dom, id) {
98 if seen.insert(resource.url.clone()) {
99 results.push(resource);
100 }
101 }
102 let children = dom.children(id);
104 for child in children.into_iter().rev() {
105 stack.push(child);
106 }
107 }
108
109 results
110}
111
112#[derive(Debug, Clone)]
114pub struct LoadedResource {
115 pub url: String,
116 pub resource_type: ResourceType,
117 pub content: String,
118 pub content_type: Option<String>,
119}
120
121pub async fn fetch_resources(
127 urls: Vec<ResourceUrl>,
128 block_types: &HashSet<ResourceType>,
129 max_concurrent: usize,
130) -> Vec<LoadedResource> {
131 let filtered = filter_by_block_types(urls, block_types);
132 if filtered.is_empty() {
133 return Vec::new();
134 }
135
136 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent));
137 let client = hpx::Client::new();
138
139 let total = tokio::time::timeout(std::time::Duration::from_secs(15), async {
140 let mut set = tokio::task::JoinSet::new();
141
142 for resource in filtered {
143 let sem = semaphore.clone();
144 let client = client.clone();
145 set.spawn(async move {
146 let _permit = match sem.acquire().await {
147 Ok(p) => p,
148 Err(_) => return None,
149 };
150 let result = tokio::time::timeout(
151 std::time::Duration::from_secs(5),
152 client.get(&resource.url).send(),
153 )
154 .await;
155
156 match result {
157 Ok(Ok(resp)) => {
158 let content_type = resp
159 .headers()
160 .get("content-type")
161 .and_then(|v| v.to_str().ok())
162 .map(|s| s.to_string());
163 let url = resp.uri().to_string();
164 match resp.text().await {
165 Ok(text) => Some(LoadedResource {
166 url,
167 resource_type: resource.resource_type,
168 content: text,
169 content_type,
170 }),
171 Err(_) => None,
172 }
173 }
174 _ => None,
175 }
176 });
177 }
178
179 let mut results = Vec::new();
180 while let Some(res) = set.join_next().await {
181 if let Ok(Some(loaded)) = res {
182 results.push(loaded);
183 }
184 }
185 results
186 })
187 .await;
188
189 total.unwrap_or_default()
190}
191
192pub fn filter_by_block_types(
194 resources: Vec<ResourceUrl>,
195 block_types: &HashSet<ResourceType>,
196) -> Vec<ResourceUrl> {
197 resources
198 .into_iter()
199 .filter(|r| !block_types.contains(&r.resource_type))
200 .collect()
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::html_parser::parse_html;
207
208 #[test]
209 fn extract_stylesheet() {
210 let dom = parse_html(
211 r#"<html><head><link rel="stylesheet" href="/style.css"></head><body></body></html>"#,
212 );
213 let urls = extract_resource_urls(&dom);
214 assert_eq!(urls.len(), 1);
215 assert_eq!(urls[0].resource_type, ResourceType::Stylesheet);
216 assert_eq!(urls[0].url, "/style.css");
217 }
218
219 #[test]
220 fn extract_script() {
221 let dom =
222 parse_html(r#"<html><head><script src="/app.js"></script></head><body></body></html>"#);
223 let urls = extract_resource_urls(&dom);
224 assert_eq!(urls.len(), 1);
225 assert_eq!(urls[0].resource_type, ResourceType::Script);
226 assert_eq!(urls[0].url, "/app.js");
227 }
228
229 #[test]
230 fn extract_img() {
231 let dom = parse_html(r#"<html><body><img src="/logo.png"></body></html>"#);
232 let urls = extract_resource_urls(&dom);
233 assert_eq!(urls.len(), 1);
234 assert_eq!(urls[0].resource_type, ResourceType::Image);
235 assert_eq!(urls[0].url, "/logo.png");
236 }
237
238 #[test]
239 fn extract_video_and_audio() {
240 let dom = parse_html(
241 r#"<html><body><video src="/v.mp4"></video><audio src="/a.mp3"></audio></body></html>"#,
242 );
243 let urls = extract_resource_urls(&dom);
244 assert_eq!(urls.len(), 2);
245 assert!(urls.iter().all(|r| r.resource_type == ResourceType::Media));
246 }
247
248 #[test]
249 fn extract_preload_font() {
250 let dom = parse_html(
251 r#"<html><head><link rel="preload" href="/font.woff2" as="font" crossorigin></head><body></body></html>"#,
252 );
253 let urls = extract_resource_urls(&dom);
254 assert_eq!(urls.len(), 1);
255 assert_eq!(urls[0].resource_type, ResourceType::Font);
256 assert_eq!(urls[0].url, "/font.woff2");
257 }
258
259 #[test]
260 fn inline_script_ignored() {
261 let dom = parse_html(r#"<html><head><script>alert(1)</script></head><body></body></html>"#);
262 let urls = extract_resource_urls(&dom);
263 assert!(urls.is_empty());
264 }
265
266 #[test]
267 fn all_resource_types() {
268 let dom = parse_html(
269 r#"<html>
270 <head>
271 <link rel="stylesheet" href="/style.css">
272 <script src="/app.js"></script>
273 <link rel="preload" href="/font.woff2" as="font">
274 </head>
275 <body>
276 <img src="/photo.jpg">
277 <video src="/clip.mp4"></video>
278 </body>
279 </html>"#,
280 );
281 let urls = extract_resource_urls(&dom);
282 assert_eq!(urls.len(), 5);
283 let types: HashSet<ResourceType> = urls.iter().map(|r| r.resource_type).collect();
284 assert!(types.contains(&ResourceType::Stylesheet));
285 assert!(types.contains(&ResourceType::Script));
286 assert!(types.contains(&ResourceType::Image));
287 assert!(types.contains(&ResourceType::Font));
288 assert!(types.contains(&ResourceType::Media));
289 }
290
291 #[test]
292 fn dedup_same_url() {
293 let dom = parse_html(
294 r#"<html><head>
295 <link rel="stylesheet" href="/style.css">
296 <link rel="stylesheet" href="/style.css">
297 </head><body></body></html>"#,
298 );
299 let urls = extract_resource_urls(&dom);
300 assert_eq!(urls.len(), 1);
301 }
302
303 #[test]
304 fn filter_blocks_stylesheets() {
305 let dom = parse_html(
306 r#"<html><head>
307 <link rel="stylesheet" href="/style.css">
308 <script src="/app.js"></script>
309 </head><body><img src="/logo.png"></body></html>"#,
310 );
311 let urls = extract_resource_urls(&dom);
312 let mut block = HashSet::new();
313 block.insert(ResourceType::Stylesheet);
314 let filtered = filter_by_block_types(urls, &block);
315 assert_eq!(filtered.len(), 2);
316 assert!(
317 filtered
318 .iter()
319 .all(|r| r.resource_type != ResourceType::Stylesheet)
320 );
321 }
322
323 #[test]
324 fn filter_empty_block_returns_all() {
325 let dom = parse_html(
326 r#"<html><head><link rel="stylesheet" href="/s.css"></head><body><img src="/i.png"></body></html>"#,
327 );
328 let urls = extract_resource_urls(&dom);
329 let block = HashSet::new();
330 let filtered = filter_by_block_types(urls.clone(), &block);
331 assert_eq!(filtered.len(), urls.len());
332 }
333
334 #[test]
335 fn filter_all_blocks_returns_empty() {
336 let dom = parse_html(
337 r#"<html><head>
338 <link rel="stylesheet" href="/style.css">
339 <script src="/app.js"></script>
340 </head><body><img src="/logo.png"></body></html>"#,
341 );
342 let urls = extract_resource_urls(&dom);
343 let mut block = HashSet::new();
344 block.insert(ResourceType::Stylesheet);
345 block.insert(ResourceType::Script);
346 block.insert(ResourceType::Image);
347 block.insert(ResourceType::Font);
348 block.insert(ResourceType::Media);
349 let filtered = filter_by_block_types(urls, &block);
350 assert!(filtered.is_empty());
351 }
352
353 #[test]
354 fn filter_idempotent() {
355 let dom = parse_html(
356 r#"<html><head>
357 <link rel="stylesheet" href="/style.css">
358 <script src="/app.js"></script>
359 </head><body><img src="/logo.png"></body></html>"#,
360 );
361 let urls = extract_resource_urls(&dom);
362 let mut block = HashSet::new();
363 block.insert(ResourceType::Stylesheet);
364 let once = filter_by_block_types(urls.clone(), &block);
365 let twice = filter_by_block_types(once.clone(), &block);
366 assert_eq!(once, twice);
367 }
368
369 #[test]
370 fn block_images_from_html() {
371 let dom =
372 parse_html(r#"<html><body><img src="/photo.jpg"><img src="/icon.png"></body></html>"#);
373 let urls = extract_resource_urls(&dom);
374 assert_eq!(urls.len(), 2);
375 assert!(urls.iter().all(|r| r.resource_type == ResourceType::Image));
376 let mut block = HashSet::new();
377 block.insert(ResourceType::Image);
378 let filtered = filter_by_block_types(urls, &block);
379 assert!(filtered.is_empty());
380 }
381
382 #[test]
383 fn no_resources() {
384 let dom = parse_html(r#"<html><body><p>Hello</p></body></html>"#);
385 let urls = extract_resource_urls(&dom);
386 assert!(urls.is_empty());
387 }
388
389 #[tokio::test]
390 async fn fetch_resources_concurrent() {
391 use tokio::io::AsyncWriteExt;
392
393 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
394 let addr = listener.local_addr().unwrap();
395 let base = format!("http://{addr}");
396
397 let resources = vec![
398 ("/style.css", "text/css", "body{}"),
399 ("/app.js", "application/javascript", "alert(1)"),
400 ("/logo.png", "image/png", "PNGDATA"),
401 ];
402
403 let server_resources = resources.clone();
404 let server = tokio::spawn(async move {
405 for (path, ct, body) in &server_resources {
406 let (mut stream, _) = listener.accept().await.unwrap();
407 let response = format!(
408 "HTTP/1.1 200 OK\r\nContent-Type: {ct}\r\nContent-Length: {}\r\n\r\n{body}",
409 body.len()
410 );
411 stream.write_all(response.as_bytes()).await.unwrap();
412 }
413 });
414
415 let urls: Vec<ResourceUrl> = resources
416 .iter()
417 .map(|(path, _, _)| ResourceUrl {
418 resource_type: if path.ends_with(".css") {
419 ResourceType::Stylesheet
420 } else if path.ends_with(".js") {
421 ResourceType::Script
422 } else {
423 ResourceType::Image
424 },
425 url: format!("{base}{path}"),
426 })
427 .collect();
428
429 let loaded = fetch_resources(urls, &HashSet::new(), 6).await;
430 server.await.unwrap();
431
432 assert_eq!(loaded.len(), 3);
433 let mut contents: Vec<&str> = loaded.iter().map(|r| r.content.as_str()).collect();
434 contents.sort();
435 assert_eq!(contents, vec!["PNGDATA", "alert(1)", "body{}"]);
436 }
437
438 #[tokio::test]
439 async fn fetch_resources_respects_block_types() {
440 use tokio::io::AsyncWriteExt;
441
442 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
443 let addr = listener.local_addr().unwrap();
444 let base = format!("http://{addr}");
445
446 let server = tokio::spawn(async move {
447 let (mut stream, _) = listener.accept().await.unwrap();
448 let body = "body{}";
449 let response = format!(
450 "HTTP/1.1 200 OK\r\nContent-Type: text/css\r\nContent-Length: {}\r\n\r\n{body}",
451 body.len()
452 );
453 stream.write_all(response.as_bytes()).await.unwrap();
454 });
455
456 let urls = vec![ResourceUrl {
457 resource_type: ResourceType::Stylesheet,
458 url: format!("{base}/style.css"),
459 }];
460
461 let mut block = HashSet::new();
462 block.insert(ResourceType::Stylesheet);
463 let loaded = fetch_resources(urls, &block, 6).await;
464
465 assert!(loaded.is_empty());
466 }
468
469 #[tokio::test]
470 async fn fetch_resources_empty_input() {
471 let loaded = fetch_resources(vec![], &HashSet::new(), 6).await;
472 assert!(loaded.is_empty());
473 }
474}
475
476#[cfg(test)]
477#[cfg(feature = "proptest")]
478mod proptests {
479 use proptest::prelude::*;
480
481 use super::*;
482 use crate::html_parser::parse_html;
483
484 fn url_strategy() -> impl Strategy<Value = String> {
485 prop::collection::vec("[a-zA-Z0-9/._-]", 1..20).prop_map(|chars| chars.join(""))
486 }
487
488 fn resource_tag_strategy() -> impl Strategy<Value = String> {
489 prop_oneof![
490 url_strategy().prop_map(|href| format!(r#"<link rel="stylesheet" href="{}">"#, href)),
492 url_strategy().prop_map(|src| format!(r#"<script src="{}"></script>"#, src)),
494 url_strategy().prop_map(|src| format!(r#"<img src="{}">"#, src)),
496 url_strategy().prop_map(|src| format!(r#"<video src="{}"></video>"#, src)),
498 url_strategy().prop_map(|src| format!(r#"<audio src="{}"></audio>"#, src)),
500 ]
501 }
502
503 fn html_with_resources_strategy() -> impl Strategy<Value = String> {
504 prop::collection::vec(resource_tag_strategy(), 0..10).prop_map(|tags| {
505 format!(
506 r#"<html><head>{}</head><body>{}</body></html>"#,
507 tags[..tags.len().min(tags.len() / 2)].join(""),
508 tags[tags.len().min(tags.len() / 2)..].join("")
509 )
510 })
511 }
512
513 proptest! {
514 #[test]
515 fn extracted_urls_are_nonempty_strings(html in html_with_resources_strategy()) {
516 let dom = parse_html(&html);
517 let urls = extract_resource_urls(&dom);
518 for r in &urls {
519 prop_assert!(!r.url.is_empty());
520 }
521 }
522
523 #[test]
524 fn block_filtering_removes_blocked_types(html in html_with_resources_strategy()) {
525 let dom = parse_html(&html);
526 let urls = extract_resource_urls(&dom);
527 let mut block = HashSet::new();
528 block.insert(ResourceType::Stylesheet);
529 let filtered = filter_by_block_types(urls, &block);
530 for r in &filtered {
531 prop_assert_ne!(r.resource_type, ResourceType::Stylesheet);
532 }
533 }
534
535 #[test]
536 fn block_filtering_is_idempotent(html in html_with_resources_strategy()) {
537 let dom = parse_html(&html);
538 let urls = extract_resource_urls(&dom);
539 let mut block = HashSet::new();
540 block.insert(ResourceType::Script);
541 let once = filter_by_block_types(urls.clone(), &block);
542 let twice = filter_by_block_types(once.clone(), &block);
543 prop_assert_eq!(once, twice);
544 }
545 }
546}