Skip to main content

termivar_scanner/
legacy_discovery.rs

1//! Bounded transport and typed state for the opt-in legacy scanner.
2//!
3//! This is a migration boundary, not a second scanner runtime. Discovery
4//! phases two through four share one passive authority and verification phases
5//! five through nine share a distinct active authority. The two finite
6//! envelopes cannot consume or reset each other. Phase one and custom phases
7//! may still use the explicitly unmetered legacy client.
8
9use 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/// Host-configurable resource envelope for legacy discovery phases two to four.
51#[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    /// Creates the default finite discovery envelope.
64    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    /// Replaces the link-depth bound.
77    pub const fn with_max_depth(mut self, limit: usize) -> Self {
78        self.max_depth = limit;
79        self
80    }
81
82    /// Replaces the page bound, rejecting values beyond retained state.
83    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    /// Replaces the shared dispatch bound. Zero denies every request.
92    pub const fn with_max_requests(mut self, limit: u32) -> Self {
93        self.max_requests = limit;
94        self
95    }
96
97    /// Replaces the timeout for one request at millisecond precision.
98    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    /// Replaces the shared monotonic wall-clock bound at millisecond precision.
107    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    /// Replaces cumulative and per-response body ceilings together.
116    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    /// Returns the maximum link depth, where the root is depth zero.
136    pub const fn max_depth(self) -> usize {
137        self.max_depth
138    }
139
140    /// Returns the maximum number of crawler pages scheduled.
141    pub const fn max_pages(self) -> usize {
142        self.max_pages
143    }
144
145    /// Returns the maximum shared HTTP dispatch count.
146    pub const fn max_requests(self) -> u32 {
147        self.max_requests
148    }
149
150    /// Returns the timeout applied to one request and its bounded body read.
151    pub const fn request_timeout(self) -> Duration {
152        self.request_timeout
153    }
154
155    /// Returns the shared monotonic wall-clock allowance.
156    pub const fn max_wall_time(self) -> Duration {
157        self.max_wall_time
158    }
159
160    /// Returns the cumulative delivered response-body allowance.
161    pub const fn max_cumulative_body_bytes(self) -> u64 {
162        self.max_cumulative_body_bytes
163    }
164
165    /// Returns the retained body ceiling for one response.
166    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/// Host-configurable resource envelope shared by legacy phases five to nine.
178///
179/// The authority permits only bodyless, exact-origin requests through the
180/// redirect- and retry-disabled broker. It does not grant verifier transition
181/// authority: a completed request is still only a probe receipt or bounded
182/// observation until an existing verifier contract evaluates its evidence.
183#[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    /// Creates the default finite legacy-verification envelope.
194    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    /// Replaces the shared dispatch bound. Zero denies every request.
205    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    /// Replaces the timeout for one request at millisecond precision.
214    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    /// Replaces the shared monotonic wall-clock bound at millisecond precision.
223    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    /// Replaces cumulative and per-response body ceilings together.
232    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    /// Returns the maximum shared active-verification dispatch count.
252    pub const fn max_requests(self) -> u32 {
253        self.max_requests
254    }
255
256    /// Returns the timeout applied to one request and bounded body read.
257    pub const fn request_timeout(self) -> Duration {
258        self.request_timeout
259    }
260
261    /// Returns the shared monotonic wall-clock allowance.
262    pub const fn max_wall_time(self) -> Duration {
263        self.max_wall_time
264    }
265
266    /// Returns the cumulative delivered response-body allowance.
267    pub const fn max_cumulative_body_bytes(self) -> u64 {
268        self.max_cumulative_body_bytes
269    }
270
271    /// Returns the retained body ceiling for one response.
272    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/// Method semantics retained for one parser-tree-descendant HTML form.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
285#[non_exhaustive]
286pub enum DiscoveryFormMethod {
287    /// Missing, invalid, or explicitly GET form method.
288    Get,
289    /// Explicit POST form method. It is recorded but never converted to GET.
290    Post,
291    /// HTML dialog form method. It is recorded but never sent as HTTP.
292    Dialog,
293}
294
295/// Bounded parser-tree-descendant form observation.
296#[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    /// Creates a typed form observation after applying collection bounds.
305    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    /// Returns the canonical action URL.
327    pub fn action(&self) -> &Url {
328        &self.action
329    }
330
331    /// Returns the observed form method semantics.
332    pub const fn method(&self) -> DiscoveryFormMethod {
333        self.method
334    }
335
336    /// Returns sorted, unique named controls.
337    pub const fn controls(&self) -> &BTreeSet<String> {
338        &self.controls
339    }
340}
341
342/// Candidate discovery writes committed as one state transition.
343#[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/// Deterministic read snapshot used by later discovery phases.
376#[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/// Bounded response exposed to discovery semantics, not raw reqwest state.
447#[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/// Shared active-transport authority for corrected or quarantined legacy
503/// verification phases. It owns no claim state and cannot confirm findings.
504#[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            // URL-derived names are observations from the endpoint itself, not
755            // an unbounded caller batch. Retain their canonical lexical prefix
756            // within the per-endpoint ceiling so even a query-heavy root is
757            // still registered rather than disappearing on initialization.
758            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}