1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
9
10use crate::{
11 context::ScanContext,
12 contracts::{ScanFinding, ScanPhase},
13 error::ScannerError,
14 http_evidence::HttpProbeMethod,
15 legacy_discovery::{DiscoveryDelta, DiscoveryForm, DiscoveryFormMethod},
16};
17use async_trait::async_trait;
18use html5ever::{ns, parse_document, tendril::TendrilSink, ParseOpts};
19use markup5ever_rcdom::{Handle, NodeData, RcDom};
20use url::Url;
21
22const CRAWL_ACTION_ID: &str = "legacy.discovery.crawl";
23const MAX_REFERENCES_PER_PAGE: usize = 1_024;
24const MAX_FORMS_PER_PAGE: usize = 256;
25const MAX_CONTROLS_PER_FORM: usize = 256;
26const MAX_CRAWLER_HTML_PARSE_BYTES: usize = 64 * 1024;
27const MAX_DISCOVERY_NAME_BYTES: usize = 1_024;
28const MAX_DISCOVERED_ENDPOINTS: usize = 4_096;
29const MAX_DISCOVERED_FORMS: usize = 1_024;
30
31#[derive(Debug)]
33pub struct CrawlPhase;
34
35#[async_trait]
36impl ScanPhase for CrawlPhase {
37 fn phase_number(&self) -> u8 {
38 2
39 }
40
41 fn name(&self) -> &'static str {
42 "Web Crawler & Parameter Discovery"
43 }
44
45 async fn execute(&self, ctx: &ScanContext) -> Result<Vec<ScanFinding>, ScannerError> {
46 ctx.log("Phase 2: bounded same-origin crawler initiated".to_string());
47
48 let limits = ctx.discovery_limits();
49 let discovery = crawl_with_broker(
50 ctx,
51 CrawlPolicy {
52 max_depth: limits.max_depth(),
53 max_pages: limits.max_pages(),
54 },
55 )
56 .await?;
57 let endpoint_count = discovery.endpoint_count();
58 let form_count = discovery.form_count();
59 discovery.commit(ctx)?;
60
61 ctx.log(format!(
62 "Phase 2: bounded crawl completed with {endpoint_count} endpoints and {form_count} forms"
63 ));
64 Ok(Vec::new())
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69struct CrawlPolicy {
70 max_depth: usize,
71 max_pages: usize,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
75enum FormMethod {
76 Get,
77 Post,
78 Dialog,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
82struct DiscoveredForm {
83 action: Url,
84 method: FormMethod,
85 controls: Vec<String>,
86}
87
88#[derive(Debug, Default, PartialEq, Eq)]
89struct ParsedDocument {
90 links: Vec<Url>,
91 forms: Vec<DiscoveredForm>,
92}
93
94#[derive(Debug, Default)]
95struct CrawlDiscovery {
96 endpoints: BTreeMap<String, BTreeSet<String>>,
97 visited: BTreeSet<String>,
98 forms: BTreeSet<DiscoveredForm>,
99}
100
101impl CrawlDiscovery {
102 fn register_endpoint(&mut self, url: &Url) {
103 let params = query_parameter_names(url);
104 self.endpoints
105 .entry(url.as_str().to_string())
106 .or_default()
107 .extend(params);
108 }
109
110 fn register_get_form(&mut self, form: &DiscoveredForm) {
111 let params = self
112 .endpoints
113 .entry(form.action.as_str().to_string())
114 .or_default();
115 params.extend(query_parameter_names(&form.action));
116 params.extend(form.controls.iter().cloned());
117 }
118
119 fn endpoint_count(&self) -> usize {
120 self.endpoints.len()
121 }
122
123 fn form_count(&self) -> usize {
124 self.forms.len()
125 }
126
127 fn commit(self, ctx: &ScanContext) -> Result<(), ScannerError> {
128 let mut delta = DiscoveryDelta::new();
129 for (url, params) in self.endpoints {
130 delta.record_endpoint(Url::parse(&url)?, params);
131 }
132 for url in self.visited {
133 delta.record_visited(Url::parse(&url)?);
134 }
135 for form in self.forms {
136 delta.record_form(DiscoveryForm::new(
137 form.action,
138 match form.method {
139 FormMethod::Get => DiscoveryFormMethod::Get,
140 FormMethod::Post => DiscoveryFormMethod::Post,
141 FormMethod::Dialog => DiscoveryFormMethod::Dialog,
142 },
143 form.controls,
144 )?);
145 }
146 ctx.commit_discovery(CRAWL_ACTION_ID, delta)
147 }
148}
149
150async fn crawl_with_broker(
151 ctx: &ScanContext,
152 policy: CrawlPolicy,
153) -> Result<CrawlDiscovery, ScannerError> {
154 let root = ctx.canonicalize_discovery_url(ctx.authorized_target())?;
155 let visited_before_crawl = ctx.discovery_snapshot().visited().clone();
156 let mut discovery = CrawlDiscovery::default();
157 discovery.register_endpoint(&root);
158
159 if policy.max_pages == 0 {
160 return Ok(discovery);
161 }
162
163 let mut queue = VecDeque::new();
164 if !visited_before_crawl.contains(root.as_str()) {
165 queue.push_back((root.clone(), 0_usize));
166 }
167 let mut scheduled = BTreeSet::from([root.as_str().to_string()]);
168
169 while let Some((page_url, depth)) = queue.pop_front() {
170 let response = ctx
171 .request(CRAWL_ACTION_ID, HttpProbeMethod::Get, page_url.clone())
172 .await?;
173 let requested = ctx.canonicalize_discovery_url(response.request_url())?;
174 let final_url = ctx.canonicalize_discovery_url(response.final_url())?;
175 if requested != page_url || final_url != page_url {
176 return Err(ScannerError::InvalidTarget);
177 }
178 discovery.visited.insert(page_url.as_str().to_string());
179
180 if (300..400).contains(&response.status())
183 || response.body_truncated()
184 || response.body().len() > MAX_CRAWLER_HTML_PARSE_BYTES
185 || !response
186 .content_type()
187 .is_some_and(supported_html_media_type)
188 {
189 continue;
190 }
191
192 let parsed = parse_html_document(&page_url, response.body());
193 let mut candidates = Vec::new();
194 for link in parsed.links {
195 let Some(candidate) = canonical_same_origin(ctx, &root, link)? else {
196 continue;
197 };
198 candidates.push((candidate, None));
199 }
200
201 for form in parsed.forms {
202 let Some(action) = canonical_same_origin(ctx, &root, form.action.clone())? else {
203 continue;
204 };
205 candidates.push((action.clone(), Some(DiscoveredForm { action, ..form })));
206 }
207 candidates.sort_by(|(left_url, left_form), (right_url, right_form)| {
208 left_url
209 .as_str()
210 .cmp(right_url.as_str())
211 .then_with(|| form_method_rank(left_form).cmp(&form_method_rank(right_form)))
212 .then_with(|| {
213 left_form
214 .as_ref()
215 .map(|form| form.controls.as_slice())
216 .cmp(&right_form.as_ref().map(|form| form.controls.as_slice()))
217 })
218 });
219
220 for (candidate, form) in candidates {
221 let Some(form) = form else {
222 if schedule_page(
223 &mut queue,
224 &mut scheduled,
225 &visited_before_crawl,
226 candidate.clone(),
227 depth,
228 policy,
229 ) {
230 discovery.register_endpoint(&candidate);
231 }
232 continue;
233 };
234 bounded_insert(&mut discovery.forms, form.clone(), MAX_DISCOVERED_FORMS);
235 match form.method {
236 FormMethod::Get => {
237 if schedule_page(
238 &mut queue,
239 &mut scheduled,
240 &visited_before_crawl,
241 candidate,
242 depth,
243 policy,
244 ) {
245 discovery.register_get_form(&form);
246 }
247 },
248 FormMethod::Post | FormMethod::Dialog => {},
249 }
250 }
251 }
252
253 Ok(discovery)
254}
255
256fn form_method_rank(form: &Option<DiscoveredForm>) -> u8 {
257 match form.as_ref().map(|form| form.method) {
258 None => 0,
259 Some(FormMethod::Get) => 1,
260 Some(FormMethod::Post) => 2,
261 Some(FormMethod::Dialog) => 3,
262 }
263}
264
265fn canonical_same_origin(
266 ctx: &ScanContext,
267 root: &Url,
268 candidate: Url,
269) -> Result<Option<Url>, ScannerError> {
270 if !candidate.username().is_empty() || candidate.password().is_some() {
271 return Ok(None);
272 }
273 let Some(candidate) = resolve_same_origin(root, candidate.as_str(), root) else {
274 return Ok(None);
275 };
276 ctx.canonicalize_discovery_url(&candidate).map(Some)
277}
278
279fn schedule_page(
280 queue: &mut VecDeque<(Url, usize)>,
281 scheduled: &mut BTreeSet<String>,
282 visited_before_crawl: &BTreeSet<String>,
283 candidate: Url,
284 parent_depth: usize,
285 policy: CrawlPolicy,
286) -> bool {
287 if scheduled.contains(candidate.as_str()) {
288 return true;
289 }
290
291 let page_limit = policy.max_pages.min(MAX_DISCOVERED_ENDPOINTS);
292 if parent_depth >= policy.max_depth || scheduled.len() >= page_limit {
293 return false;
294 }
295
296 scheduled.insert(candidate.as_str().to_string());
297 if !visited_before_crawl.contains(candidate.as_str()) {
298 queue.push_back((candidate, parent_depth + 1));
299 }
300 true
301}
302
303fn supported_html_media_type(content_type: &str) -> bool {
304 let media_type = content_type.split(';').next().unwrap_or_default().trim();
305 media_type.eq_ignore_ascii_case("text/html")
306}
307
308fn canonicalize_url(mut url: Url) -> Url {
309 url.set_fragment(None);
310 if url.path().is_empty() {
311 url.set_path("/");
312 }
313
314 let pairs: Vec<(String, String)> = url
315 .query_pairs()
316 .map(|(name, value)| (name.into_owned(), value.into_owned()))
317 .collect();
318 if pairs.is_empty() {
319 url.set_query(None);
320 return url;
321 }
322
323 let mut names = HashSet::with_capacity(pairs.len());
327 if pairs.iter().all(|(name, _)| names.insert(name.clone())) {
328 let mut sorted = pairs;
329 sorted.sort();
330 url.query_pairs_mut().clear().extend_pairs(sorted);
331 }
332 url
333}
334
335fn resolve_same_origin(base: &Url, reference: &str, authorized_origin: &Url) -> Option<Url> {
336 let resolved = base.join(reference).ok()?;
337 if !matches!(resolved.scheme(), "http" | "https")
338 || !resolved.username().is_empty()
339 || resolved.password().is_some()
340 || !same_origin(authorized_origin, &resolved)
341 {
342 return None;
343 }
344 Some(canonicalize_url(resolved))
345}
346
347fn same_origin(target: &Url, candidate: &Url) -> bool {
348 target.origin() == candidate.origin()
349}
350
351fn query_parameter_names(url: &Url) -> BTreeSet<String> {
352 url.query_pairs()
353 .filter_map(|(name, _)| {
354 (!name.is_empty() && name.len() <= MAX_DISCOVERY_NAME_BYTES).then(|| name.into_owned())
355 })
356 .collect()
357}
358
359fn bounded_insert<T: Ord>(values: &mut BTreeSet<T>, value: T, limit: usize) {
360 if limit == 0 || !values.insert(value) {
361 return;
362 }
363 if values.len() > limit {
364 values.pop_last();
365 }
366}
367
368fn parse_html_document(document_url: &Url, html: &[u8]) -> ParsedDocument {
369 let dom = parse_document(RcDom::default(), ParseOpts::default())
370 .from_utf8()
371 .one(html);
372 let base_url = first_document_base(&dom.document, document_url);
373 let mut links = BTreeSet::new();
374 let mut forms = BTreeSet::new();
375 let mut pending = vec![dom.document.clone()];
376
377 while let Some(handle) = pending.pop() {
378 if let NodeData::Element { name, attrs, .. } = &handle.data {
379 if name.ns == ns!(html) && name.local.as_ref() == "a" {
380 if let Some(reference) = html_attribute(attrs, "href") {
381 if let Ok(url) = base_url.join(&reference) {
382 bounded_insert(&mut links, canonicalize_url(url), MAX_REFERENCES_PER_PAGE);
383 }
384 }
385 } else if name.ns == ns!(html) && name.local.as_ref() == "form" {
386 if let Some(form) = parse_form(&handle, document_url, &base_url) {
387 bounded_insert(&mut forms, form, MAX_FORMS_PER_PAGE);
388 }
389 }
390 }
391 pending.extend(handle.children.borrow().iter().rev().cloned());
392 }
393
394 ParsedDocument {
395 links: links.into_iter().collect(),
396 forms: forms.into_iter().collect(),
397 }
398}
399
400fn first_document_base(root: &Handle, document_url: &Url) -> Url {
401 let mut pending = vec![root.clone()];
402 while let Some(handle) = pending.pop() {
403 if let NodeData::Element { name, attrs, .. } = &handle.data {
404 if name.ns == ns!(html) && name.local.as_ref() == "base" {
405 if let Some(reference) = html_attribute(attrs, "href") {
406 return document_url
410 .join(&reference)
411 .ok()
412 .filter(|url| matches!(url.scheme(), "http" | "https"))
413 .unwrap_or_else(|| document_url.clone());
414 }
415 }
416 }
417 pending.extend(handle.children.borrow().iter().rev().cloned());
418 }
419 document_url.clone()
420}
421
422fn parse_form(handle: &Handle, document_url: &Url, base_url: &Url) -> Option<DiscoveredForm> {
423 let (action, method) = match &handle.data {
424 NodeData::Element { attrs, .. } => {
425 let action = match html_attribute(attrs, "action") {
426 None => document_url.clone(),
427 Some(value) if value.trim().is_empty() => document_url.clone(),
428 Some(value) => base_url
429 .join(value.trim())
430 .ok()
431 .filter(|url| matches!(url.scheme(), "http" | "https"))?,
432 };
433 let method = match html_attribute(attrs, "method") {
434 Some(value) if value.eq_ignore_ascii_case("post") => FormMethod::Post,
435 Some(value) if value.eq_ignore_ascii_case("dialog") => FormMethod::Dialog,
436 _ => FormMethod::Get,
437 };
438 (action, method)
439 },
440 _ => return None,
441 };
442
443 let mut controls = BTreeSet::new();
444 let mut pending: Vec<Handle> = handle.children.borrow().iter().rev().cloned().collect();
448 while let Some(child) = pending.pop() {
449 if let NodeData::Element { name, attrs, .. } = &child.data {
450 if name.ns == ns!(html)
451 && matches!(
452 name.local.as_ref(),
453 "input" | "select" | "textarea" | "button"
454 )
455 && html_attribute(attrs, "form").is_none()
456 {
457 if let Some(control_name) = html_attribute(attrs, "name") {
458 if !control_name.is_empty() && control_name.len() <= MAX_DISCOVERY_NAME_BYTES {
459 bounded_insert(&mut controls, control_name, MAX_CONTROLS_PER_FORM);
460 }
461 }
462 }
463 }
464 pending.extend(child.children.borrow().iter().rev().cloned());
465 }
466
467 Some(DiscoveredForm {
468 action: canonicalize_url(action),
469 method,
470 controls: controls.into_iter().collect(),
471 })
472}
473
474fn html_attribute(
475 attrs: &std::cell::RefCell<Vec<html5ever::Attribute>>,
476 local_name: &str,
477) -> Option<String> {
478 attrs
479 .borrow()
480 .iter()
481 .find(|attr| attr.name.ns == ns!() && attr.name.local.as_ref() == local_name)
482 .map(|attr| attr.value.to_string())
483}
484
485#[cfg(test)]
486mod tests {
487 use std::{
488 net::SocketAddr,
489 sync::{Arc, Mutex},
490 time::Duration,
491 };
492
493 use tokio::{
494 io::{AsyncReadExt, AsyncWriteExt},
495 net::TcpListener,
496 task::JoinHandle,
497 };
498
499 use super::*;
500 use crate::legacy_discovery::DiscoveryLimits;
501
502 struct FixtureResponse {
503 status: &'static str,
504 headers: Vec<(String, String)>,
505 body: Vec<u8>,
506 }
507
508 impl FixtureResponse {
509 fn html(body: impl Into<String>) -> Self {
510 Self::media("text/html", body)
511 }
512
513 fn media(content_type: &str, body: impl Into<String>) -> Self {
514 Self {
515 status: "200 OK",
516 headers: vec![("Content-Type".to_owned(), content_type.to_owned())],
517 body: body.into().into_bytes(),
518 }
519 }
520
521 fn redirect(location: impl Into<String>) -> Self {
522 Self {
523 status: "302 Found",
524 headers: vec![
525 ("Content-Type".to_owned(), "text/html".to_owned()),
526 ("Location".to_owned(), location.into()),
527 ],
528 body: Vec::new(),
529 }
530 }
531 }
532
533 struct LocalFixture {
534 target: Url,
535 requests: Arc<Mutex<Vec<String>>>,
536 task: JoinHandle<()>,
537 }
538
539 impl LocalFixture {
540 fn request_paths(&self) -> Vec<String> {
541 self.requests
542 .lock()
543 .unwrap_or_else(std::sync::PoisonError::into_inner)
544 .clone()
545 }
546 }
547
548 impl Drop for LocalFixture {
549 fn drop(&mut self) {
550 self.task.abort();
551 }
552 }
553
554 async fn serve_fixture(
555 max_requests: usize,
556 handler: impl Fn(&str, SocketAddr) -> FixtureResponse + Send + Sync + 'static,
557 ) -> LocalFixture {
558 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
559 let address = listener.local_addr().unwrap();
560 let requests = Arc::new(Mutex::new(Vec::new()));
561 let observed_requests = Arc::clone(&requests);
562 let handler = Arc::new(handler);
563 let task = tokio::spawn(async move {
564 for _ in 0..max_requests {
565 let (mut stream, _) = listener.accept().await.unwrap();
566 let mut request = Vec::new();
567 loop {
568 let mut chunk = [0_u8; 1_024];
569 let read = stream.read(&mut chunk).await.unwrap();
570 if read == 0 {
571 break;
572 }
573 request.extend_from_slice(&chunk[..read]);
574 if request.windows(4).any(|window| window == b"\r\n\r\n") {
575 break;
576 }
577 assert!(request.len() <= 16 * 1_024, "fixture request too large");
578 }
579 let target = String::from_utf8_lossy(&request)
580 .lines()
581 .next()
582 .and_then(|line| line.split_ascii_whitespace().nth(1))
583 .unwrap()
584 .to_owned();
585 observed_requests
586 .lock()
587 .unwrap_or_else(std::sync::PoisonError::into_inner)
588 .push(target.clone());
589 let response = handler(&target, address);
590 let mut head = format!(
591 "HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n",
592 response.status,
593 response.body.len()
594 );
595 for (name, value) in response.headers {
596 head.push_str(&format!("{name}: {value}\r\n"));
597 }
598 head.push_str("\r\n");
599 if stream.write_all(head.as_bytes()).await.is_ok() {
600 let _ = stream.write_all(&response.body).await;
601 }
602 let _ = stream.shutdown().await;
603 }
604 });
605
606 LocalFixture {
607 target: Url::parse(&format!("http://{address}/")).unwrap(),
608 requests,
609 task,
610 }
611 }
612
613 fn discovery_limits(
614 max_depth: usize,
615 max_pages: usize,
616 max_requests: u32,
617 max_response_body_bytes: usize,
618 ) -> DiscoveryLimits {
619 let limits = DiscoveryLimits::new()
620 .with_max_depth(max_depth)
621 .with_max_requests(max_requests);
622 let limits = limits.with_max_pages(max_pages).unwrap();
623 let limits = limits.with_request_timeout(Duration::from_secs(2)).unwrap();
624 let limits = limits.with_max_wall_time(Duration::from_secs(10)).unwrap();
625 limits
626 .with_body_limits(1024 * 1024, max_response_body_bytes)
627 .unwrap()
628 }
629
630 fn scan_context(target: Url, limits: DiscoveryLimits) -> ScanContext {
631 let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
632 ScanContext::new_with_discovery_limits(target, reqwest::Client::new(), telemetry, limits)
633 }
634
635 #[test]
636 fn phase_identity_is_stable() {
637 let crawl = CrawlPhase;
638 assert_eq!(crawl.phase_number(), 2);
639 assert_eq!(crawl.name(), "Web Crawler & Parameter Discovery");
640 }
641
642 #[test]
643 fn crawler_scope_requires_the_exact_normalized_origin() {
644 let target = Url::parse("https://example.test/app").unwrap();
645
646 assert!(same_origin(
647 &target,
648 &Url::parse("https://example.test/next").unwrap()
649 ));
650 assert!(same_origin(
651 &target,
652 &Url::parse("https://example.test:443/explicit-default").unwrap()
653 ));
654 assert!(!same_origin(
655 &target,
656 &Url::parse("http://example.test/downgrade").unwrap()
657 ));
658 assert!(!same_origin(
659 &target,
660 &Url::parse("https://example.test:8443/other-service").unwrap()
661 ));
662 assert!(!same_origin(
663 &target,
664 &Url::parse("https://other.test/outside").unwrap()
665 ));
666 }
667
668 #[test]
669 fn canonicalization_drops_fragments_default_ports_and_sorts_unique_queries() {
670 let first = canonicalize_url(
671 Url::parse("https://EXAMPLE.test:443/a/../page?z=last&a=first#section").unwrap(),
672 );
673 let second =
674 canonicalize_url(Url::parse("https://example.test/page?a=first&z=last#other").unwrap());
675 assert_eq!(first, second);
676 assert_eq!(first.as_str(), "https://example.test/page?a=first&z=last");
677 }
678
679 #[test]
680 fn canonicalization_preserves_repeated_query_name_order() {
681 let url = canonicalize_url(
682 Url::parse("https://example.test/?choice=first&choice=second&a=1").unwrap(),
683 );
684 assert_eq!(
685 url.query(),
686 Some("choice=first&choice=second&a=1"),
687 "repeated-value application semantics must not be reordered"
688 );
689 }
690
691 #[test]
692 fn resolution_handles_relative_root_and_absolute_references() {
693 let origin = Url::parse("https://example.test/app/index.html").unwrap();
694 assert_eq!(
695 resolve_same_origin(&origin, "next", &origin)
696 .unwrap()
697 .as_str(),
698 "https://example.test/app/next"
699 );
700 assert_eq!(
701 resolve_same_origin(&origin, "/root", &origin)
702 .unwrap()
703 .as_str(),
704 "https://example.test/root"
705 );
706 assert_eq!(
707 resolve_same_origin(
708 &origin,
709 "https://example.test/absolute?b=2&a=1#fragment",
710 &origin,
711 )
712 .unwrap()
713 .as_str(),
714 "https://example.test/absolute?a=1&b=2"
715 );
716 assert!(resolve_same_origin(&origin, "https://outside.test/", &origin).is_none());
717 assert!(resolve_same_origin(&origin, "javascript:alert(1)", &origin).is_none());
718 assert!(
719 resolve_same_origin(&origin, "https://user:secret@example.test/private", &origin)
720 .is_none()
721 );
722 }
723
724 #[test]
725 fn supported_media_type_is_explicit_and_parameter_tolerant() {
726 assert!(supported_html_media_type("text/html; charset=utf-8"));
727 assert!(supported_html_media_type("TEXT/HTML"));
728 assert!(!supported_html_media_type("application/xhtml+xml"));
729 assert!(!supported_html_media_type("text/plain"));
730 assert!(!supported_html_media_type("application/json"));
731 assert!(!supported_html_media_type(""));
732 }
733
734 #[test]
735 fn standards_parser_extracts_links_and_typed_forms() {
736 let document_url = Url::parse("https://example.test/app/page?existing=1").unwrap();
737 let document = br#"
738 <!doctype html>
739 <base href="/base/">
740 <a href="relative?z=2&a=1#part">relative</a>
741 <a href="/root">root</a>
742 <script>"<a href='/not-a-link'>"</script>
743 <form action="submit?kept=yes" method="GET">
744 <input name="query"><select name="kind"></select>
745 <textarea name="notes"></textarea><button name="commit"></button>
746 </form>
747 <form action="/write" method="PoSt">
748 <input name="csrf"><button name="save"></button>
749 </form>
750 <form method="dialog"><input name="choice"></form>
751 "#;
752 let parsed = parse_html_document(&document_url, document);
753
754 assert_eq!(
755 parsed.links,
756 vec![
757 Url::parse("https://example.test/base/relative?a=1&z=2").unwrap(),
758 Url::parse("https://example.test/root").unwrap(),
759 ]
760 );
761 assert_eq!(parsed.forms.len(), 3);
762 let get = parsed
763 .forms
764 .iter()
765 .find(|form| form.method == FormMethod::Get)
766 .unwrap();
767 let post = parsed
768 .forms
769 .iter()
770 .find(|form| form.method == FormMethod::Post)
771 .unwrap();
772 let dialog = parsed
773 .forms
774 .iter()
775 .find(|form| form.method == FormMethod::Dialog)
776 .unwrap();
777 assert_eq!(
778 get.action.as_str(),
779 "https://example.test/base/submit?kept=yes"
780 );
781 assert_eq!(get.controls, vec!["commit", "kind", "notes", "query"]);
782 assert_eq!(post.action.as_str(), "https://example.test/write");
783 assert_eq!(post.controls, vec!["csrf", "save"]);
784 assert_eq!(dialog.action, document_url);
785 }
786
787 #[test]
788 fn missing_or_empty_form_action_targets_the_document_not_base_element() {
789 let document_url = Url::parse("https://example.test/current?kept=1").unwrap();
790 let parsed = parse_html_document(
791 &document_url,
792 br#"<base href="/elsewhere/"><form><input name="a"></form><form action=""><input name="b"></form>"#,
793 );
794 assert_eq!(parsed.forms.len(), 2);
795 assert!(parsed.forms.iter().all(|form| form.action == document_url));
796 }
797
798 #[test]
799 fn invalid_first_base_href_is_not_rescued_by_later_base() {
800 let document_url = Url::parse("https://example.test/document").unwrap();
801 let parsed = parse_html_document(
802 &document_url,
803 br#"<base href="http://[invalid"><base href="/first/"><base href="/second/"><a href="next">next</a>"#,
804 );
805 assert_eq!(
806 parsed.links,
807 vec![Url::parse("https://example.test/next").unwrap()]
808 );
809 }
810
811 #[test]
812 fn non_http_first_base_falls_back_and_is_not_rescued() {
813 let document_url = Url::parse("https://example.test/path/document").unwrap();
814 let parsed = parse_html_document(
815 &document_url,
816 br#"<base href="javascript:alert(1)"><base href="/rescue/"><a href="next">next</a>"#,
817 );
818 assert_eq!(
819 parsed.links,
820 vec![Url::parse("https://example.test/path/next").unwrap()]
821 );
822 }
823
824 #[test]
825 fn cross_origin_http_base_resolves_before_scope_filtering() {
826 let document_url = Url::parse("https://example.test/document").unwrap();
827 let parsed = parse_html_document(
828 &document_url,
829 br#"<base href="https://outside.test/base/"><a href="next">next</a>"#,
830 );
831 assert_eq!(
832 parsed.links,
833 vec![Url::parse("https://outside.test/base/next").unwrap()]
834 );
835 assert!(
836 resolve_same_origin(&document_url, parsed.links[0].as_str(), &document_url).is_none()
837 );
838 }
839
840 #[test]
841 fn first_parseable_base_wins_over_later_base() {
842 let document_url = Url::parse("https://example.test/document").unwrap();
843 let parsed = parse_html_document(
844 &document_url,
845 br#"<base href="/first/"><base href="/second/"><a href="next">next</a>"#,
846 );
847 assert_eq!(
848 parsed.links,
849 vec![Url::parse("https://example.test/first/next").unwrap()]
850 );
851 }
852
853 #[test]
854 fn invalid_method_uses_html_get_default() {
855 let document_url = Url::parse("https://example.test/document").unwrap();
856 let parsed = parse_html_document(
857 &document_url,
858 br#"<form method="delete" action="/resource"><input name="id"></form>"#,
859 );
860 assert_eq!(parsed.forms[0].method, FormMethod::Get);
861 }
862
863 #[test]
864 fn invalid_present_form_action_is_inert_instead_of_falling_back() {
865 let document_url = Url::parse("https://example.test/document").unwrap();
866 let parsed = parse_html_document(
867 &document_url,
868 br#"
869 <form action="http://[invalid"><input name="invalid"></form>
870 <form action="javascript:alert(1)"><input name="non-http"></form>
871 <form><input name="kept"></form>
872 "#,
873 );
874
875 assert_eq!(parsed.forms.len(), 1);
876 assert_eq!(parsed.forms[0].action, document_url);
877 assert_eq!(parsed.forms[0].controls, vec!["kept"]);
878 }
879
880 #[test]
881 fn explicit_nonempty_form_owner_is_not_attributed_to_descendant_form() {
882 let document_url = Url::parse("https://example.test/document").unwrap();
883 let parsed = parse_html_document(
884 &document_url,
885 br#"
886 <form id="local">
887 <input name="kept">
888 <input name="other" form="elsewhere">
889 <input name="empty-owner" form="">
890 <input name="whitespace-owner" form=" ">
891 </form>
892 "#,
893 );
894
895 assert_eq!(parsed.forms.len(), 1);
896 assert_eq!(parsed.forms[0].controls, vec!["kept"]);
897 }
898
899 #[test]
900 fn get_form_preserves_action_query_and_control_names() {
901 let mut discovery = CrawlDiscovery::default();
902 let form = DiscoveredForm {
903 action: canonicalize_url(Url::parse("https://example.test/search?scope=docs").unwrap()),
904 method: FormMethod::Get,
905 controls: vec!["q".to_string(), "scope".to_string()],
906 };
907 discovery.register_get_form(&form);
908 assert_eq!(
909 discovery.endpoints[form.action.as_str()],
910 BTreeSet::from(["q".to_string(), "scope".to_string()])
911 );
912 }
913
914 #[test]
915 fn post_form_remains_typed_and_is_not_registered_as_get_endpoint() {
916 let mut discovery = CrawlDiscovery::default();
917 let form = DiscoveredForm {
918 action: Url::parse("https://example.test/write").unwrap(),
919 method: FormMethod::Post,
920 controls: vec!["body".to_string()],
921 };
922 discovery.forms.insert(form.clone());
923 assert_eq!(discovery.forms, BTreeSet::from([form]));
924 assert!(discovery.endpoints.is_empty());
925 }
926
927 #[test]
928 fn root_endpoint_registration_includes_existing_query_names() {
929 let root = canonicalize_url(
930 Url::parse("https://example.test/start?mode=safe&token=redacted").unwrap(),
931 );
932 let mut discovery = CrawlDiscovery::default();
933 discovery.register_endpoint(&root);
934 assert_eq!(
935 discovery.endpoints[root.as_str()],
936 BTreeSet::from(["mode".to_string(), "token".to_string()])
937 );
938 }
939
940 #[test]
941 fn crawl_policy_is_explicit_and_finite() {
942 let policy = CrawlPolicy {
943 max_depth: 4,
944 max_pages: 64,
945 };
946 assert_eq!(policy.max_depth, 4);
947 assert_eq!(policy.max_pages, 64);
948 assert_eq!(MAX_CRAWLER_HTML_PARSE_BYTES, 64 * 1024);
949 assert!(policy.max_depth < policy.max_pages);
950 }
951
952 #[test]
953 fn queue_model_is_breadth_first_and_depth_bounded() {
954 let mut queue = VecDeque::from([
955 (Url::parse("https://example.test/root").unwrap(), 0_usize),
956 (Url::parse("https://example.test/second").unwrap(), 1_usize),
957 ]);
958 queue.push_back((Url::parse("https://example.test/third").unwrap(), 1_usize));
959 assert_eq!(queue.pop_front().unwrap().0.path(), "/root");
960 assert_eq!(queue.pop_front().unwrap().0.path(), "/second");
961 assert_eq!(queue.pop_front().unwrap().0.path(), "/third");
962 }
963
964 #[test]
965 fn canonical_sort_makes_budget_selection_independent_of_markup_order() {
966 fn selected(markup: &[u8]) -> Vec<String> {
967 let root = Url::parse("https://example.test/").unwrap();
968 let mut candidates: Vec<_> = parse_html_document(&root, markup)
969 .links
970 .into_iter()
971 .map(canonicalize_url)
972 .collect();
973 candidates.sort_by(|left, right| left.as_str().cmp(right.as_str()));
974 candidates.into_iter().take(2).map(Into::into).collect()
975 }
976
977 assert_eq!(
978 selected(br#"<a href="/c"></a><a href="/a"></a><a href="/b"></a>"#),
979 selected(br#"<a href="/b"></a><a href="/c"></a><a href="/a"></a>"#)
980 );
981 }
982
983 #[test]
984 fn link_cap_keeps_the_same_lexicographically_smallest_urls_in_any_dom_order() {
985 fn markup(indices: &[usize]) -> String {
986 indices
987 .iter()
988 .map(|index| format!(r#"<a href="/link-{index:04}"></a>"#))
989 .collect()
990 }
991
992 let document_url = Url::parse("https://example.test/").unwrap();
993 let forward = (0..MAX_REFERENCES_PER_PAGE + 17).collect::<Vec<_>>();
994 let reverse = forward.iter().copied().rev().collect::<Vec<_>>();
995 let first = parse_html_document(&document_url, markup(&forward).as_bytes()).links;
996 let second = parse_html_document(&document_url, markup(&reverse).as_bytes()).links;
997
998 assert_eq!(first, second);
999 assert_eq!(first.len(), MAX_REFERENCES_PER_PAGE);
1000 assert_eq!(first.first().unwrap().path(), "/link-0000");
1001 assert_eq!(
1002 first.last().unwrap().path(),
1003 format!("/link-{:04}", MAX_REFERENCES_PER_PAGE - 1)
1004 );
1005 }
1006
1007 #[test]
1008 fn form_cap_keeps_the_same_lexicographically_smallest_forms_in_any_dom_order() {
1009 fn markup(indices: &[usize]) -> String {
1010 indices
1011 .iter()
1012 .map(|index| format!(r#"<form action="/form-{index:04}"><input name="q"></form>"#))
1013 .collect()
1014 }
1015
1016 let document_url = Url::parse("https://example.test/").unwrap();
1017 let forward = (0..MAX_FORMS_PER_PAGE + 17).collect::<Vec<_>>();
1018 let reverse = forward.iter().copied().rev().collect::<Vec<_>>();
1019 let first = parse_html_document(&document_url, markup(&forward).as_bytes()).forms;
1020 let second = parse_html_document(&document_url, markup(&reverse).as_bytes()).forms;
1021
1022 assert_eq!(first, second);
1023 assert_eq!(first.len(), MAX_FORMS_PER_PAGE);
1024 assert_eq!(first.first().unwrap().action.path(), "/form-0000");
1025 assert_eq!(
1026 first.last().unwrap().action.path(),
1027 format!("/form-{:04}", MAX_FORMS_PER_PAGE - 1)
1028 );
1029 }
1030
1031 #[test]
1032 fn control_cap_keeps_the_same_lexicographically_smallest_names_in_any_dom_order() {
1033 fn markup(indices: &[usize]) -> String {
1034 let controls = indices
1035 .iter()
1036 .map(|index| format!(r#"<input name="control-{index:04}">"#))
1037 .collect::<String>();
1038 format!(r#"<form action="/submit">{controls}</form>"#)
1039 }
1040
1041 let document_url = Url::parse("https://example.test/").unwrap();
1042 let forward = (0..MAX_CONTROLS_PER_FORM + 17).collect::<Vec<_>>();
1043 let reverse = forward.iter().copied().rev().collect::<Vec<_>>();
1044 let first = parse_html_document(&document_url, markup(&forward).as_bytes()).forms;
1045 let second = parse_html_document(&document_url, markup(&reverse).as_bytes()).forms;
1046
1047 assert_eq!(first, second);
1048 assert_eq!(first[0].controls.len(), MAX_CONTROLS_PER_FORM);
1049 assert_eq!(first[0].controls.first().unwrap(), "control-0000");
1050 assert_eq!(
1051 first[0].controls.last().unwrap(),
1052 &format!("control-{:04}", MAX_CONTROLS_PER_FORM - 1)
1053 );
1054 }
1055
1056 #[tokio::test]
1057 async fn depth_zero_registers_only_the_root_but_retains_passive_forms() {
1058 let fixture = serve_fixture(1, |target, _address| {
1059 assert_eq!(target, "/");
1060 FixtureResponse::html(
1061 r#"
1062 <a href="/child">child</a>
1063 <form action="/search" method="get"><input name="q"></form>
1064 <form action="/write" method="post"><input name="body"></form>
1065 "#,
1066 )
1067 })
1068 .await;
1069 let context = scan_context(fixture.target.clone(), discovery_limits(0, 8, 8, 64 * 1024));
1070
1071 CrawlPhase.execute(&context).await.unwrap();
1072
1073 assert_eq!(fixture.request_paths(), vec!["/"]);
1074 let snapshot = context.discovery_snapshot();
1075 assert_eq!(snapshot.endpoints().len(), 1);
1076 assert!(snapshot.endpoints().contains_key(fixture.target.as_str()));
1077 assert!(snapshot
1078 .endpoints()
1079 .keys()
1080 .all(|url| !url.ends_with("/child") && !url.ends_with("/search")));
1081 assert_eq!(context.discovery_forms().len(), 2);
1082 }
1083
1084 #[tokio::test]
1085 async fn one_page_budget_registers_only_the_root() {
1086 let fixture = serve_fixture(1, |target, _address| {
1087 assert_eq!(target, "/");
1088 FixtureResponse::html(r#"<a href="/a">a</a><a href="/b">b</a>"#)
1089 })
1090 .await;
1091 let context = scan_context(fixture.target.clone(), discovery_limits(8, 1, 8, 64 * 1024));
1092
1093 CrawlPhase.execute(&context).await.unwrap();
1094
1095 assert_eq!(fixture.request_paths(), vec!["/"]);
1096 let snapshot = context.discovery_snapshot();
1097 assert_eq!(snapshot.endpoints().len(), 1);
1098 assert!(snapshot.endpoints().contains_key(fixture.target.as_str()));
1099 }
1100
1101 #[tokio::test]
1102 async fn page_budget_selects_canonical_candidates_before_registration() {
1103 let fixture = serve_fixture(2, |target, _address| match target {
1104 "/" => FixtureResponse::html(r#"<a href="/b">b</a><a href="/a">a</a>"#),
1105 "/a" => FixtureResponse::html(r#"<a href="/deep">deep</a>"#),
1106 other => panic!("unexpected crawler request: {other}"),
1107 })
1108 .await;
1109 let context = scan_context(fixture.target.clone(), discovery_limits(8, 2, 8, 64 * 1024));
1110
1111 CrawlPhase.execute(&context).await.unwrap();
1112
1113 assert_eq!(fixture.request_paths(), vec!["/", "/a"]);
1114 let endpoints = context.discovery_snapshot().endpoints().clone();
1115 assert_eq!(endpoints.len(), 2);
1116 assert!(endpoints.keys().any(|url| url.ends_with("/a")));
1117 assert!(endpoints
1118 .keys()
1119 .all(|url| !url.ends_with("/b") && !url.ends_with("/deep")));
1120 }
1121
1122 #[tokio::test]
1123 async fn depth_budget_excludes_descendants_beyond_the_selected_frontier() {
1124 let fixture = serve_fixture(2, |target, _address| match target {
1125 "/" => FixtureResponse::html(r#"<a href="/child">child</a>"#),
1126 "/child" => FixtureResponse::html(r#"<a href="/deep">deep</a>"#),
1127 other => panic!("unexpected crawler request: {other}"),
1128 })
1129 .await;
1130 let context = scan_context(fixture.target.clone(), discovery_limits(1, 8, 8, 64 * 1024));
1131
1132 CrawlPhase.execute(&context).await.unwrap();
1133
1134 assert_eq!(fixture.request_paths(), vec!["/", "/child"]);
1135 let endpoints = context.discovery_snapshot().endpoints().clone();
1136 assert_eq!(endpoints.len(), 2);
1137 assert!(endpoints.keys().any(|url| url.ends_with("/child")));
1138 assert!(endpoints.keys().all(|url| !url.ends_with("/deep")));
1139 }
1140
1141 #[tokio::test]
1142 async fn previsited_root_suppresses_the_root_request() {
1143 let fixture = serve_fixture(1, |_target, _address| {
1144 panic!("previsited root must not be requested")
1145 })
1146 .await;
1147 let context = scan_context(fixture.target.clone(), discovery_limits(1, 8, 8, 64 * 1024));
1148 context.mark_visited(fixture.target.to_string());
1149
1150 CrawlPhase.execute(&context).await.unwrap();
1151
1152 assert!(fixture.request_paths().is_empty());
1153 assert!(context
1154 .discovery_snapshot()
1155 .endpoints()
1156 .contains_key(fixture.target.as_str()));
1157 }
1158
1159 #[tokio::test]
1160 async fn previsited_child_is_registered_but_not_requested_again() {
1161 let fixture = serve_fixture(1, |target, _address| {
1162 assert_eq!(target, "/");
1163 FixtureResponse::html(r#"<a href="/child">child</a>"#)
1164 })
1165 .await;
1166 let child = fixture.target.join("/child").unwrap();
1167 let context = scan_context(fixture.target.clone(), discovery_limits(1, 8, 8, 64 * 1024));
1168 context.mark_visited(child.to_string());
1169
1170 CrawlPhase.execute(&context).await.unwrap();
1171
1172 assert_eq!(fixture.request_paths(), vec!["/"]);
1173 let snapshot = context.discovery_snapshot();
1174 assert!(snapshot.endpoints().contains_key(child.as_str()));
1175 assert!(snapshot.visited().contains(child.as_str()));
1176 assert!(snapshot.visited().contains(fixture.target.as_str()));
1177 }
1178
1179 #[tokio::test]
1180 async fn broker_crawl_is_same_origin_and_retains_typed_forms_without_post_probe() {
1181 let outside = serve_fixture(1, |_target, _address| {
1182 FixtureResponse::html("outside must not be requested")
1183 })
1184 .await;
1185 let outside_url = outside.target.to_string();
1186 let fixture = serve_fixture(6, move |target, address| match target {
1187 "/" => FixtureResponse::html(format!(
1188 r#"
1189 <a href="relative">relative</a>
1190 <a href="/root">root</a>
1191 <a href="http://{address}/absolute">absolute</a>
1192 <a href="{outside_url}outside">outside</a>
1193 <a href="http://user:secret@{address}/private">credentials</a>
1194 <a href="/redirect">redirect</a>
1195 <form action="/search?existing=1" method="get">
1196 <input name="q"><select name="kind"></select>
1197 </form>
1198 <form action="/write" method="post">
1199 <textarea name="body"></textarea><button name="save"></button>
1200 </form>
1201 "#
1202 )),
1203 "/redirect" => FixtureResponse::redirect(outside_url.clone()),
1204 "/absolute" | "/relative" | "/root" | "/search?existing=1" => FixtureResponse::html(""),
1205 other => panic!("unexpected crawler request: {other}"),
1206 })
1207 .await;
1208 let context = scan_context(fixture.target.clone(), discovery_limits(1, 8, 8, 64 * 1024));
1209
1210 CrawlPhase.execute(&context).await.unwrap();
1211
1212 assert_eq!(
1213 fixture.request_paths(),
1214 vec![
1215 "/",
1216 "/absolute",
1217 "/redirect",
1218 "/relative",
1219 "/root",
1220 "/search?existing=1",
1221 ]
1222 );
1223 assert!(outside.request_paths().is_empty());
1224 let snapshot = context.discovery_snapshot();
1225 assert_eq!(snapshot.endpoints().len(), 6);
1226 assert!(snapshot
1227 .endpoints()
1228 .keys()
1229 .all(|url| !url.contains("outside") && !url.contains("@") && !url.ends_with("/write")));
1230 let search = snapshot
1231 .endpoints()
1232 .iter()
1233 .find(|(url, _)| url.ends_with("/search?existing=1"))
1234 .unwrap()
1235 .1;
1236 assert_eq!(
1237 search,
1238 &BTreeSet::from(["existing".to_owned(), "kind".to_owned(), "q".to_owned()])
1239 );
1240 let forms = context.discovery_forms();
1241 assert_eq!(forms.len(), 2);
1242 let post = forms
1243 .iter()
1244 .find(|form| form.method() == DiscoveryFormMethod::Post)
1245 .unwrap();
1246 assert!(post.action().as_str().ends_with("/write"));
1247 assert_eq!(
1248 post.controls(),
1249 &BTreeSet::from(["body".to_owned(), "save".to_owned()])
1250 );
1251 }
1252
1253 #[tokio::test]
1254 async fn broker_crawl_requests_each_canonical_url_exactly_once() {
1255 let fixture = serve_fixture(2, |target, address| match target {
1256 "/" => FixtureResponse::html(format!(
1257 r#"
1258 <a href="/page?b=2&a=1#one">first</a>
1259 <a href="/page?a=1&b=2#two">second</a>
1260 <a href="http://{address}/page?a=1&b=2">third</a>
1261 "#
1262 )),
1263 "/page?a=1&b=2" => FixtureResponse::html(""),
1264 other => panic!("unexpected crawler request: {other}"),
1265 })
1266 .await;
1267 let context = scan_context(fixture.target.clone(), discovery_limits(1, 8, 8, 64 * 1024));
1268
1269 CrawlPhase.execute(&context).await.unwrap();
1270
1271 assert_eq!(fixture.request_paths(), vec!["/", "/page?a=1&b=2"]);
1272 assert_eq!(context.discovery_snapshot().endpoints().len(), 2);
1273 }
1274
1275 #[tokio::test]
1276 async fn truncated_html_is_not_partially_parsed() {
1277 let fixture = serve_fixture(1, |target, _address| {
1278 assert_eq!(target, "/");
1279 FixtureResponse::html(format!(
1280 "<a href=\"/must-not-be-observed\">link</a>{}",
1281 "x".repeat(8 * 1024)
1282 ))
1283 })
1284 .await;
1285 let context = scan_context(fixture.target.clone(), discovery_limits(1, 8, 8, 64));
1286
1287 CrawlPhase.execute(&context).await.unwrap();
1288
1289 let snapshot = context.discovery_snapshot();
1290 assert_eq!(fixture.request_paths(), vec!["/"]);
1291 assert_eq!(snapshot.endpoints().len(), 1, "partial HTML must be inert");
1292 assert!(snapshot
1293 .endpoints()
1294 .keys()
1295 .all(|url| !url.contains("must-not-be-observed")));
1296 }
1297
1298 #[tokio::test]
1299 async fn complete_body_over_hard_html_parse_cap_is_inert() {
1300 let fixture = serve_fixture(1, |target, _address| {
1301 assert_eq!(target, "/");
1302 let prefix = r#"<a href="/must-not-be-observed">link</a>"#;
1303 FixtureResponse::html(format!(
1304 "{prefix}{}",
1305 "x".repeat(MAX_CRAWLER_HTML_PARSE_BYTES + 1 - prefix.len())
1306 ))
1307 })
1308 .await;
1309 let context = scan_context(
1310 fixture.target.clone(),
1311 discovery_limits(1, 8, 8, MAX_CRAWLER_HTML_PARSE_BYTES * 2),
1312 );
1313
1314 CrawlPhase.execute(&context).await.unwrap();
1315
1316 let snapshot = context.discovery_snapshot();
1317 assert_eq!(fixture.request_paths(), vec!["/"]);
1318 assert_eq!(snapshot.endpoints().len(), 1);
1319 assert!(snapshot
1320 .endpoints()
1321 .keys()
1322 .all(|url| !url.contains("must-not-be-observed")));
1323 }
1324
1325 #[tokio::test]
1326 async fn exact_hard_html_parse_cap_remains_eligible() {
1327 let fixture = serve_fixture(2, |target, _address| match target {
1328 "/" => {
1329 let prefix = r#"<a href="/at-boundary">link</a>"#;
1330 FixtureResponse::html(format!(
1331 "{prefix}{}",
1332 "x".repeat(MAX_CRAWLER_HTML_PARSE_BYTES - prefix.len())
1333 ))
1334 },
1335 "/at-boundary" => FixtureResponse::html(""),
1336 other => panic!("unexpected crawler request: {other}"),
1337 })
1338 .await;
1339 let context = scan_context(
1340 fixture.target.clone(),
1341 discovery_limits(1, 8, 8, MAX_CRAWLER_HTML_PARSE_BYTES * 2),
1342 );
1343
1344 CrawlPhase.execute(&context).await.unwrap();
1345
1346 assert_eq!(fixture.request_paths(), vec!["/", "/at-boundary"]);
1347 assert!(context
1348 .discovery_snapshot()
1349 .endpoints()
1350 .contains_key(fixture.target.join("/at-boundary").unwrap().as_str()));
1351 }
1352
1353 #[tokio::test]
1354 async fn mid_crawl_budget_failure_commits_no_partial_state() {
1355 let fixture = serve_fixture(1, |target, _address| {
1356 assert_eq!(target, "/");
1357 FixtureResponse::html(r#"<a href="/second">second</a>"#)
1358 })
1359 .await;
1360 let context = scan_context(fixture.target.clone(), discovery_limits(1, 8, 1, 64 * 1024));
1361 let before = context.discovery_snapshot();
1362
1363 let error = CrawlPhase.execute(&context).await.unwrap_err();
1364
1365 assert!(matches!(error, ScannerError::BudgetExceeded(_)));
1366 assert_eq!(fixture.request_paths(), vec!["/"]);
1367 assert_eq!(context.discovery_snapshot(), before);
1368 assert_eq!(context.discovered_endpoints.len(), 1);
1369 assert!(context
1370 .discovered_endpoints
1371 .contains_key(fixture.target.as_str()));
1372 assert!(context.visited_urls.is_empty());
1373 assert!(context.discovery_forms().is_empty());
1374 }
1375}