Skip to main content

termivar_scanner/phases/
phase3_fuzzer.rs

1//! # Phase 3: controlled directory discovery
2//!
3//! The opt-in legacy directory phase compares each candidate with randomized
4//! nonexistent-path controls through the run-scoped discovery broker. It
5//! records endpoint observations only; no status code is a vulnerability claim.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use async_trait::async_trait;
10use sha2::{Digest, Sha256};
11use url::Url;
12use uuid::Uuid;
13
14use crate::{
15    context::ScanContext,
16    contracts::{ScanFinding, ScanPhase},
17    error::ScannerError,
18    http_evidence::HttpProbeMethod,
19    legacy_discovery::{BoundedHttpResponse, DiscoveryDelta},
20};
21
22const DIRECTORY_ACTION_ID: &str = "legacy.discovery.directory";
23const MAX_DIRECTORY_CANDIDATES: usize = 256;
24const MAX_DIRECTORY_CANDIDATE_BYTES: usize = 8_192;
25const MAX_DIRECTORY_CONTROL_SHAPES: usize = 16;
26const MAX_DIRECTORY_SHAPE_DEPTH: usize = 16;
27const MAX_DIRECTORY_EXTENSION_BYTES: usize = 32;
28/// Directory discovery with a bounded candidate wordlist.
29#[derive(Debug)]
30pub struct DirectoryFuzzer {
31    wordlist: Vec<String>,
32    candidate_input_within_limits: bool,
33}
34
35impl DirectoryFuzzer {
36    /// Creates a directory discoverer while retaining the pre-1.0 constructor
37    /// shape used by legacy hosts.
38    ///
39    /// The compatibility concurrency argument no longer changes execution:
40    /// bounded discovery deliberately dispatches sequentially so request
41    /// selection and budget exhaustion remain deterministic.
42    pub fn new(wordlist: Vec<String>, _concurrency_limit: usize) -> Self {
43        Self::sequential(wordlist)
44    }
45
46    /// Creates the deterministic sequential form without a compatibility
47    /// concurrency argument.
48    pub fn sequential(wordlist: Vec<String>) -> Self {
49        let candidate_input_within_limits = wordlist.len() <= MAX_DIRECTORY_CANDIDATES
50            && wordlist
51                .iter()
52                .all(|candidate| candidate.len() <= MAX_DIRECTORY_CANDIDATE_BYTES);
53        Self {
54            wordlist,
55            candidate_input_within_limits,
56        }
57    }
58
59    /// Uses a conservative list of common application endpoints while
60    /// retaining the legacy constructor shape.
61    pub fn with_default_wordlist(_concurrency_limit: usize) -> Self {
62        Self::with_default_wordlist_sequential()
63    }
64
65    /// Uses the conservative endpoint set with deterministic sequential
66    /// dispatch.
67    pub fn with_default_wordlist_sequential() -> Self {
68        Self::sequential(
69            [
70                "/admin",
71                "/api",
72                "/api/v1",
73                "/docs",
74                "/swagger",
75                "/swagger.json",
76                "/graphql",
77                "/health",
78                "/status",
79                "/uploads",
80                "/files",
81                "/backup",
82                "/debug",
83            ]
84            .into_iter()
85            .map(str::to_owned)
86            .collect(),
87        )
88    }
89
90    fn candidates(&self, context: &ScanContext) -> Result<Vec<Url>, ScannerError> {
91        if !self.candidate_input_within_limits {
92            return Err(ScannerError::DiscoveryStateLimitExceeded);
93        }
94        let mut candidates = BTreeSet::new();
95        for word in &self.wordlist {
96            let joined = context.authorized_target().join(word)?;
97            candidates.insert(context.canonicalize_discovery_url(&joined)?.to_string());
98        }
99        candidates
100            .into_iter()
101            .map(|value| Url::parse(&value).map_err(ScannerError::from))
102            .collect()
103    }
104}
105
106#[async_trait]
107impl ScanPhase for DirectoryFuzzer {
108    fn phase_number(&self) -> u8 {
109        3
110    }
111
112    fn name(&self) -> &'static str {
113        // Retained as the stable phase/action identity for existing reports.
114        "Directory & Endpoint Fuzzer"
115    }
116
117    async fn execute(&self, context: &ScanContext) -> Result<Vec<ScanFinding>, ScannerError> {
118        context.log("Phase 3: controlled directory discovery initiated.".to_owned());
119        let candidates = self.candidates(context)?;
120        if candidates.is_empty() {
121            context.commit_discovery(DIRECTORY_ACTION_ID, DiscoveryDelta::new())?;
122            context.log(
123                "Phase 3: controlled directory discovery completed with 0 endpoint observations."
124                    .to_owned(),
125            );
126            return Ok(Vec::new());
127        }
128        let candidate_groups = group_candidates_by_shape(candidates)?;
129        if candidate_groups.is_empty() {
130            context.commit_discovery(DIRECTORY_ACTION_ID, DiscoveryDelta::new())?;
131            context.log(
132                "Phase 3: no conservatively calibratable endpoint candidates remained.".to_owned(),
133            );
134            return Ok(Vec::new());
135        }
136
137        let discovery_before = context.discovery_snapshot();
138        let mut calibrations = BTreeMap::new();
139        for shape in candidate_groups.keys() {
140            let calibration = ControlCalibration::collect(context, shape).await?;
141            if !calibration.is_stable() {
142                context.commit_discovery(DIRECTORY_ACTION_ID, DiscoveryDelta::new())?;
143                context.log(
144                    "Phase 3: nonexistent-path controls were unusable or unstable; candidate dispatch was skipped."
145                        .to_owned(),
146                );
147                return Ok(Vec::new());
148            }
149            calibrations.insert(shape.clone(), calibration);
150        }
151
152        let mut delta = DiscoveryDelta::new();
153        let mut findings = Vec::new();
154        for (shape, candidates) in candidate_groups {
155            for candidate in candidates {
156                let response = context
157                    .request(DIRECTORY_ACTION_ID, HttpProbeMethod::Get, candidate.clone())
158                    .await?;
159                let calibration = calibrations
160                    .get(&shape)
161                    .ok_or(ScannerError::DiscoveryStateLimitExceeded)?;
162                let (control, signature) = calibration.compare(&candidate, &response);
163                if candidate_is_observable(&control, &signature) {
164                    let existing_parameters = discovery_before
165                        .endpoints()
166                        .get(candidate.as_str())
167                        .cloned()
168                        .unwrap_or_default();
169                    delta.record_endpoint(candidate.clone(), existing_parameters);
170                    findings.push(ScanFinding {
171                        phase: self.phase_number(),
172                        module_name: self.name().to_owned(),
173                        severity: "INFO".to_owned(),
174                        description:
175                            "A path produced a response distinct from calibrated nonexistent-path controls."
176                                .to_owned(),
177                        evidence: format!(
178                            "Endpoint observation: {} (HTTP {})",
179                            endpoint_subject(&candidate),
180                            response.status()
181                        ),
182                    });
183                }
184            }
185        }
186
187        // A failed probe returns before this single commit, so a partial batch
188        // can never escape into later phases.
189        context.commit_discovery(DIRECTORY_ACTION_ID, delta)?;
190        context.log(format!(
191            "Phase 3: controlled directory discovery completed with {} endpoint observations.",
192            findings.len()
193        ));
194        Ok(findings)
195    }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
199struct DirectoryShape {
200    parent_segments: Vec<String>,
201    trailing_slash: bool,
202    extension: Option<String>,
203}
204
205impl DirectoryShape {
206    fn from_candidate(candidate: &Url) -> Result<Option<Self>, ScannerError> {
207        if candidate.query().is_some() {
208            // Replaying supplied query values on randomized control paths would
209            // expand the host's authority and could duplicate sensitive data.
210            return Ok(None);
211        }
212        if candidate.path().contains('%') || candidate.path().contains("//") {
213            // Percent-encoded and empty-segment paths can change shape after
214            // intermediary decoding or normalization. Do not guess at their
215            // equivalence class.
216            return Ok(None);
217        }
218        let segments = candidate
219            .path()
220            .split('/')
221            .filter(|segment| !segment.is_empty())
222            .collect::<Vec<_>>();
223        if segments.is_empty() {
224            return Ok(None);
225        }
226        if segments
227            .iter()
228            .take(segments.len().saturating_sub(1))
229            .any(|segment| !is_safe_path_component(segment))
230        {
231            return Ok(None);
232        }
233        if segments.len() > MAX_DIRECTORY_SHAPE_DEPTH {
234            return Err(ScannerError::DiscoveryStateLimitExceeded);
235        }
236        let Some(last_segment) = segments.last() else {
237            return Ok(None);
238        };
239        let extension = match last_segment.split_once('.') {
240            Some((stem, extension))
241                if !extension.contains('.')
242                    && is_safe_path_component(stem)
243                    && is_safe_path_component(extension)
244                    && extension.len() <= MAX_DIRECTORY_EXTENSION_BYTES =>
245            {
246                Some(extension.to_owned())
247            },
248            Some(_) => return Ok(None),
249            None if is_safe_path_component(last_segment) => None,
250            None => return Ok(None),
251        };
252        if segments.iter().any(|segment| segment.starts_with('.')) {
253            // A generic dot-path policy cannot be safely calibrated without
254            // probing a special namespace, so fail closed for these candidates.
255            return Ok(None);
256        }
257        Ok(Some(Self {
258            parent_segments: segments[..segments.len().saturating_sub(1)]
259                .iter()
260                .map(|segment| (*segment).to_owned())
261                .collect(),
262            trailing_slash: candidate.path().ends_with('/'),
263            extension,
264        }))
265    }
266
267    fn control_url(&self, context: &ScanContext, nonce: &str) -> Result<Url, ScannerError> {
268        let mut path = String::new();
269        for segment in &self.parent_segments {
270            path.push('/');
271            path.push_str(segment);
272        }
273        path.push('/');
274        path.push_str(nonce);
275        if let Some(extension) = &self.extension {
276            path.push('.');
277            path.push_str(extension);
278        }
279        if self.trailing_slash {
280            path.push('/');
281        }
282        let control = context.authorized_target().join(&path)?;
283        context.canonicalize_discovery_url(&control)
284    }
285}
286
287fn is_safe_path_component(value: &str) -> bool {
288    !value.is_empty()
289        && value
290            .bytes()
291            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
292}
293
294fn group_candidates_by_shape(
295    candidates: Vec<Url>,
296) -> Result<BTreeMap<DirectoryShape, Vec<Url>>, ScannerError> {
297    let mut groups = BTreeMap::<DirectoryShape, Vec<Url>>::new();
298    for candidate in candidates {
299        if let Some(shape) = DirectoryShape::from_candidate(&candidate)? {
300            groups.entry(shape).or_default().push(candidate);
301        }
302    }
303    if groups.len() > MAX_DIRECTORY_CONTROL_SHAPES {
304        return Err(ScannerError::DiscoveryStateLimitExceeded);
305    }
306    Ok(groups)
307}
308
309struct ControlCalibration {
310    urls: [Url; 2],
311    nonces: [String; 2],
312    responses: [BoundedHttpResponse; 2],
313}
314
315impl ControlCalibration {
316    async fn collect(context: &ScanContext, shape: &DirectoryShape) -> Result<Self, ScannerError> {
317        let first_nonce = Uuid::new_v4().simple().to_string();
318        let mut second_nonce = Uuid::new_v4().simple().to_string();
319        while second_nonce == first_nonce {
320            second_nonce = Uuid::new_v4().simple().to_string();
321        }
322        let nonces = [first_nonce, second_nonce];
323        let urls = [
324            shape.control_url(context, &nonces[0])?,
325            shape.control_url(context, &nonces[1])?,
326        ];
327        let first = context
328            .request(DIRECTORY_ACTION_ID, HttpProbeMethod::Get, urls[0].clone())
329            .await?;
330        let second = context
331            .request(DIRECTORY_ACTION_ID, HttpProbeMethod::Get, urls[1].clone())
332            .await?;
333        Ok(Self {
334            urls,
335            nonces,
336            responses: [first, second],
337        })
338    }
339
340    fn scrubber(&self, candidate: Option<&Url>) -> ResponseScrubber {
341        let mut urls = vec![&self.urls[0], &self.urls[1]];
342        if let Some(candidate) = candidate {
343            urls.push(candidate);
344        }
345        ResponseScrubber::from_requests(&urls, &[self.nonces[0].as_str(), self.nonces[1].as_str()])
346    }
347
348    fn is_stable(&self) -> bool {
349        if self.responses.iter().any(control_is_unusable) {
350            return false;
351        }
352        let scrubber = self.scrubber(None);
353        ResponseSignature::capture_with_scrubber(&self.responses[0], &scrubber)
354            == ResponseSignature::capture_with_scrubber(&self.responses[1], &scrubber)
355    }
356
357    fn compare(
358        &self,
359        candidate_url: &Url,
360        candidate_response: &BoundedHttpResponse,
361    ) -> (ResponseSignature, ResponseSignature) {
362        let scrubber = self.scrubber(Some(candidate_url));
363        (
364            ResponseSignature::capture_with_scrubber(&self.responses[0], &scrubber),
365            ResponseSignature::capture_with_scrubber(candidate_response, &scrubber),
366        )
367    }
368}
369
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub(super) struct ResponseSignature {
372    status: u16,
373    content_type: Option<String>,
374    location: Option<String>,
375    cache_control: Option<String>,
376    www_authenticate: Option<String>,
377    body_digest: [u8; 32],
378    body_length: usize,
379    body_truncated: bool,
380}
381
382impl ResponseSignature {
383    pub(super) fn capture(response: &BoundedHttpResponse, scrubbed_values: &[&str]) -> Self {
384        let scrubber = ResponseScrubber::new(response.request_url(), scrubbed_values);
385        Self::capture_with_scrubber(response, &scrubber)
386    }
387
388    fn capture_with_scrubber(response: &BoundedHttpResponse, scrubber: &ResponseScrubber) -> Self {
389        let normalized_body = scrubber.body(response.body());
390        Self {
391            status: response.status(),
392            content_type: response
393                .content_type()
394                .map(|value| scrubber.header(value).to_ascii_lowercase()),
395            location: response
396                .location()
397                .map(|value| normalize_location(response.request_url(), value, scrubber)),
398            cache_control: response
399                .header("cache-control")
400                .map(|value| scrubber.header(value).to_ascii_lowercase()),
401            www_authenticate: response
402                .header("www-authenticate")
403                .map(|value| scrubber.header(value).to_ascii_lowercase()),
404            body_digest: Sha256::digest(&normalized_body).into(),
405            body_length: normalized_body.len(),
406            body_truncated: response.body_truncated(),
407        }
408    }
409
410    #[cfg(test)]
411    pub(super) fn from_parts(status: u16, location: Option<&str>, body: &str) -> Self {
412        let normalized = normalize_text(body, &[]).into_bytes();
413        Self {
414            status,
415            content_type: Some("text/html".to_owned()),
416            location: location.map(str::to_owned),
417            cache_control: None,
418            www_authenticate: None,
419            body_digest: Sha256::digest(&normalized).into(),
420            body_length: normalized.len(),
421            body_truncated: false,
422        }
423    }
424}
425
426fn control_is_unusable(response: &BoundedHttpResponse) -> bool {
427    response.body_truncated() || response.status() == 429 || matches!(response.status(), 500..=599)
428}
429
430fn candidate_is_observable(control: &ResponseSignature, candidate: &ResponseSignature) -> bool {
431    // 401 and 403 are endpoint observations when they differ from the control;
432    // they still produce INFO output rather than a security finding. A bounded
433    // prefix is not enough to distinguish either response, so truncation fails
434    // closed instead of manufacturing a route observation.
435    !control.body_truncated
436        && !candidate.body_truncated
437        && matches!(candidate.status, 200..=399 | 401 | 403)
438        && candidate != control
439}
440
441struct ResponseScrubber {
442    values: Vec<String>,
443}
444
445impl ResponseScrubber {
446    fn new(request_url: &Url, extra_values: &[&str]) -> Self {
447        Self::from_requests(&[request_url], extra_values)
448    }
449
450    fn from_requests(request_urls: &[&Url], extra_values: &[&str]) -> Self {
451        let mut values = extra_values
452            .iter()
453            .filter(|value| !value.is_empty())
454            .map(|value| (*value).to_owned())
455            .collect::<Vec<_>>();
456
457        // Soft-404 implementations commonly echo the requested target. Remove
458        // every compared request's incidental values from every leg. A shared
459        // universe avoids manufacturing a difference when a control template
460        // happens to contain the static candidate path (for example, a nav
461        // link to `/admin`).
462        for request_url in request_urls {
463            let mut request_target = request_url.path().to_owned();
464            if let Some(query) = request_url.query() {
465                request_target.push('?');
466                request_target.push_str(query);
467                add_scrub_variants(&mut values, query);
468            }
469            add_scrub_variants(&mut values, request_url.path());
470            if let Some(terminal) = request_url
471                .path()
472                .trim_end_matches('/')
473                .rsplit('/')
474                .next()
475                .filter(|terminal| !terminal.is_empty())
476            {
477                // Some wildcard routers echo only the requested basename. It
478                // is incidental request data just like a full path and must
479                // not manufacture a candidate/control distinction.
480                add_scrub_variants(&mut values, terminal);
481            }
482            add_scrub_variants(&mut values, &request_target);
483            add_scrub_variants(&mut values, request_url.as_str());
484        }
485        values.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
486        values.dedup();
487        Self { values }
488    }
489
490    fn body(&self, body: &[u8]) -> Vec<u8> {
491        normalize_text(&String::from_utf8_lossy(body), &self.values).into_bytes()
492    }
493
494    fn header(&self, value: &str) -> String {
495        normalize_text(value, &self.values)
496    }
497}
498
499fn add_scrub_variants(values: &mut Vec<String>, value: &str) {
500    if value.is_empty() {
501        return;
502    }
503    values.push(value.to_owned());
504    let encoded = percent_encode_non_alphanumeric(value.as_bytes());
505    values.push(encoded.clone());
506    values.push(lowercase_percent_escapes(&encoded));
507    values.push(uppercase_percent_escapes(value));
508    values.push(lowercase_percent_escapes(value));
509}
510
511fn lowercase_percent_escapes(value: &str) -> String {
512    normalize_percent_escape_case(value, u8::to_ascii_lowercase)
513}
514
515fn uppercase_percent_escapes(value: &str) -> String {
516    normalize_percent_escape_case(value, u8::to_ascii_uppercase)
517}
518
519fn normalize_percent_escape_case(value: &str, map: fn(&u8) -> u8) -> String {
520    let mut bytes = value.as_bytes().to_vec();
521    let mut index = 0;
522    while index + 2 < bytes.len() {
523        if bytes[index] == b'%'
524            && bytes[index + 1].is_ascii_hexdigit()
525            && bytes[index + 2].is_ascii_hexdigit()
526        {
527            bytes[index + 1] = map(&bytes[index + 1]);
528            bytes[index + 2] = map(&bytes[index + 2]);
529            index += 3;
530        } else {
531            index += 1;
532        }
533    }
534    match String::from_utf8(bytes) {
535        Ok(normalized) => normalized,
536        Err(_) => value.to_owned(),
537    }
538}
539
540fn percent_encode_non_alphanumeric(value: &[u8]) -> String {
541    const HEX: &[u8; 16] = b"0123456789ABCDEF";
542    let mut encoded = String::with_capacity(value.len().saturating_mul(3));
543    for byte in value {
544        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
545            encoded.push(char::from(*byte));
546        } else {
547            encoded.push('%');
548            encoded.push(char::from(HEX[usize::from(byte >> 4)]));
549            encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
550        }
551    }
552    encoded
553}
554
555fn normalize_location(request_url: &Url, location: &str, scrubber: &ResponseScrubber) -> String {
556    let Ok(mut destination) = request_url.join(location.trim()) else {
557        return "[invalid-location]".to_owned();
558    };
559    if !matches!(destination.scheme(), "http" | "https") {
560        return "[non-http-location]".to_owned();
561    }
562    if destination.origin() != request_url.origin() {
563        return "[cross-origin-location]".to_owned();
564    }
565    destination.set_fragment(None);
566    let pairs = destination
567        .query_pairs()
568        .map(|(name, value)| (name.into_owned(), value.into_owned()))
569        .collect::<Vec<_>>();
570    if pairs.is_empty() {
571        destination.set_query(None);
572    } else {
573        let mut names = std::collections::HashSet::with_capacity(pairs.len());
574        if pairs.iter().all(|(name, _)| names.insert(name.clone())) {
575            let mut sorted = pairs;
576            sorted.sort();
577            destination.query_pairs_mut().clear().extend_pairs(sorted);
578        }
579    }
580    scrubber.header(destination.as_str())
581}
582
583fn normalize_text(text: &str, scrubbed_values: &[String]) -> String {
584    // The replacement must not contain any character allowed in dynamic URL
585    // components. Otherwise a later short value (for example parameter `q`)
586    // can rewrite an earlier replacement and manufacture unequal signatures.
587    const SCRUB_SENTINEL: &str = "\u{1f}";
588    let mut normalized = uppercase_percent_escapes(text);
589    for value in scrubbed_values {
590        if !value.is_empty() {
591            normalized = normalized.replace(&uppercase_percent_escapes(value), SCRUB_SENTINEL);
592        }
593    }
594    normalized.split_whitespace().collect::<Vec<_>>().join(" ")
595}
596
597fn endpoint_subject(endpoint: &Url) -> String {
598    let mut subject = endpoint.clone();
599    subject.set_query(None);
600    subject.set_fragment(None);
601    subject.to_string()
602}
603
604#[cfg(test)]
605mod tests {
606    use std::sync::{
607        atomic::{AtomicUsize, Ordering},
608        Arc, Mutex,
609    };
610    use std::time::Duration;
611
612    use tokio::{
613        io::{AsyncReadExt, AsyncWriteExt},
614        net::TcpListener,
615        task::JoinHandle,
616    };
617
618    use super::*;
619    use crate::legacy_discovery::DiscoveryLimits;
620
621    struct FixtureResponse {
622        status: &'static str,
623        headers: Vec<(String, String)>,
624        body: String,
625    }
626
627    impl FixtureResponse {
628        fn html(status: &'static str, body: impl Into<String>) -> Self {
629            Self {
630                status,
631                headers: vec![("Content-Type".to_owned(), "text/html".to_owned())],
632                body: body.into(),
633            }
634        }
635
636        fn redirect(location: impl Into<String>, body: impl Into<String>) -> Self {
637            Self {
638                status: "302 Found",
639                headers: vec![
640                    ("Content-Type".to_owned(), "text/html".to_owned()),
641                    ("Location".to_owned(), location.into()),
642                ],
643                body: body.into(),
644            }
645        }
646    }
647
648    struct LocalFixture {
649        target: Url,
650        requests: Arc<AtomicUsize>,
651        targets: Arc<Mutex<Vec<String>>>,
652        task: JoinHandle<()>,
653    }
654
655    impl Drop for LocalFixture {
656        fn drop(&mut self) {
657            self.task.abort();
658        }
659    }
660
661    async fn serve_fixture(
662        max_requests: usize,
663        handler: impl Fn(&str) -> FixtureResponse + Send + Sync + 'static,
664    ) -> LocalFixture {
665        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
666        let address = listener.local_addr().unwrap();
667        let requests = Arc::new(AtomicUsize::new(0));
668        let observed_requests = Arc::clone(&requests);
669        let targets = Arc::new(Mutex::new(Vec::new()));
670        let observed_targets = Arc::clone(&targets);
671        let handler = Arc::new(handler);
672        let task = tokio::spawn(async move {
673            for _ in 0..max_requests {
674                let (mut stream, _) = listener.accept().await.unwrap();
675                let mut request = Vec::new();
676                loop {
677                    let mut chunk = [0_u8; 1_024];
678                    let read = stream.read(&mut chunk).await.unwrap();
679                    if read == 0 {
680                        break;
681                    }
682                    request.extend_from_slice(&chunk[..read]);
683                    if request.windows(4).any(|window| window == b"\r\n\r\n") {
684                        break;
685                    }
686                    assert!(request.len() <= 16 * 1_024, "fixture request too large");
687                }
688                observed_requests.fetch_add(1, Ordering::SeqCst);
689                let target = String::from_utf8_lossy(&request)
690                    .lines()
691                    .next()
692                    .and_then(|line| line.split_ascii_whitespace().nth(1))
693                    .unwrap()
694                    .to_owned();
695                observed_targets
696                    .lock()
697                    .unwrap_or_else(std::sync::PoisonError::into_inner)
698                    .push(target.clone());
699                let response = handler(&target);
700                let mut wire = format!(
701                    "HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n",
702                    response.status,
703                    response.body.len()
704                );
705                for (name, value) in response.headers {
706                    wire.push_str(&format!("{name}: {value}\r\n"));
707                }
708                wire.push_str("\r\n");
709                wire.push_str(&response.body);
710                stream.write_all(wire.as_bytes()).await.unwrap();
711                stream.shutdown().await.unwrap();
712            }
713        });
714
715        LocalFixture {
716            target: Url::parse(&format!("http://{address}/")).unwrap(),
717            requests,
718            targets,
719            task,
720        }
721    }
722
723    fn scan_context(target: Url) -> ScanContext {
724        let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
725        ScanContext::new(target, reqwest::Client::new(), telemetry)
726    }
727
728    fn signature(status: u16, location: Option<&str>, body: &str) -> ResponseSignature {
729        ResponseSignature::from_parts(status, location, body)
730    }
731
732    fn control_nonce_from_target(target: &str) -> Option<&str> {
733        let path = target.split('?').next()?.trim_end_matches('/');
734        let terminal = path.rsplit('/').next()?;
735        let nonce = terminal.split_once('.').map_or(terminal, |(stem, _)| stem);
736        (nonce.len() == 32 && nonce.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(nonce)
737    }
738
739    fn is_control_target(target: &str) -> bool {
740        control_nonce_from_target(target).is_some()
741    }
742
743    #[test]
744    fn phase_identity_is_stable() {
745        let fuzzer = DirectoryFuzzer::with_default_wordlist_sequential();
746        assert_eq!(fuzzer.phase_number(), 3);
747        assert_eq!(fuzzer.name(), "Directory & Endpoint Fuzzer");
748        assert_eq!(fuzzer.wordlist.len(), 13);
749    }
750
751    #[test]
752    fn oversized_candidate_input_fails_before_transport() {
753        let context = scan_context(Url::parse("https://example.test/").unwrap());
754        let fuzzer = DirectoryFuzzer::sequential(
755            (0..=MAX_DIRECTORY_CANDIDATES)
756                .map(|index| format!("/path-{index}"))
757                .collect(),
758        );
759
760        assert!(matches!(
761            fuzzer.candidates(&context),
762            Err(ScannerError::DiscoveryStateLimitExceeded)
763        ));
764    }
765
766    #[test]
767    fn wildcard_200_and_custom_soft_404_are_suppressed() {
768        let control = signature(200, None, "not found: [nonce]");
769        let wildcard = signature(200, None, "not found: [nonce]");
770        assert!(!candidate_is_observable(&control, &wildcard));
771
772        let custom_control = signature(404, None, "Our custom missing page");
773        let custom_candidate = signature(404, None, "Our custom missing page");
774        assert!(!candidate_is_observable(&custom_control, &custom_candidate));
775    }
776
777    #[test]
778    fn stable_selected_header_difference_is_observable() {
779        let control = signature(200, None, "same body");
780        let mut candidate = control.clone();
781        candidate.cache_control = Some("private".to_owned());
782        assert!(candidate_is_observable(&control, &candidate));
783    }
784
785    #[test]
786    fn redirect_all_is_suppressed_but_distinct_redirect_is_observed() {
787        let control = signature(302, Some("/login"), "redirecting");
788        assert!(!candidate_is_observable(
789            &control,
790            &signature(302, Some("/login"), "redirecting")
791        ));
792        assert!(candidate_is_observable(
793            &control,
794            &signature(302, Some("/admin/login"), "redirecting")
795        ));
796    }
797
798    #[test]
799    fn protected_status_is_only_observable_when_distinct_from_control() {
800        let control = signature(404, None, "missing");
801        assert!(candidate_is_observable(
802            &control,
803            &signature(403, None, "forbidden")
804        ));
805        let wildcard = signature(403, None, "forbidden");
806        assert!(!candidate_is_observable(
807            &wildcard,
808            &signature(403, None, "forbidden")
809        ));
810    }
811
812    #[test]
813    fn explicit_missing_and_server_error_statuses_are_not_endpoints() {
814        let control = signature(200, None, "wildcard");
815        assert!(!candidate_is_observable(
816            &control,
817            &signature(404, None, "distinct missing page")
818        ));
819        assert!(!candidate_is_observable(
820            &control,
821            &signature(500, None, "server failure")
822        ));
823    }
824
825    #[test]
826    fn truncated_bodies_never_establish_a_distinct_endpoint() {
827        let control = signature(404, None, "missing");
828        let mut candidate = signature(200, None, "bounded prefix");
829        candidate.body_truncated = true;
830        assert!(!candidate_is_observable(&control, &candidate));
831
832        let mut truncated_control = control;
833        truncated_control.body_truncated = true;
834        assert!(!candidate_is_observable(
835            &truncated_control,
836            &signature(200, None, "complete")
837        ));
838    }
839
840    #[test]
841    fn nonce_scrubbing_makes_equivalent_control_pages_equal() {
842        assert_eq!(
843            normalize_text("missing path alpha-123", &["alpha-123".to_owned()]),
844            normalize_text("missing path beta-456", &["beta-456".to_owned()]),
845        );
846    }
847
848    #[test]
849    fn request_target_echoes_are_scrubbed() {
850        let first_url = Url::parse("https://example.test/resource-123").unwrap();
851        let second_url = Url::parse("https://example.test/admin").unwrap();
852        let scrubber = ResponseScrubber::from_requests(&[&first_url, &second_url], &["123"]);
853        let first = scrubber.body(b"no route: /resource-123; nav /admin");
854        let second = scrubber.body(b"no route: /admin; nav /admin");
855        assert_eq!(first, second);
856    }
857
858    #[test]
859    fn encoded_request_target_echoes_are_scrubbed() {
860        let first_url = Url::parse("https://example.test/resource-123").unwrap();
861        let second_url = Url::parse("https://example.test/admin").unwrap();
862        let scrubber = ResponseScrubber::from_requests(&[&first_url, &second_url], &["123"]);
863        let first = scrubber.body(b"no route: %2Fresource-123");
864        let second = scrubber.body(b"no route: %2fadmin");
865        assert_eq!(first, second);
866    }
867
868    #[test]
869    fn redirect_destination_is_resolved_and_request_target_is_scrubbed() {
870        let control_url = Url::parse("https://example.test/resource-123?source=control").unwrap();
871        let candidate_url = Url::parse("https://example.test/admin?source=candidate").unwrap();
872        let scrubber = ResponseScrubber::from_requests(&[&control_url, &candidate_url], &["123"]);
873
874        assert_eq!(
875            normalize_location(&control_url, "/login?next=/resource-123", &scrubber,),
876            normalize_location(&candidate_url, "/login?next=/admin", &scrubber),
877        );
878        assert_eq!(
879            normalize_location(&control_url, "https://other.test/a", &scrubber),
880            "[cross-origin-location]",
881        );
882        assert_eq!(
883            normalize_location(&control_url, "mailto:ops@example.test", &scrubber),
884            "[non-http-location]",
885        );
886    }
887
888    #[test]
889    fn redirect_destination_canonicalizes_only_safe_query_ordering() {
890        let request = Url::parse("https://example.test/source").unwrap();
891        let scrubber = ResponseScrubber::new(&request, &[]);
892        assert_eq!(
893            normalize_location(&request, "/login?b=2&a=1#one", &scrubber),
894            normalize_location(&request, "/login?a=1&b=2#two", &scrubber),
895        );
896        assert_ne!(
897            normalize_location(&request, "/login?a=1&a=2", &scrubber),
898            normalize_location(&request, "/login?a=2&a=1", &scrubber),
899            "repeated-name value order can carry application semantics"
900        );
901    }
902
903    #[test]
904    fn endpoint_subject_never_discloses_query_values() {
905        let endpoint = Url::parse("https://example.test/path?token=secret#part").unwrap();
906        assert_eq!(endpoint_subject(&endpoint), "https://example.test/path");
907    }
908
909    #[test]
910    fn candidate_construction_is_canonical_deduplicated_and_same_origin() {
911        let context = scan_context(Url::parse("https://example.test/root").unwrap());
912        let fuzzer = DirectoryFuzzer::sequential(vec![
913            "/a#first".to_owned(),
914            "/a#second".to_owned(),
915            "/b?z=2&a=1".to_owned(),
916        ]);
917        assert_eq!(
918            fuzzer
919                .candidates(&context)
920                .unwrap()
921                .into_iter()
922                .map(|url| url.to_string())
923                .collect::<Vec<_>>(),
924            vec!["https://example.test/a", "https://example.test/b?a=1&z=2"]
925        );
926        assert!(
927            DirectoryFuzzer::sequential(vec!["https://outside.test/a".to_owned()])
928                .candidates(&context)
929                .is_err()
930        );
931    }
932
933    #[test]
934    fn candidate_shapes_separate_depth_trailing_slash_and_extension() {
935        let candidates = [
936            "https://example.test/a",
937            "https://example.test/a/",
938            "https://example.test/a/b",
939            "https://example.test/a.json",
940            "https://example.test/a?view=one",
941        ]
942        .into_iter()
943        .map(|candidate| Url::parse(candidate).unwrap())
944        .collect();
945
946        assert_eq!(group_candidates_by_shape(candidates).unwrap().len(), 4);
947        assert!(DirectoryShape::from_candidate(
948            &Url::parse("https://example.test/.hidden").unwrap()
949        )
950        .unwrap()
951        .is_none());
952        assert!(DirectoryShape::from_candidate(
953            &Url::parse("https://example.test/admin?token=secret").unwrap()
954        )
955        .unwrap()
956        .is_none());
957        for unsafe_candidate in [
958            "https://example.test/admin%2fsettings",
959            "https://example.test/admin;mode=one",
960            "https://example.test/admin.json.bak",
961            "https://example.test/a.b/admin",
962        ] {
963            assert!(
964                DirectoryShape::from_candidate(&Url::parse(unsafe_candidate).unwrap())
965                    .unwrap()
966                    .is_none()
967            );
968        }
969    }
970
971    #[tokio::test]
972    async fn wildcard_200_with_request_echo_produces_no_observation() {
973        let fixture = serve_fixture(4, |target| {
974            FixtureResponse::html("200 OK", format!("no route {target}; nav /admin"))
975        })
976        .await;
977        let context = scan_context(fixture.target.clone());
978        let findings = DirectoryFuzzer::sequential(vec!["/api".to_owned(), "/admin".to_owned()])
979            .execute(&context)
980            .await
981            .unwrap();
982
983        assert!(findings.is_empty());
984        assert_eq!(
985            context
986                .discovery_snapshot()
987                .endpoints()
988                .keys()
989                .collect::<Vec<_>>(),
990            vec![&fixture.target.to_string()]
991        );
992        assert_eq!(fixture.requests.load(Ordering::SeqCst), 4);
993        let targets = fixture
994            .targets
995            .lock()
996            .unwrap_or_else(std::sync::PoisonError::into_inner);
997        assert!(is_control_target(&targets[0]));
998        assert!(is_control_target(&targets[1]));
999        assert_eq!(
1000            targets[2..].iter().map(String::as_str).collect::<Vec<_>>(),
1001            vec!["/admin", "/api"]
1002        );
1003    }
1004
1005    #[tokio::test]
1006    async fn dynamic_soft_404_control_signatures_prevent_candidate_dispatch() {
1007        let sequence = Arc::new(AtomicUsize::new(0));
1008        let handler_sequence = Arc::clone(&sequence);
1009        let fixture = serve_fixture(2, move |_| {
1010            let request_id = handler_sequence.fetch_add(1, Ordering::SeqCst);
1011            FixtureResponse {
1012                status: "200 OK",
1013                headers: vec![
1014                    ("Content-Type".to_owned(), "text/html".to_owned()),
1015                    (
1016                        "Cache-Control".to_owned(),
1017                        format!("private, request-id={request_id}"),
1018                    ),
1019                ],
1020                body: "custom application missing page".to_owned(),
1021            }
1022        })
1023        .await;
1024        let context = scan_context(fixture.target.clone());
1025
1026        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1027            .execute(&context)
1028            .await
1029            .unwrap();
1030
1031        assert!(findings.is_empty());
1032        assert_eq!(fixture.requests.load(Ordering::SeqCst), 2);
1033    }
1034
1035    #[tokio::test]
1036    async fn unusable_first_or_second_control_prevents_candidate_dispatch() {
1037        let first_sequence = Arc::new(AtomicUsize::new(0));
1038        let handler_sequence = Arc::clone(&first_sequence);
1039        let first_unusable = serve_fixture(2, move |_| {
1040            if handler_sequence.fetch_add(1, Ordering::SeqCst) == 0 {
1041                FixtureResponse::html("500 Internal Server Error", "temporary failure")
1042            } else {
1043                FixtureResponse::html("200 OK", "wildcard")
1044            }
1045        })
1046        .await;
1047        let context = scan_context(first_unusable.target.clone());
1048        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1049            .execute(&context)
1050            .await
1051            .unwrap();
1052        assert!(findings.is_empty());
1053        assert_eq!(first_unusable.requests.load(Ordering::SeqCst), 2);
1054
1055        let second_sequence = Arc::new(AtomicUsize::new(0));
1056        let handler_sequence = Arc::clone(&second_sequence);
1057        let second_unusable = serve_fixture(2, move |_| {
1058            if handler_sequence.fetch_add(1, Ordering::SeqCst) == 1 {
1059                FixtureResponse::html("429 Too Many Requests", "rate limited")
1060            } else {
1061                FixtureResponse::html("200 OK", "wildcard")
1062            }
1063        })
1064        .await;
1065        let context = scan_context(second_unusable.target.clone());
1066        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1067            .execute(&context)
1068            .await
1069            .unwrap();
1070        assert!(findings.is_empty());
1071        assert_eq!(second_unusable.requests.load(Ordering::SeqCst), 2);
1072    }
1073
1074    #[tokio::test]
1075    async fn lowercase_percent_encoded_request_echo_is_suppressed() {
1076        let fixture = serve_fixture(3, |target| {
1077            let encoded = percent_encode_non_alphanumeric(target.as_bytes()).to_ascii_lowercase();
1078            FixtureResponse::html("200 OK", format!("no route {encoded}"))
1079        })
1080        .await;
1081        let context = scan_context(fixture.target.clone());
1082
1083        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1084            .execute(&context)
1085            .await
1086            .unwrap();
1087
1088        assert!(findings.is_empty());
1089        assert_eq!(fixture.requests.load(Ordering::SeqCst), 3);
1090    }
1091
1092    #[tokio::test]
1093    async fn selected_headers_location_and_body_share_the_scrub_universe() {
1094        let fixture = serve_fixture(3, |target| {
1095            let encoded = percent_encode_non_alphanumeric(target.as_bytes());
1096            FixtureResponse {
1097                status: "302 Found",
1098                headers: vec![
1099                    (
1100                        "Content-Type".to_owned(),
1101                        format!("text/html; profile={encoded}"),
1102                    ),
1103                    (
1104                        "Location".to_owned(),
1105                        format!("/login?next={encoded}&b=2&a=1"),
1106                    ),
1107                    (
1108                        "Cache-Control".to_owned(),
1109                        format!("private, target={encoded}"),
1110                    ),
1111                    (
1112                        "WWW-Authenticate".to_owned(),
1113                        format!("Basic realm={encoded}"),
1114                    ),
1115                ],
1116                body: format!("redirecting from {target}"),
1117            }
1118        })
1119        .await;
1120        let context = scan_context(fixture.target.clone());
1121
1122        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1123            .execute(&context)
1124            .await
1125            .unwrap();
1126
1127        assert!(findings.is_empty());
1128        assert_eq!(fixture.requests.load(Ordering::SeqCst), 3);
1129    }
1130
1131    #[tokio::test]
1132    async fn ordinary_controls_do_not_turn_dot_path_policy_into_an_observation() {
1133        let fixture = serve_fixture(3, |target| {
1134            if target.starts_with("/.") {
1135                FixtureResponse::html("403 Forbidden", "dot paths forbidden")
1136            } else {
1137                FixtureResponse::html("200 OK", format!("wildcard {target}"))
1138            }
1139        })
1140        .await;
1141        let context = scan_context(fixture.target.clone());
1142
1143        let findings = DirectoryFuzzer::sequential(vec!["/.git".to_owned(), "/admin".to_owned()])
1144            .execute(&context)
1145            .await
1146            .unwrap();
1147
1148        assert!(findings.is_empty());
1149        let targets = fixture
1150            .targets
1151            .lock()
1152            .unwrap_or_else(std::sync::PoisonError::into_inner);
1153        assert_eq!(targets.len(), 3);
1154        assert!(targets.iter().all(|target| !target.starts_with("/.")));
1155    }
1156
1157    #[tokio::test]
1158    async fn extension_specific_wildcard_is_calibrated_with_matching_controls() {
1159        let fixture = serve_fixture(3, |target| {
1160            if target.split('?').next().unwrap().ends_with(".json") {
1161                FixtureResponse::html("200 OK", format!(r#"{{"missing":"{target}"}}"#))
1162            } else {
1163                FixtureResponse::html("404 Not Found", "missing")
1164            }
1165        })
1166        .await;
1167        let context = scan_context(fixture.target.clone());
1168
1169        let findings = DirectoryFuzzer::sequential(vec!["/swagger.json".to_owned()])
1170            .execute(&context)
1171            .await
1172            .unwrap();
1173
1174        assert!(findings.is_empty());
1175        assert_eq!(fixture.requests.load(Ordering::SeqCst), 3);
1176        let targets = fixture
1177            .targets
1178            .lock()
1179            .unwrap_or_else(std::sync::PoisonError::into_inner);
1180        assert!(targets[0].ends_with(".json"));
1181        assert!(targets[1].ends_with(".json"));
1182        assert_eq!(targets[2], "/swagger.json");
1183    }
1184
1185    #[tokio::test]
1186    async fn parent_scoped_wildcard_is_calibrated_inside_the_same_namespace() {
1187        let fixture = serve_fixture(3, |target| {
1188            if target.starts_with("/api/") {
1189                FixtureResponse::html("200 OK", format!("api wildcard {target}"))
1190            } else {
1191                FixtureResponse::html("404 Not Found", "missing")
1192            }
1193        })
1194        .await;
1195        let context = scan_context(fixture.target.clone());
1196
1197        let findings = DirectoryFuzzer::sequential(vec!["/api/v1".to_owned()])
1198            .execute(&context)
1199            .await
1200            .unwrap();
1201
1202        assert!(findings.is_empty());
1203        assert_eq!(fixture.requests.load(Ordering::SeqCst), 3);
1204        let targets = fixture
1205            .targets
1206            .lock()
1207            .unwrap_or_else(std::sync::PoisonError::into_inner);
1208        assert!(targets[0].starts_with("/api/"));
1209        assert!(targets[1].starts_with("/api/"));
1210        assert_eq!(targets[2], "/api/v1");
1211    }
1212
1213    #[tokio::test]
1214    async fn terminal_segment_only_echo_is_not_an_endpoint_observation() {
1215        let fixture = serve_fixture(6, |target| {
1216            let terminal = target
1217                .trim_end_matches('/')
1218                .rsplit('/')
1219                .next()
1220                .unwrap_or_default();
1221            FixtureResponse::html("200 OK", format!("missing resource {terminal}"))
1222        })
1223        .await;
1224        let context = scan_context(fixture.target.clone());
1225
1226        let findings =
1227            DirectoryFuzzer::sequential(vec!["/admin".to_owned(), "/config.json".to_owned()])
1228                .execute(&context)
1229                .await
1230                .unwrap();
1231
1232        assert!(findings.is_empty());
1233        assert_eq!(fixture.requests.load(Ordering::SeqCst), 6);
1234    }
1235
1236    #[tokio::test]
1237    async fn empty_candidate_set_performs_zero_requests() {
1238        let fixture = serve_fixture(0, |_| unreachable!()).await;
1239        let context = scan_context(fixture.target.clone());
1240
1241        let findings = DirectoryFuzzer::sequential(Vec::new())
1242            .execute(&context)
1243            .await
1244            .unwrap();
1245
1246        assert!(findings.is_empty());
1247        assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
1248    }
1249
1250    #[tokio::test]
1251    async fn query_values_are_never_replayed_to_control_paths() {
1252        let fixture = serve_fixture(0, |_| unreachable!()).await;
1253        let context = scan_context(fixture.target.clone());
1254
1255        let findings = DirectoryFuzzer::sequential(vec!["/admin?token=secret".to_owned()])
1256            .execute(&context)
1257            .await
1258            .unwrap();
1259
1260        assert!(findings.is_empty());
1261        assert_eq!(fixture.requests.load(Ordering::SeqCst), 0);
1262        assert!(fixture
1263            .targets
1264            .lock()
1265            .unwrap_or_else(std::sync::PoisonError::into_inner)
1266            .is_empty());
1267    }
1268
1269    #[tokio::test]
1270    async fn truncated_control_prevents_candidate_dispatch() {
1271        let fixture =
1272            serve_fixture(2, |_| FixtureResponse::html("200 OK", "x".repeat(1_024))).await;
1273        let limits = DiscoveryLimits::new()
1274            .with_max_requests(2)
1275            .with_body_limits(4 * 1_024, 64)
1276            .unwrap();
1277        let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
1278        let context = ScanContext::new_with_discovery_limits(
1279            fixture.target.clone(),
1280            reqwest::Client::new(),
1281            telemetry,
1282            limits,
1283        );
1284
1285        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1286            .execute(&context)
1287            .await
1288            .unwrap();
1289
1290        assert!(findings.is_empty());
1291        assert_eq!(fixture.requests.load(Ordering::SeqCst), 2);
1292    }
1293
1294    #[tokio::test]
1295    async fn prior_control_nonce_echo_does_not_change_classification() {
1296        let remembered_nonce = Arc::new(Mutex::new(None::<String>));
1297        let handler_nonce = Arc::clone(&remembered_nonce);
1298        let fixture = serve_fixture(3, move |target| {
1299            let nonce = if let Some(value) = control_nonce_from_target(target) {
1300                *handler_nonce
1301                    .lock()
1302                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.to_owned());
1303                value.to_owned()
1304            } else {
1305                handler_nonce
1306                    .lock()
1307                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1308                    .clone()
1309                    .unwrap()
1310            };
1311            FixtureResponse::html("200 OK", format!("missing nonce {nonce}"))
1312        })
1313        .await;
1314        let context = scan_context(fixture.target.clone());
1315
1316        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1317            .execute(&context)
1318            .await
1319            .unwrap();
1320
1321        assert!(findings.is_empty());
1322        assert_eq!(fixture.requests.load(Ordering::SeqCst), 3);
1323    }
1324
1325    #[tokio::test]
1326    async fn custom_soft_404_and_redirect_all_produce_no_observation() {
1327        let soft_404 = serve_fixture(3, |_| {
1328            FixtureResponse::html("200 OK", "custom application missing page")
1329        })
1330        .await;
1331        let context = scan_context(soft_404.target.clone());
1332        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1333            .execute(&context)
1334            .await
1335            .unwrap();
1336        assert!(findings.is_empty());
1337
1338        let redirect_all = serve_fixture(3, |target| {
1339            let path = target.split('?').next().unwrap();
1340            let encoded = percent_encode_non_alphanumeric(path.as_bytes());
1341            let location = if path == "/admin" {
1342                format!("/login?a=1&next={encoded}&b=2")
1343            } else {
1344                format!("/login?b=2&next={encoded}&a=1")
1345            };
1346            FixtureResponse::redirect(location, format!("redirecting from {path}"))
1347        })
1348        .await;
1349        let context = scan_context(redirect_all.target.clone());
1350        let findings = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1351            .execute(&context)
1352            .await
1353            .unwrap();
1354        assert!(findings.is_empty());
1355        assert_eq!(
1356            context
1357                .discovery_snapshot()
1358                .endpoints()
1359                .keys()
1360                .collect::<Vec<_>>(),
1361            vec![&redirect_all.target.to_string()]
1362        );
1363    }
1364
1365    #[tokio::test]
1366    async fn distinct_endpoint_and_protected_route_are_info_observations() {
1367        let fixture = serve_fixture(4, |target| {
1368            if target == "/admin" {
1369                FixtureResponse::html("200 OK", "admin endpoint")
1370            } else if target == "/protected" {
1371                FixtureResponse::html("403 Forbidden", "access boundary")
1372            } else {
1373                FixtureResponse::html("404 Not Found", "missing")
1374            }
1375        })
1376        .await;
1377        let context = scan_context(fixture.target.clone());
1378        let findings =
1379            DirectoryFuzzer::sequential(vec!["/protected".to_owned(), "/admin".to_owned()])
1380                .execute(&context)
1381                .await
1382                .unwrap();
1383
1384        assert_eq!(findings.len(), 2);
1385        assert!(findings[0].evidence.contains("/admin"));
1386        assert!(findings[1].evidence.contains("/protected"));
1387        assert!(findings.iter().all(|finding| finding.severity == "INFO"));
1388        assert!(findings.iter().all(|finding| {
1389            let text = format!("{} {}", finding.description, finding.evidence).to_lowercase();
1390            !text.contains("vulnerab")
1391                && !text.contains("confirm")
1392                && !text.contains(".venom-nonexistent")
1393        }));
1394        let snapshot = context.discovery_snapshot();
1395        assert!(snapshot
1396            .endpoints()
1397            .contains_key(fixture.target.join("/admin").unwrap().as_str()));
1398        assert!(snapshot
1399            .endpoints()
1400            .contains_key(fixture.target.join("/protected").unwrap().as_str()));
1401    }
1402
1403    #[tokio::test]
1404    async fn budget_denial_after_control_leaves_discovery_state_uncommitted() {
1405        let fixture = serve_fixture(1, |_| {
1406            FixtureResponse::html("404 Not Found", "missing control")
1407        })
1408        .await;
1409        let limits = DiscoveryLimits::new()
1410            .with_max_depth(1)
1411            .with_max_pages(1)
1412            .unwrap()
1413            .with_max_requests(1)
1414            .with_request_timeout(Duration::from_secs(1))
1415            .unwrap()
1416            .with_max_wall_time(Duration::from_secs(10))
1417            .unwrap()
1418            .with_body_limits(128 * 1_024, 64 * 1_024)
1419            .unwrap();
1420        let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
1421        let context = ScanContext::new_with_discovery_limits(
1422            fixture.target.clone(),
1423            reqwest::Client::new(),
1424            telemetry,
1425            limits,
1426        );
1427        let mut seed = DiscoveryDelta::new();
1428        let existing = fixture.target.join("/existing").unwrap();
1429        seed.record_endpoint(existing.clone(), ["kept".to_owned()]);
1430        context.commit_discovery(DIRECTORY_ACTION_ID, seed).unwrap();
1431
1432        let before = context.discovery_snapshot();
1433        let error = DirectoryFuzzer::sequential(vec!["/admin".to_owned()])
1434            .execute(&context)
1435            .await
1436            .unwrap_err();
1437        assert!(matches!(error, ScannerError::BudgetExceeded(_)));
1438        assert_eq!(context.discovery_snapshot(), before);
1439        assert_eq!(fixture.requests.load(Ordering::SeqCst), 1);
1440        assert_eq!(
1441            context.discovery_snapshot().endpoints()[existing.as_str()],
1442            BTreeSet::from(["kept".to_owned()])
1443        );
1444    }
1445}