1use 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#[derive(Debug)]
30pub struct DirectoryFuzzer {
31 wordlist: Vec<String>,
32 candidate_input_within_limits: bool,
33}
34
35impl DirectoryFuzzer {
36 pub fn new(wordlist: Vec<String>, _concurrency_limit: usize) -> Self {
43 Self::sequential(wordlist)
44 }
45
46 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 pub fn with_default_wordlist(_concurrency_limit: usize) -> Self {
62 Self::with_default_wordlist_sequential()
63 }
64
65 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 "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 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 return Ok(None);
211 }
212 if candidate.path().contains('%') || candidate.path().contains("//") {
213 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 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 !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 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 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 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}