1use std::{
10 collections::{BTreeMap, BTreeSet, HashSet},
11 sync::{Arc, Mutex, MutexGuard},
12 time::{Duration, Instant},
13};
14
15use url::Url;
16
17use crate::{
18 error::ScannerError,
19 http_evidence::{
20 HttpEvidencePolicy, HttpProbe, HttpProbeMethod, HttpRequestBroker, HttpRequestBrokerError,
21 MAX_HTTP_BODY_LIMIT,
22 },
23 runtime_budget::RequestAccountingBroker,
24 DecisionExecutionLimits, DecisionExecutionStage, RuntimeBudget, RuntimeBudgetDimension,
25 RuntimeLimitExceeded,
26};
27
28const DEFAULT_MAX_DEPTH: usize = 4;
29const DEFAULT_MAX_PAGES: usize = 64;
30const DEFAULT_MAX_REQUESTS: u32 = 64;
31const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
32const DEFAULT_MAX_WALL_TIME: Duration = Duration::from_secs(120);
33const DEFAULT_MAX_CUMULATIVE_BODY_BYTES: u64 = 4 * 1024 * 1024;
34const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024;
35const DEFAULT_MAX_VERIFICATION_REQUESTS: u32 = 96;
36const DEFAULT_VERIFICATION_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
37const DEFAULT_MAX_VERIFICATION_WALL_TIME: Duration = Duration::from_secs(120);
38const DEFAULT_MAX_VERIFICATION_RESPONSE_BYTES: u64 = 4 * 1024 * 1024;
39const DEFAULT_MAX_VERIFICATION_RESPONSE_BODY_BYTES: usize = 64 * 1024;
40const MAX_VERIFICATION_REQUESTS: u32 = 4_096;
41const MAX_DISCOVERY_ENDPOINTS: usize = 4_096;
42const MAX_DISCOVERY_FORMS: usize = 4_096;
43const MAX_DISCOVERY_URL_BYTES: usize = 8_192;
44const MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT: usize = 256;
45const MAX_DISCOVERY_TOTAL_PARAMETERS: usize = 16_384;
46const MAX_DISCOVERY_CONTROLS_PER_FORM: usize = 256;
47const MAX_DISCOVERY_TOTAL_FORM_CONTROLS: usize = 16_384;
48const MAX_DISCOVERY_NAME_BYTES: usize = 1_024;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct DiscoveryLimits {
53 max_depth: usize,
54 max_pages: usize,
55 max_requests: u32,
56 request_timeout: Duration,
57 max_wall_time: Duration,
58 max_cumulative_body_bytes: u64,
59 max_response_body_bytes: usize,
60}
61
62impl DiscoveryLimits {
63 pub const fn new() -> Self {
65 Self {
66 max_depth: DEFAULT_MAX_DEPTH,
67 max_pages: DEFAULT_MAX_PAGES,
68 max_requests: DEFAULT_MAX_REQUESTS,
69 request_timeout: DEFAULT_REQUEST_TIMEOUT,
70 max_wall_time: DEFAULT_MAX_WALL_TIME,
71 max_cumulative_body_bytes: DEFAULT_MAX_CUMULATIVE_BODY_BYTES,
72 max_response_body_bytes: DEFAULT_MAX_RESPONSE_BODY_BYTES,
73 }
74 }
75
76 pub const fn with_max_depth(mut self, limit: usize) -> Self {
78 self.max_depth = limit;
79 self
80 }
81
82 pub fn with_max_pages(mut self, limit: usize) -> Result<Self, ScannerError> {
84 if limit > MAX_DISCOVERY_ENDPOINTS {
85 return Err(ScannerError::InvalidDiscoveryLimits);
86 }
87 self.max_pages = limit;
88 Ok(self)
89 }
90
91 pub const fn with_max_requests(mut self, limit: u32) -> Self {
93 self.max_requests = limit;
94 self
95 }
96
97 pub fn with_request_timeout(mut self, limit: Duration) -> Result<Self, ScannerError> {
99 if limit < Duration::from_millis(1) {
100 return Err(ScannerError::InvalidDiscoveryLimits);
101 }
102 self.request_timeout = limit;
103 Ok(self)
104 }
105
106 pub fn with_max_wall_time(mut self, limit: Duration) -> Result<Self, ScannerError> {
108 if limit < Duration::from_millis(1) {
109 return Err(ScannerError::InvalidDiscoveryLimits);
110 }
111 self.max_wall_time = limit;
112 Ok(self)
113 }
114
115 pub fn with_body_limits(
117 mut self,
118 cumulative: u64,
119 per_response: usize,
120 ) -> Result<Self, ScannerError> {
121 let per_response_u64 =
122 u64::try_from(per_response).map_err(|_| ScannerError::InvalidDiscoveryLimits)?;
123 if cumulative == 0
124 || per_response == 0
125 || per_response > MAX_HTTP_BODY_LIMIT
126 || per_response_u64 > cumulative
127 {
128 return Err(ScannerError::InvalidDiscoveryLimits);
129 }
130 self.max_cumulative_body_bytes = cumulative;
131 self.max_response_body_bytes = per_response;
132 Ok(self)
133 }
134
135 pub const fn max_depth(self) -> usize {
137 self.max_depth
138 }
139
140 pub const fn max_pages(self) -> usize {
142 self.max_pages
143 }
144
145 pub const fn max_requests(self) -> u32 {
147 self.max_requests
148 }
149
150 pub const fn request_timeout(self) -> Duration {
152 self.request_timeout
153 }
154
155 pub const fn max_wall_time(self) -> Duration {
157 self.max_wall_time
158 }
159
160 pub const fn max_cumulative_body_bytes(self) -> u64 {
162 self.max_cumulative_body_bytes
163 }
164
165 pub const fn max_response_body_bytes(self) -> usize {
167 self.max_response_body_bytes
168 }
169}
170
171impl Default for DiscoveryLimits {
172 fn default() -> Self {
173 Self::new()
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct VerificationLimits {
185 max_requests: u32,
186 request_timeout: Duration,
187 max_wall_time: Duration,
188 max_cumulative_body_bytes: u64,
189 max_response_body_bytes: usize,
190}
191
192impl VerificationLimits {
193 pub const fn new() -> Self {
195 Self {
196 max_requests: DEFAULT_MAX_VERIFICATION_REQUESTS,
197 request_timeout: DEFAULT_VERIFICATION_REQUEST_TIMEOUT,
198 max_wall_time: DEFAULT_MAX_VERIFICATION_WALL_TIME,
199 max_cumulative_body_bytes: DEFAULT_MAX_VERIFICATION_RESPONSE_BYTES,
200 max_response_body_bytes: DEFAULT_MAX_VERIFICATION_RESPONSE_BODY_BYTES,
201 }
202 }
203
204 pub fn with_max_requests(mut self, limit: u32) -> Result<Self, ScannerError> {
206 if limit > MAX_VERIFICATION_REQUESTS {
207 return Err(ScannerError::InvalidVerificationLimits);
208 }
209 self.max_requests = limit;
210 Ok(self)
211 }
212
213 pub fn with_request_timeout(mut self, limit: Duration) -> Result<Self, ScannerError> {
215 if limit < Duration::from_millis(1) {
216 return Err(ScannerError::InvalidVerificationLimits);
217 }
218 self.request_timeout = limit;
219 Ok(self)
220 }
221
222 pub fn with_max_wall_time(mut self, limit: Duration) -> Result<Self, ScannerError> {
224 if limit < Duration::from_millis(1) {
225 return Err(ScannerError::InvalidVerificationLimits);
226 }
227 self.max_wall_time = limit;
228 Ok(self)
229 }
230
231 pub fn with_body_limits(
233 mut self,
234 cumulative: u64,
235 per_response: usize,
236 ) -> Result<Self, ScannerError> {
237 let per_response_u64 =
238 u64::try_from(per_response).map_err(|_| ScannerError::InvalidVerificationLimits)?;
239 if cumulative == 0
240 || per_response == 0
241 || per_response > MAX_HTTP_BODY_LIMIT
242 || per_response_u64 > cumulative
243 {
244 return Err(ScannerError::InvalidVerificationLimits);
245 }
246 self.max_cumulative_body_bytes = cumulative;
247 self.max_response_body_bytes = per_response;
248 Ok(self)
249 }
250
251 pub const fn max_requests(self) -> u32 {
253 self.max_requests
254 }
255
256 pub const fn request_timeout(self) -> Duration {
258 self.request_timeout
259 }
260
261 pub const fn max_wall_time(self) -> Duration {
263 self.max_wall_time
264 }
265
266 pub const fn max_cumulative_body_bytes(self) -> u64 {
268 self.max_cumulative_body_bytes
269 }
270
271 pub const fn max_response_body_bytes(self) -> usize {
273 self.max_response_body_bytes
274 }
275}
276
277impl Default for VerificationLimits {
278 fn default() -> Self {
279 Self::new()
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
285#[non_exhaustive]
286pub enum DiscoveryFormMethod {
287 Get,
289 Post,
291 Dialog,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
297pub struct DiscoveryForm {
298 action: Url,
299 method: DiscoveryFormMethod,
300 controls: BTreeSet<String>,
301}
302
303impl DiscoveryForm {
304 pub(crate) fn new(
306 action: Url,
307 method: DiscoveryFormMethod,
308 controls: impl IntoIterator<Item = String>,
309 ) -> Result<Self, ScannerError> {
310 let controls = controls.into_iter().collect::<BTreeSet<_>>();
311 if action.as_str().len() > MAX_DISCOVERY_URL_BYTES
312 || controls.len() > MAX_DISCOVERY_CONTROLS_PER_FORM
313 || controls
314 .iter()
315 .any(|name| name.is_empty() || name.len() > MAX_DISCOVERY_NAME_BYTES)
316 {
317 return Err(ScannerError::DiscoveryStateLimitExceeded);
318 }
319 Ok(Self {
320 action,
321 method,
322 controls,
323 })
324 }
325
326 pub fn action(&self) -> &Url {
328 &self.action
329 }
330
331 pub const fn method(&self) -> DiscoveryFormMethod {
333 self.method
334 }
335
336 pub const fn controls(&self) -> &BTreeSet<String> {
338 &self.controls
339 }
340}
341
342#[derive(Debug, Default)]
344pub(crate) struct DiscoveryDelta {
345 endpoints: BTreeMap<String, BTreeSet<String>>,
346 visited: BTreeSet<String>,
347 forms: BTreeSet<DiscoveryForm>,
348}
349
350impl DiscoveryDelta {
351 pub(crate) fn new() -> Self {
352 Self::default()
353 }
354
355 pub(crate) fn record_endpoint(
356 &mut self,
357 url: Url,
358 parameters: impl IntoIterator<Item = String>,
359 ) {
360 self.endpoints
361 .entry(url.to_string())
362 .or_default()
363 .extend(parameters);
364 }
365
366 pub(crate) fn record_visited(&mut self, url: Url) {
367 self.visited.insert(url.to_string());
368 }
369
370 pub(crate) fn record_form(&mut self, form: DiscoveryForm) {
371 self.forms.insert(form);
372 }
373}
374
375#[derive(Debug, Clone, Default, PartialEq, Eq)]
377pub(crate) struct DiscoverySnapshot {
378 endpoints: BTreeMap<String, BTreeSet<String>>,
379 visited: BTreeSet<String>,
380 forms: BTreeSet<DiscoveryForm>,
381}
382
383impl DiscoverySnapshot {
384 pub(crate) const fn endpoints(&self) -> &BTreeMap<String, BTreeSet<String>> {
385 &self.endpoints
386 }
387
388 pub(crate) const fn visited(&self) -> &BTreeSet<String> {
389 &self.visited
390 }
391
392 pub(crate) const fn forms(&self) -> &BTreeSet<DiscoveryForm> {
393 &self.forms
394 }
395
396 pub(crate) fn merge_endpoint(
397 &mut self,
398 url: Url,
399 parameters: impl IntoIterator<Item = String>,
400 ) {
401 if self.endpoints.len() >= MAX_DISCOVERY_ENDPOINTS
402 && !self.endpoints.contains_key(url.as_str())
403 {
404 return;
405 }
406 if url.as_str().len() > MAX_DISCOVERY_URL_BYTES {
407 return;
408 }
409 let total_before = self.endpoints.values().map(BTreeSet::len).sum::<usize>();
410 let entry = self.endpoints.entry(url.to_string()).or_default();
411 let global_room = MAX_DISCOVERY_TOTAL_PARAMETERS.saturating_sub(total_before);
412 let endpoint_room = MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT.saturating_sub(entry.len());
413 let mut merged = parameters.into_iter().collect::<BTreeSet<_>>();
414 merged.extend(url.query_pairs().filter_map(|(name, _)| {
415 (!name.is_empty() && name.len() <= MAX_DISCOVERY_NAME_BYTES).then(|| name.into_owned())
416 }));
417 for name in merged
418 .into_iter()
419 .filter(|name| !name.is_empty() && name.len() <= MAX_DISCOVERY_NAME_BYTES)
420 .take(global_room.min(endpoint_room))
421 {
422 entry.insert(name);
423 }
424 }
425
426 pub(crate) fn merge_visited(&mut self, url: Url) {
427 if url.as_str().len() <= MAX_DISCOVERY_URL_BYTES
428 && (self.visited.len() < MAX_DISCOVERY_ENDPOINTS || self.visited.contains(url.as_str()))
429 {
430 self.visited.insert(url.to_string());
431 }
432 }
433}
434
435#[derive(Debug, Default)]
436struct DiscoveryState {
437 snapshot: DiscoverySnapshot,
438 started: Option<Instant>,
439}
440
441#[derive(Debug, Default)]
442struct VerificationState {
443 started: Option<Instant>,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq)]
448pub(crate) struct BoundedHttpResponse {
449 request_url: Url,
450 final_url: Url,
451 status: u16,
452 headers: BTreeMap<String, String>,
453 body: Vec<u8>,
454 body_truncated: bool,
455}
456
457impl BoundedHttpResponse {
458 pub(crate) const fn request_url(&self) -> &Url {
459 &self.request_url
460 }
461
462 pub(crate) const fn final_url(&self) -> &Url {
463 &self.final_url
464 }
465
466 pub(crate) const fn status(&self) -> u16 {
467 self.status
468 }
469
470 pub(crate) fn header(&self, name: &str) -> Option<&str> {
471 self.headers
472 .get(&name.to_ascii_lowercase())
473 .map(String::as_str)
474 }
475
476 pub(crate) fn content_type(&self) -> Option<&str> {
477 self.header("content-type")
478 }
479
480 pub(crate) fn location(&self) -> Option<&str> {
481 self.header("location")
482 }
483
484 pub(crate) fn body(&self) -> &[u8] {
485 &self.body
486 }
487
488 pub(crate) const fn body_truncated(&self) -> bool {
489 self.body_truncated
490 }
491}
492
493#[derive(Clone)]
494pub(crate) struct LegacyDiscoveryAuthority {
495 limits: DiscoveryLimits,
496 authorized_origin: url::Origin,
497 broker: Option<HttpRequestBroker>,
498 cancellation: tokio_util::sync::CancellationToken,
499 state: Arc<Mutex<DiscoveryState>>,
500}
501
502#[derive(Clone)]
505pub(crate) struct LegacyVerificationAuthority {
506 limits: VerificationLimits,
507 authorized_origin: url::Origin,
508 broker: Option<HttpRequestBroker>,
509 #[cfg(test)]
510 accounting: RequestAccountingBroker,
511 cancellation: tokio_util::sync::CancellationToken,
512 state: Arc<Mutex<VerificationState>>,
513}
514
515impl LegacyVerificationAuthority {
516 pub(crate) fn new(
517 target: &Url,
518 limits: VerificationLimits,
519 cancellation: tokio_util::sync::CancellationToken,
520 ) -> Self {
521 let active_limit = u16::try_from(limits.max_requests).unwrap_or(u16::MAX);
522 let budget = RuntimeBudget::default()
523 .with_max_total_requests(limits.max_requests)
524 .with_max_active_verifications(active_limit)
525 .with_max_wall_time(limits.max_wall_time)
526 .with_max_response_bytes(limits.max_cumulative_body_bytes)
527 .with_max_request_body_bytes(0);
528 let accounting = RequestAccountingBroker::new(budget);
529 let policy = HttpEvidencePolicy::new(
530 [target.clone()],
531 limits.request_timeout,
532 limits.max_response_body_bytes,
533 );
534 let broker = policy
535 .and_then(|policy| HttpRequestBroker::new_metered(policy, accounting.clone()))
536 .ok();
537 Self {
538 limits,
539 authorized_origin: target.origin(),
540 broker,
541 #[cfg(test)]
542 accounting,
543 cancellation,
544 state: Arc::new(Mutex::new(VerificationState::default())),
545 }
546 }
547
548 pub(crate) const fn limits(&self) -> VerificationLimits {
549 self.limits
550 }
551
552 pub(crate) async fn request(
553 &self,
554 action_id: &str,
555 method: HttpProbeMethod,
556 url: Url,
557 ) -> Result<BoundedHttpResponse, ScannerError> {
558 if self.cancellation.is_cancelled() {
559 return Err(ScannerError::Cancelled);
560 }
561 let request_url = canonicalize_exact_origin(&url, &self.authorized_origin)?;
562 let remaining = self.remaining_wall_time(action_id)?;
563 let broker = self.broker.as_ref().ok_or(ScannerError::InvalidTarget)?;
564 let probe =
565 HttpProbe::new(request_url.clone(), method).map_err(|_| ScannerError::InvalidTarget)?;
566 let collect = broker.collect_for_runtime(
567 action_id,
568 DecisionExecutionStage::Active,
569 None,
570 DecisionExecutionLimits::new().with_max_response_body_bytes(
571 u64::try_from(self.limits.max_response_body_bytes).unwrap_or(u64::MAX),
572 ),
573 &probe,
574 );
575 let collected = tokio::select! {
576 biased;
577 _ = self.cancellation.cancelled() => return Err(ScannerError::Cancelled),
578 result = tokio::time::timeout(remaining, collect) => match result {
579 Ok(result) => result,
580 Err(_) => return Err(self.wall_limit(action_id, self.verification_elapsed())),
581 },
582 }
583 .map_err(map_broker_error)?;
584
585 bounded_response(request_url, collected)
586 }
587
588 pub(crate) fn ensure_commit_allowed(&self, action_id: &str) -> Result<(), ScannerError> {
589 if self.cancellation.is_cancelled() {
590 return Err(ScannerError::Cancelled);
591 }
592 self.remaining_wall_time(action_id).map(|_| ())
593 }
594
595 #[cfg(test)]
596 pub(crate) fn accounting_snapshot(&self) -> crate::runtime_budget::RequestAccountingSnapshot {
597 self.accounting.snapshot()
598 }
599
600 fn wall_limit(&self, action_id: &str, elapsed: Duration) -> ScannerError {
601 ScannerError::BudgetExceeded(RuntimeLimitExceeded::new(
602 RuntimeBudgetDimension::WallTime,
603 u64::try_from(self.limits.max_wall_time.as_millis()).unwrap_or(u64::MAX),
604 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
605 Some(action_id.to_owned()),
606 ))
607 }
608
609 fn remaining_wall_time(&self, action_id: &str) -> Result<Duration, ScannerError> {
610 let elapsed = {
611 let mut state = self.lock_state();
612 state.started.get_or_insert_with(Instant::now).elapsed()
613 };
614 if elapsed >= self.limits.max_wall_time {
615 return Err(self.wall_limit(action_id, elapsed));
616 }
617 Ok(self.limits.max_wall_time.saturating_sub(elapsed))
618 }
619
620 fn verification_elapsed(&self) -> Duration {
621 self.lock_state()
622 .started
623 .map_or(Duration::ZERO, |started| started.elapsed())
624 }
625
626 fn lock_state(&self) -> MutexGuard<'_, VerificationState> {
627 self.state
628 .lock()
629 .unwrap_or_else(std::sync::PoisonError::into_inner)
630 }
631}
632
633impl LegacyDiscoveryAuthority {
634 pub(crate) fn new(
635 target: &Url,
636 limits: DiscoveryLimits,
637 cancellation: tokio_util::sync::CancellationToken,
638 ) -> Self {
639 let budget = RuntimeBudget::default()
640 .with_max_total_requests(limits.max_requests)
641 .with_max_wall_time(limits.max_wall_time)
642 .with_max_response_bytes(limits.max_cumulative_body_bytes)
643 .with_max_request_body_bytes(0);
644 let accounting = RequestAccountingBroker::new(budget);
645 let policy = HttpEvidencePolicy::new(
646 [target.clone()],
647 limits.request_timeout,
648 limits.max_response_body_bytes,
649 );
650 let broker = policy
651 .and_then(|policy| HttpRequestBroker::new_metered(policy, accounting.clone()))
652 .ok();
653 let authority = Self {
654 limits,
655 authorized_origin: target.origin(),
656 broker,
657 cancellation,
658 state: Arc::new(Mutex::new(DiscoveryState::default())),
659 };
660 let mut root = DiscoveryDelta::new();
661 root.record_endpoint(
662 target.clone(),
663 target
664 .query_pairs()
665 .filter_map(|(name, _)| {
666 (!name.is_empty() && name.len() <= MAX_DISCOVERY_NAME_BYTES)
667 .then(|| name.into_owned())
668 })
669 .collect::<BTreeSet<_>>()
670 .into_iter()
671 .take(MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT),
672 );
673 let _ = authority.commit_initial(root);
674 authority
675 }
676
677 pub(crate) const fn limits(&self) -> DiscoveryLimits {
678 self.limits
679 }
680
681 pub(crate) fn canonicalize(&self, url: &Url) -> Result<Url, ScannerError> {
682 canonicalize_exact_origin(url, &self.authorized_origin)
683 }
684
685 pub(crate) async fn request(
686 &self,
687 action_id: &str,
688 method: HttpProbeMethod,
689 url: Url,
690 ) -> Result<BoundedHttpResponse, ScannerError> {
691 if self.cancellation.is_cancelled() {
692 return Err(ScannerError::Cancelled);
693 }
694 let request_url = self.canonicalize(&url)?;
695 let remaining = self.remaining_wall_time(action_id)?;
696 let broker = self.broker.as_ref().ok_or(ScannerError::InvalidTarget)?;
697 let probe =
698 HttpProbe::new(request_url.clone(), method).map_err(|_| ScannerError::InvalidTarget)?;
699 let collect = broker.collect_for_runtime(
700 action_id,
701 DecisionExecutionStage::Passive,
702 None,
703 DecisionExecutionLimits::new().with_max_response_body_bytes(
704 u64::try_from(self.limits.max_response_body_bytes).unwrap_or(u64::MAX),
705 ),
706 &probe,
707 );
708 let collected = tokio::select! {
709 biased;
710 _ = self.cancellation.cancelled() => return Err(ScannerError::Cancelled),
711 result = tokio::time::timeout(remaining, collect) => match result {
712 Ok(result) => result,
713 Err(_) => return Err(self.wall_limit(action_id, self.discovery_elapsed())),
714 },
715 }
716 .map_err(map_broker_error)?;
717
718 bounded_response(request_url, collected)
719 }
720
721 pub(crate) fn snapshot(&self) -> DiscoverySnapshot {
722 self.lock_state().snapshot.clone()
723 }
724
725 pub(crate) fn commit(
726 &self,
727 action_id: &str,
728 delta: DiscoveryDelta,
729 ) -> Result<DiscoverySnapshot, ScannerError> {
730 self.commit_inner(Some(action_id), delta)
731 }
732
733 fn commit_initial(&self, delta: DiscoveryDelta) -> Result<DiscoverySnapshot, ScannerError> {
734 self.commit_inner(None, delta)
735 }
736
737 fn commit_inner(
738 &self,
739 action_id: Option<&str>,
740 delta: DiscoveryDelta,
741 ) -> Result<DiscoverySnapshot, ScannerError> {
742 let mut state = self.lock_state();
743 let mut candidate = state.snapshot.clone();
744 for (raw_url, parameters) in delta.endpoints {
745 let url = self.canonicalize(&Url::parse(&raw_url)?)?;
746 let mut parameters = parameters;
747 if parameters.len() > MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT
748 || parameters
749 .iter()
750 .any(|name| name.is_empty() || name.len() > MAX_DISCOVERY_NAME_BYTES)
751 {
752 return Err(ScannerError::DiscoveryStateLimitExceeded);
753 }
754 for name in url
759 .query_pairs()
760 .filter_map(|(name, _)| {
761 (!name.is_empty() && name.len() <= MAX_DISCOVERY_NAME_BYTES)
762 .then(|| name.into_owned())
763 })
764 .collect::<BTreeSet<_>>()
765 {
766 if parameters.len() >= MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT {
767 break;
768 }
769 parameters.insert(name);
770 }
771 candidate
772 .endpoints
773 .entry(url.to_string())
774 .or_default()
775 .extend(parameters);
776 }
777 for raw_url in delta.visited {
778 let url = self.canonicalize(&Url::parse(&raw_url)?)?;
779 candidate.visited.insert(url.to_string());
780 }
781 for mut form in delta.forms {
782 form.action = self.canonicalize(&form.action)?;
783 candidate.forms.insert(form);
784 }
785 if candidate.endpoints.len() > MAX_DISCOVERY_ENDPOINTS
786 || candidate.visited.len() > MAX_DISCOVERY_ENDPOINTS
787 || candidate.forms.len() > MAX_DISCOVERY_FORMS
788 || candidate
789 .endpoints
790 .values()
791 .any(|parameters| parameters.len() > MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT)
792 || candidate
793 .endpoints
794 .values()
795 .map(BTreeSet::len)
796 .sum::<usize>()
797 > MAX_DISCOVERY_TOTAL_PARAMETERS
798 || candidate
799 .forms
800 .iter()
801 .map(|form| form.controls.len())
802 .sum::<usize>()
803 > MAX_DISCOVERY_TOTAL_FORM_CONTROLS
804 {
805 return Err(ScannerError::DiscoveryStateLimitExceeded);
806 }
807 if let (Some(action_id), Some(started)) = (action_id, state.started) {
808 let elapsed = started.elapsed();
809 if elapsed >= self.limits.max_wall_time {
810 return Err(self.wall_limit(action_id, elapsed));
811 }
812 }
813 if action_id.is_some() && self.cancellation.is_cancelled() {
814 return Err(ScannerError::Cancelled);
815 }
816 state.snapshot = candidate.clone();
817 Ok(candidate)
818 }
819
820 fn wall_limit(&self, action_id: &str, elapsed: Duration) -> ScannerError {
821 ScannerError::BudgetExceeded(RuntimeLimitExceeded::new(
822 RuntimeBudgetDimension::WallTime,
823 u64::try_from(self.limits.max_wall_time.as_millis()).unwrap_or(u64::MAX),
824 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
825 Some(action_id.to_owned()),
826 ))
827 }
828
829 fn remaining_wall_time(&self, action_id: &str) -> Result<Duration, ScannerError> {
830 let elapsed = {
831 let mut state = self.lock_state();
832 state.started.get_or_insert_with(Instant::now).elapsed()
833 };
834 if elapsed >= self.limits.max_wall_time {
835 return Err(self.wall_limit(action_id, elapsed));
836 }
837 Ok(self.limits.max_wall_time.saturating_sub(elapsed))
838 }
839
840 fn discovery_elapsed(&self) -> Duration {
841 self.lock_state()
842 .started
843 .map_or(Duration::ZERO, |started| started.elapsed())
844 }
845
846 fn lock_state(&self) -> MutexGuard<'_, DiscoveryState> {
847 self.state
848 .lock()
849 .unwrap_or_else(std::sync::PoisonError::into_inner)
850 }
851}
852
853fn canonicalize_exact_origin(
854 url: &Url,
855 authorized_origin: &url::Origin,
856) -> Result<Url, ScannerError> {
857 if !matches!(url.scheme(), "http" | "https")
858 || !url.username().is_empty()
859 || url.password().is_some()
860 || &url.origin() != authorized_origin
861 {
862 return Err(ScannerError::InvalidTarget);
863 }
864 let mut canonical = url.clone();
865 canonical.set_fragment(None);
866 if canonical.path().is_empty() {
867 canonical.set_path("/");
868 }
869 let pairs = canonical
870 .query_pairs()
871 .map(|(name, value)| (name.into_owned(), value.into_owned()))
872 .collect::<Vec<_>>();
873 if pairs.is_empty() {
874 canonical.set_query(None);
875 } else {
876 let mut names = HashSet::with_capacity(pairs.len());
877 if pairs.iter().all(|(name, _)| names.insert(name.clone())) {
878 let mut sorted = pairs;
879 sorted.sort();
880 canonical.query_pairs_mut().clear().extend_pairs(sorted);
881 }
882 }
883 if canonical.as_str().len() > MAX_DISCOVERY_URL_BYTES {
884 return Err(ScannerError::DiscoveryStateLimitExceeded);
885 }
886 Ok(canonical)
887}
888
889fn bounded_response(
890 request_url: Url,
891 collected: crate::http_evidence::CollectedHttpResponse,
892) -> Result<BoundedHttpResponse, ScannerError> {
893 let mut headers = BTreeMap::new();
894 for name in [
895 "content-type",
896 "location",
897 "cache-control",
898 "www-authenticate",
899 ] {
900 if let Some(value) = collected.header(name) {
901 headers.insert(name.to_owned(), value.to_owned());
902 }
903 }
904 Ok(BoundedHttpResponse {
905 request_url,
906 final_url: collected.final_url().clone(),
907 status: collected.status(),
908 headers,
909 body: collected.body().to_vec(),
910 body_truncated: collected.body_truncated(),
911 })
912}
913
914fn map_broker_error(error: HttpRequestBrokerError) -> ScannerError {
915 match error {
916 HttpRequestBrokerError::RuntimeLimit(limit) => ScannerError::BudgetExceeded(limit),
917 HttpRequestBrokerError::Http(_) => ScannerError::NetworkError(
918 "bounded legacy transport failed without accepting a result".to_owned(),
919 ),
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use tokio::{
926 io::{AsyncReadExt, AsyncWriteExt},
927 net::TcpListener,
928 };
929
930 use super::*;
931
932 #[test]
933 fn defaults_are_finite_and_coherent() {
934 let limits = DiscoveryLimits::default();
935 assert_eq!(limits.max_depth(), 4);
936 assert_eq!(limits.max_pages(), 64);
937 assert_eq!(limits.max_requests(), 64);
938 assert!(limits.max_response_body_bytes() as u64 <= limits.max_cumulative_body_bytes());
939 assert!(limits.request_timeout() <= limits.max_wall_time());
940 }
941
942 #[test]
943 fn invalid_limits_fail_closed() {
944 assert!(DiscoveryLimits::new()
945 .with_request_timeout(Duration::ZERO)
946 .is_err());
947 assert!(DiscoveryLimits::new()
948 .with_request_timeout(Duration::from_micros(999))
949 .is_err());
950 assert!(DiscoveryLimits::new()
951 .with_max_wall_time(Duration::from_micros(999))
952 .is_err());
953 assert!(DiscoveryLimits::new().with_body_limits(1, 2).is_err());
954 }
955
956 #[test]
957 fn verification_defaults_are_finite_and_coherent() {
958 let limits = VerificationLimits::default();
959 assert_eq!(limits.max_requests(), 96);
960 assert!(limits.max_response_body_bytes() as u64 <= limits.max_cumulative_body_bytes());
961 assert!(limits.request_timeout() <= limits.max_wall_time());
962 assert!(limits.max_requests() <= MAX_VERIFICATION_REQUESTS);
963 }
964
965 #[test]
966 fn invalid_verification_limits_fail_closed() {
967 assert!(VerificationLimits::new()
968 .with_max_requests(MAX_VERIFICATION_REQUESTS + 1)
969 .is_err());
970 assert!(VerificationLimits::new()
971 .with_request_timeout(Duration::ZERO)
972 .is_err());
973 assert!(VerificationLimits::new()
974 .with_request_timeout(Duration::from_micros(999))
975 .is_err());
976 assert!(VerificationLimits::new()
977 .with_max_wall_time(Duration::from_micros(999))
978 .is_err());
979 assert!(VerificationLimits::new().with_body_limits(1, 2).is_err());
980 }
981
982 #[test]
983 fn verification_wall_clock_starts_on_first_request_preflight() {
984 let target = Url::parse("https://example.test/").unwrap();
985 let limits = VerificationLimits::new()
986 .with_max_wall_time(Duration::from_secs(1))
987 .unwrap();
988 let authority = LegacyVerificationAuthority::new(
989 &target,
990 limits,
991 tokio_util::sync::CancellationToken::new(),
992 );
993
994 std::thread::sleep(Duration::from_millis(10));
995
996 let remaining = authority
997 .remaining_wall_time("legacy.verification.test")
998 .unwrap();
999 assert!(remaining > Duration::from_millis(900));
1000 }
1001
1002 #[test]
1003 fn verification_commit_guard_rechecks_cancellation() {
1004 let target = Url::parse("https://example.test/").unwrap();
1005 let cancellation = tokio_util::sync::CancellationToken::new();
1006 let authority = LegacyVerificationAuthority::new(
1007 &target,
1008 VerificationLimits::default(),
1009 cancellation.clone(),
1010 );
1011 authority
1012 .remaining_wall_time("legacy.verification.test")
1013 .unwrap();
1014 cancellation.cancel();
1015
1016 assert!(matches!(
1017 authority.ensure_commit_allowed("legacy.verification.test"),
1018 Err(ScannerError::Cancelled)
1019 ));
1020 }
1021
1022 #[test]
1023 fn verification_commit_guard_rechecks_wall_deadline() {
1024 let target = Url::parse("https://example.test/").unwrap();
1025 let authority = LegacyVerificationAuthority::new(
1026 &target,
1027 VerificationLimits::new()
1028 .with_max_wall_time(Duration::from_millis(1))
1029 .unwrap(),
1030 tokio_util::sync::CancellationToken::new(),
1031 );
1032 authority
1033 .remaining_wall_time("legacy.verification.test")
1034 .unwrap();
1035 std::thread::sleep(Duration::from_millis(5));
1036
1037 assert!(matches!(
1038 authority.ensure_commit_allowed("legacy.verification.test"),
1039 Err(ScannerError::BudgetExceeded(limit))
1040 if limit.dimension() == RuntimeBudgetDimension::WallTime
1041 ));
1042 }
1043
1044 #[tokio::test]
1045 async fn verification_scope_rejection_precedes_transport() {
1046 let target = Url::parse("https://example.test/").unwrap();
1047 let authority = LegacyVerificationAuthority::new(
1048 &target,
1049 VerificationLimits::default(),
1050 tokio_util::sync::CancellationToken::new(),
1051 );
1052
1053 for rejected in [
1054 Url::parse("https://other.test/").unwrap(),
1055 Url::parse("https://user:secret@example.test/").unwrap(),
1056 Url::parse("file:///tmp/not-http").unwrap(),
1057 ] {
1058 let error = authority
1059 .request("legacy.verification.test", HttpProbeMethod::Get, rejected)
1060 .await
1061 .unwrap_err();
1062 assert!(matches!(error, ScannerError::InvalidTarget));
1063 }
1064 }
1065
1066 #[tokio::test]
1067 async fn zero_verification_request_budget_denies_before_transport() {
1068 let target = Url::parse("http://127.0.0.1:9/").unwrap();
1069 let authority = LegacyVerificationAuthority::new(
1070 &target,
1071 VerificationLimits::new().with_max_requests(0).unwrap(),
1072 tokio_util::sync::CancellationToken::new(),
1073 );
1074
1075 let error = authority
1076 .request("legacy.verification.test", HttpProbeMethod::Get, target)
1077 .await
1078 .unwrap_err();
1079
1080 assert!(matches!(error, ScannerError::BudgetExceeded(_)));
1081 }
1082
1083 #[tokio::test]
1084 async fn verification_dispatch_is_accounted_as_active_not_passive() {
1085 let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
1086 .await
1087 .unwrap();
1088 let target = Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
1089 let server = tokio::spawn(async move {
1090 let (mut stream, _) = listener.accept().await.unwrap();
1091 let mut request = vec![0_u8; 4096];
1092 let _ = stream.read(&mut request).await.unwrap();
1093 stream
1094 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
1095 .await
1096 .unwrap();
1097 });
1098 let authority = LegacyVerificationAuthority::new(
1099 &target,
1100 VerificationLimits::default(),
1101 tokio_util::sync::CancellationToken::new(),
1102 );
1103
1104 authority
1105 .request("legacy.verification.test", HttpProbeMethod::Get, target)
1106 .await
1107 .unwrap();
1108 server.await.unwrap();
1109
1110 let accounting = authority.accounting_snapshot();
1111 assert_eq!(accounting.total_requests(), 1);
1112 assert_eq!(accounting.active_verifications(), 1);
1113 assert_eq!(accounting.passive_requests(), 0);
1114 }
1115
1116 #[test]
1117 fn discovery_wall_clock_starts_on_first_discovery_request_preflight() {
1118 let target = Url::parse("https://example.test/").unwrap();
1119 let limits = DiscoveryLimits::new()
1120 .with_max_wall_time(Duration::from_secs(1))
1121 .unwrap();
1122 let authority = LegacyDiscoveryAuthority::new(
1123 &target,
1124 limits,
1125 tokio_util::sync::CancellationToken::new(),
1126 );
1127
1128 std::thread::sleep(Duration::from_millis(10));
1129
1130 let remaining = authority
1131 .remaining_wall_time("legacy.discovery.test")
1132 .unwrap();
1133 assert!(remaining > Duration::from_millis(900));
1134 }
1135
1136 #[test]
1137 fn discovery_wall_deadline_is_rechecked_before_state_commit() {
1138 let target = Url::parse("https://example.test/").unwrap();
1139 let limits = DiscoveryLimits::new()
1140 .with_max_wall_time(Duration::from_millis(1))
1141 .unwrap();
1142 let authority = LegacyDiscoveryAuthority::new(
1143 &target,
1144 limits,
1145 tokio_util::sync::CancellationToken::new(),
1146 );
1147 let before = authority.snapshot();
1148 let _ = authority
1149 .remaining_wall_time("legacy.discovery.test")
1150 .unwrap();
1151 std::thread::sleep(Duration::from_millis(5));
1152 let mut delta = DiscoveryDelta::new();
1153 delta.record_endpoint(
1154 Url::parse("https://example.test/after-deadline").unwrap(),
1155 std::iter::empty(),
1156 );
1157
1158 let error = authority
1159 .commit("legacy.discovery.test", delta)
1160 .unwrap_err();
1161
1162 assert!(matches!(error, ScannerError::BudgetExceeded(_)));
1163 assert_eq!(authority.snapshot(), before);
1164 }
1165
1166 #[test]
1167 fn oversized_parameter_batch_fails_atomically() {
1168 let target = Url::parse("https://example.test/").unwrap();
1169 let authority = LegacyDiscoveryAuthority::new(
1170 &target,
1171 DiscoveryLimits::default(),
1172 tokio_util::sync::CancellationToken::new(),
1173 );
1174 let before = authority.snapshot();
1175 let mut delta = DiscoveryDelta::new();
1176 delta.record_endpoint(
1177 Url::parse("https://example.test/bounded").unwrap(),
1178 (0..=MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT).map(|index| format!("p{index}")),
1179 );
1180
1181 let error = authority
1182 .commit("legacy.discovery.test", delta)
1183 .unwrap_err();
1184
1185 assert!(matches!(error, ScannerError::DiscoveryStateLimitExceeded));
1186 assert_eq!(authority.snapshot(), before);
1187 }
1188
1189 #[test]
1190 fn root_query_names_are_registered_with_the_authority() {
1191 let target = Url::parse("https://example.test/?known=1&mode=safe").unwrap();
1192 let authority = LegacyDiscoveryAuthority::new(
1193 &target,
1194 DiscoveryLimits::default(),
1195 tokio_util::sync::CancellationToken::new(),
1196 );
1197
1198 assert_eq!(
1199 authority.snapshot().endpoints()[target.as_str()],
1200 BTreeSet::from(["known".to_owned(), "mode".to_owned()])
1201 );
1202 }
1203
1204 #[test]
1205 fn query_heavy_root_is_registered_with_a_deterministic_bounded_name_sample() {
1206 let mut target = Url::parse("https://example.test/").unwrap();
1207 {
1208 let mut query = target.query_pairs_mut();
1209 for index in 0..=MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT {
1210 query.append_pair(&format!("p{index:03}"), "value");
1211 }
1212 }
1213 let authority = LegacyDiscoveryAuthority::new(
1214 &target,
1215 DiscoveryLimits::default(),
1216 tokio_util::sync::CancellationToken::new(),
1217 );
1218
1219 let snapshot = authority.snapshot();
1220 let parameters = snapshot
1221 .endpoints()
1222 .get(target.as_str())
1223 .expect("the root endpoint remains registered");
1224 assert_eq!(parameters.len(), MAX_DISCOVERY_PARAMETERS_PER_ENDPOINT);
1225 assert!(parameters.contains("p000"));
1226 assert!(parameters.contains("p255"));
1227 assert!(!parameters.contains("p256"));
1228 }
1229
1230 #[test]
1231 fn cancellation_before_commit_is_atomic() {
1232 let target = Url::parse("https://example.test/").unwrap();
1233 let cancellation = tokio_util::sync::CancellationToken::new();
1234 let authority = LegacyDiscoveryAuthority::new(
1235 &target,
1236 DiscoveryLimits::default(),
1237 cancellation.clone(),
1238 );
1239 let before = authority.snapshot();
1240 let mut delta = DiscoveryDelta::new();
1241 delta.record_endpoint(
1242 Url::parse("https://example.test/not-committed").unwrap(),
1243 std::iter::empty(),
1244 );
1245 cancellation.cancel();
1246
1247 let error = authority
1248 .commit("legacy.discovery.test", delta)
1249 .unwrap_err();
1250
1251 assert!(matches!(error, ScannerError::Cancelled));
1252 assert_eq!(authority.snapshot(), before);
1253 }
1254
1255 #[test]
1256 fn endpoint_query_names_are_derived_at_the_commit_boundary() {
1257 let target = Url::parse("https://example.test/").unwrap();
1258 let authority = LegacyDiscoveryAuthority::new(
1259 &target,
1260 DiscoveryLimits::default(),
1261 tokio_util::sync::CancellationToken::new(),
1262 );
1263 let endpoint = Url::parse("https://example.test/search?known=1").unwrap();
1264 let mut delta = DiscoveryDelta::new();
1265 delta.record_endpoint(endpoint.clone(), std::iter::empty());
1266
1267 let snapshot = authority.commit("legacy.discovery.test", delta).unwrap();
1268
1269 assert_eq!(
1270 snapshot.endpoints()[endpoint.as_str()],
1271 BTreeSet::from(["known".to_owned()])
1272 );
1273 }
1274}