Skip to main content

termivar_scanner/phases/
phase2_crawl.rs

1//! # Phase 2: bounded same-origin discovery
2//!
3//! This legacy phase discovers a finite set of HTTP endpoints and form-control
4//! names. A host-owned discovery request broker supplies the transport bounds;
5//! this module supplies deterministic breadth-first traversal, URL
6//! canonicalization, standards-aware HTML parsing, and atomic state staging.
7
8use 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/// Web crawling phase for bounded endpoint and parameter discovery.
32#[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        // Redirects are observations, not crawler navigation authority. The
181        // broker never follows them and the crawler never consumes Location.
182        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    // Reordering repeated names can change first/last-value application
324    // semantics. Only canonicalize ordering when every parameter name is
325    // unique; otherwise preserve the encoded request order exactly.
326    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                    // HTML freezes URL resolution at the first base element
407                    // with an `href`. A malformed first value falls back to the
408                    // document URL; a later base must not rescue it.
409                    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    // This deliberately describes descendant controls in the standards-built
445    // DOM tree. Any specified `form=...` invokes explicit owner association
446    // that this local descendant model cannot resolve, so it remains inert.
447    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}