Skip to main content

termivar_scanner/web_runtime/
recon_ct_runtime.rs

1//! Bounded Cert Spotter provider execution under one web-assessment authority.
2//!
3//! Provider traffic is deliberately separate from the exact target origin, but
4//! every dispatch consumes the assessment's one request/byte/deadline/cancel
5//! authority. Returned CT names remain inert hypotheses and are never inserted
6//! into the assessment subject plan or target evidence store.
7
8use std::fmt;
9
10use reqwest::{header, Client, StatusCode};
11use url::{Host, Url};
12
13use super::SharedWebRuntimeAuthority;
14use crate::{
15    recon_ct_provider::{
16        CertSpotterPageOutcome, CertSpotterResponseAccumulator, CertSpotterResult,
17        CertSpotterTerminal, ReconCtProviderExecutionMode, ReconCtProviderPolicy,
18        MAX_CERT_SPOTTER_ELAPSED, MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES,
19        MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES,
20    },
21    DecisionExecutionStage, TransportDispatchOutcome,
22};
23
24const RECON_CT_PROVIDER_ACTION_ID: &str = "recon.certspotter.provider-page";
25const CERT_SPOTTER_ACCEPT: &str = "application/json";
26
27/// Stable, value-free classification for the first provider execution failure.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum ReconCtProviderFailureKind {
30    ClientInitialization,
31    RequestConstruction,
32    ParentAuthorityUnavailable,
33    Cancelled,
34    DeadlineReached,
35    TransportFailed,
36    RedirectRefused,
37    AccessRejected,
38    Throttled,
39    ProviderNotFound,
40    ServerFailure,
41    UnexpectedStatus,
42    UnsupportedMedia,
43    UnsupportedContentCoding,
44    ResponseTooLarge,
45    InvalidResponse,
46}
47
48impl ReconCtProviderFailureKind {
49    /// Stable report token; never contains a URL, domain, response, or error.
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::ClientInitialization => "client_initialization",
54            Self::RequestConstruction => "request_construction",
55            Self::ParentAuthorityUnavailable => "parent_authority_unavailable",
56            Self::Cancelled => "cancelled",
57            Self::DeadlineReached => "deadline_reached",
58            Self::TransportFailed => "transport_failed",
59            Self::RedirectRefused => "redirect_refused",
60            Self::AccessRejected => "access_rejected",
61            Self::Throttled => "throttled",
62            Self::ProviderNotFound => "provider_not_found",
63            Self::ServerFailure => "server_failure",
64            Self::UnexpectedStatus => "unexpected_status",
65            Self::UnsupportedMedia => "unsupported_media",
66            Self::UnsupportedContentCoding => "unsupported_content_coding",
67            Self::ResponseTooLarge => "response_too_large",
68            Self::InvalidResponse => "invalid_response",
69        }
70    }
71}
72
73/// Runtime-owned provider audit plus the transport-neutral parsed result.
74#[derive(Clone, Eq, PartialEq)]
75pub struct WebAssessmentReconCtProviderAudit {
76    result: CertSpotterResult,
77    request_attempt_count: usize,
78    request_admitted_count: usize,
79    response_completed_count: usize,
80    observed_response_bytes: u64,
81    first_failure: Option<ReconCtProviderFailureKind>,
82}
83
84impl fmt::Debug for WebAssessmentReconCtProviderAudit {
85    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86        formatter
87            .debug_struct("WebAssessmentReconCtProviderAudit")
88            .field("result", &"<redacted>")
89            .field("request_attempt_count", &self.request_attempt_count)
90            .field("request_admitted_count", &self.request_admitted_count)
91            .field("response_completed_count", &self.response_completed_count)
92            .field("observed_response_bytes", &self.observed_response_bytes)
93            .field("first_failure", &self.first_failure)
94            .finish()
95    }
96}
97
98impl WebAssessmentReconCtProviderAudit {
99    #[must_use]
100    pub const fn result(&self) -> &CertSpotterResult {
101        &self.result
102    }
103
104    #[must_use]
105    pub const fn request_attempt_count(&self) -> usize {
106        self.request_attempt_count
107    }
108
109    #[must_use]
110    pub const fn request_admitted_count(&self) -> usize {
111        self.request_admitted_count
112    }
113
114    #[must_use]
115    pub const fn response_completed_count(&self) -> usize {
116        self.response_completed_count
117    }
118
119    /// Bytes actually delivered to this child, including a bounded final
120    /// overrun chunk and responses that later failed parsing.
121    #[must_use]
122    pub const fn observed_response_bytes(&self) -> u64 {
123        self.observed_response_bytes
124    }
125
126    #[must_use]
127    pub const fn first_failure(&self) -> Option<ReconCtProviderFailureKind> {
128        self.first_failure
129    }
130
131    /// Returns whether transport accounting and the parser-owned bounded result
132    /// form one internally consistent provider execution receipt.
133    ///
134    /// This does not authenticate the provider or elevate any returned name to
135    /// target authority. It only protects the report composition boundary from
136    /// pairing unrelated counters and parsed results.
137    pub(crate) fn is_consistent(&self) -> bool {
138        use std::collections::BTreeSet;
139
140        use crate::recon_ct_provider::{
141            CertSpotterCompleteness, CertSpotterTerminal, MAX_CERT_SPOTTER_RESPONSE_PAGES,
142            MAX_CERT_SPOTTER_RETAINED_NAMES, MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES,
143        };
144
145        let audit = self.result.audit();
146        let mut names = BTreeSet::new();
147        let mut source_pairs = BTreeSet::new();
148        let identities_are_unique = self.result.hypotheses().iter().all(|hypothesis| {
149            names.insert(hypothesis.name())
150                && source_pairs.insert((hypothesis.name(), hypothesis.first_issuance_id()))
151        });
152        let parser_selected_terminal = matches!(
153            audit.terminal(),
154            CertSpotterTerminal::ProviderExhausted
155                | CertSpotterTerminal::HypothesisLimitReached
156                | CertSpotterTerminal::PageLimitReached
157        );
158        let failure_matches_terminal = match self.first_failure {
159            None => parser_selected_terminal,
160            Some(ReconCtProviderFailureKind::Cancelled) => {
161                audit.terminal() == CertSpotterTerminal::Cancelled
162            },
163            Some(ReconCtProviderFailureKind::DeadlineReached) => {
164                audit.terminal() == CertSpotterTerminal::DeadlineReached
165            },
166            Some(ReconCtProviderFailureKind::ParentAuthorityUnavailable) => {
167                audit.terminal() == CertSpotterTerminal::ParentAuthorityUnavailable
168            },
169            Some(_) => audit.terminal() == CertSpotterTerminal::TransportFailed,
170        };
171
172        self.request_admitted_count <= self.request_attempt_count
173            && self.request_attempt_count <= MAX_CERT_SPOTTER_RESPONSE_PAGES
174            && self.response_completed_count <= self.request_admitted_count
175            && audit.page_count() <= self.response_completed_count
176            && audit.page_count() <= MAX_CERT_SPOTTER_RESPONSE_PAGES
177            && audit.response_bytes() <= MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES
178            && u64::try_from(audit.response_bytes())
179                .is_ok_and(|accepted| accepted <= self.observed_response_bytes)
180            && audit.retained_name_count() == self.result.hypotheses().len()
181            && audit.retained_name_count() <= MAX_CERT_SPOTTER_RETAINED_NAMES
182            && identities_are_unique
183            && audit.completeness() == audit.terminal().completeness()
184            && failure_matches_terminal
185            && (self.first_failure.is_none()
186                || audit.completeness() == CertSpotterCompleteness::Unknown)
187    }
188}
189
190/// Validates the provider child authority against the selected assessment.
191///
192/// Production queries are bound to the exact target host. Owned loopback mode
193/// is a test-only transport exception with its own explicit query declaration.
194/// In every mode provider and target exact origins must remain distinct.
195pub fn recon_ct_provider_scope_is_valid(policy: &ReconCtProviderPolicy, target: &Url) -> bool {
196    let Ok(provider_origin) = Url::parse(policy.provider_origin()) else {
197        return false;
198    };
199    if same_origin(&provider_origin, target) {
200        return false;
201    }
202    match policy.execution_mode() {
203        ReconCtProviderExecutionMode::Production => target
204            .host_str()
205            .is_some_and(|host| host.eq_ignore_ascii_case(policy.query_domain())),
206        ReconCtProviderExecutionMode::OwnedLoopbackFixture => {
207            target.scheme() == "http"
208                && target.port().is_some_and(|port| port != 0)
209                && target.username().is_empty()
210                && target.password().is_none()
211                && match target.host() {
212                    Some(Host::Ipv4(address)) => address.is_loopback(),
213                    Some(Host::Ipv6(address)) => address.is_loopback(),
214                    Some(Host::Domain(_)) | None => false,
215                }
216        },
217    }
218}
219
220/// Executes at most two sequential provider requests under the shared parent.
221pub(super) async fn execute_recon_ct_provider(
222    policy: ReconCtProviderPolicy,
223    target: &Url,
224    authority: &SharedWebRuntimeAuthority,
225) -> WebAssessmentReconCtProviderAudit {
226    let mut accumulator = CertSpotterResponseAccumulator::for_policy(&policy);
227    let mut accounting = ProviderAccounting::default();
228
229    if !recon_ct_provider_scope_is_valid(&policy, target) {
230        return finalize(
231            accumulator,
232            CertSpotterTerminal::ParentAuthorityUnavailable,
233            accounting,
234            Some(ReconCtProviderFailureKind::ParentAuthorityUnavailable),
235        );
236    }
237
238    let client = match Client::builder()
239        .no_proxy()
240        .redirect(reqwest::redirect::Policy::none())
241        .retry(reqwest::retry::never())
242        .user_agent(concat!(
243            "termivar/",
244            env!("CARGO_PKG_VERSION"),
245            " cert-spotter-recon"
246        ))
247        .build()
248    {
249        Ok(client) => client,
250        Err(_) => {
251            return finalize(
252                accumulator,
253                CertSpotterTerminal::TransportFailed,
254                accounting,
255                Some(ReconCtProviderFailureKind::ClientInitialization),
256            );
257        },
258    };
259
260    let parent_timing = authority.start();
261    let local_deadline = tokio::time::Instant::now()
262        .checked_add(MAX_CERT_SPOTTER_ELAPSED)
263        .unwrap_or_else(tokio::time::Instant::now);
264    let deadline = parent_timing
265        .deadline()
266        .map_or(local_deadline, |parent| parent.min(local_deadline));
267
268    loop {
269        if authority.cancellation().is_cancelled() {
270            return finalize(
271                accumulator,
272                CertSpotterTerminal::Cancelled,
273                accounting,
274                Some(ReconCtProviderFailureKind::Cancelled),
275            );
276        }
277        if tokio::time::Instant::now() >= deadline {
278            return finalize(
279                accumulator,
280                CertSpotterTerminal::DeadlineReached,
281                accounting,
282                Some(ReconCtProviderFailureKind::DeadlineReached),
283            );
284        }
285
286        accounting.request_attempt_count = accounting.request_attempt_count.saturating_add(1);
287        let preflight = match authority
288            .request_accounting()
289            .preflight(RECON_CT_PROVIDER_ACTION_ID, DecisionExecutionStage::Passive)
290        {
291            Ok(preflight) if preflight.remaining_response_bytes() > 0 => preflight,
292            Ok(_) | Err(_) => {
293                return finalize(
294                    accumulator,
295                    CertSpotterTerminal::ParentAuthorityUnavailable,
296                    accounting,
297                    Some(ReconCtProviderFailureKind::ParentAuthorityUnavailable),
298                );
299            },
300        };
301        debug_assert!(preflight.remaining_response_bytes() > 0);
302        let expected_after = accumulator.next_after().map(ToOwned::to_owned);
303        let request_url = match policy.build_request_url(expected_after.as_deref()) {
304            Ok(url) if request_matches_policy(&url, &policy, expected_after.as_deref()) => url,
305            _ => {
306                return finalize(
307                    accumulator,
308                    CertSpotterTerminal::TransportFailed,
309                    accounting,
310                    Some(ReconCtProviderFailureKind::RequestConstruction),
311                );
312            },
313        };
314        let mut lease = match authority
315            .request_accounting()
316            .try_begin_with_request_body_bytes(
317                RECON_CT_PROVIDER_ACTION_ID,
318                DecisionExecutionStage::Passive,
319                None,
320                0,
321            ) {
322            Ok(lease) => lease,
323            Err(_) => {
324                return finalize(
325                    accumulator,
326                    CertSpotterTerminal::ParentAuthorityUnavailable,
327                    accounting,
328                    Some(ReconCtProviderFailureKind::ParentAuthorityUnavailable),
329                );
330            },
331        };
332        accounting.request_admitted_count = accounting.request_admitted_count.saturating_add(1);
333
334        let request = client
335            .get(request_url.clone())
336            .header(header::ACCEPT, CERT_SPOTTER_ACCEPT)
337            .header(header::ACCEPT_ENCODING, "identity");
338        let response = tokio::select! {
339            biased;
340            () = authority.cancellation().cancelled() => {
341                lease.finish(TransportDispatchOutcome::Cancelled);
342                return finalize(
343                    accumulator,
344                    CertSpotterTerminal::Cancelled,
345                    accounting,
346                    Some(ReconCtProviderFailureKind::Cancelled),
347                );
348            }
349            result = tokio::time::timeout_at(deadline, request.send()) => match result {
350                Err(_) => {
351                    lease.finish(TransportDispatchOutcome::RequestTimeout);
352                    return finalize(
353                        accumulator,
354                        CertSpotterTerminal::DeadlineReached,
355                        accounting,
356                        Some(ReconCtProviderFailureKind::DeadlineReached),
357                    );
358                }
359                Ok(Err(_)) => {
360                    lease.finish(TransportDispatchOutcome::TransportFailure);
361                    return finalize(
362                        accumulator,
363                        CertSpotterTerminal::TransportFailed,
364                        accounting,
365                        Some(ReconCtProviderFailureKind::TransportFailed),
366                    );
367                }
368                Ok(Ok(response)) => response,
369            }
370        };
371
372        if response.url() != &request_url {
373            lease.finish(TransportDispatchOutcome::TransportFailure);
374            return finalize(
375                accumulator,
376                CertSpotterTerminal::TransportFailed,
377                accounting,
378                Some(ReconCtProviderFailureKind::RedirectRefused),
379            );
380        }
381        if response.status() != StatusCode::OK {
382            let failure = status_failure(response.status());
383            lease.finish(TransportDispatchOutcome::TransportFailure);
384            return finalize(
385                accumulator,
386                CertSpotterTerminal::TransportFailed,
387                accounting,
388                Some(failure),
389            );
390        }
391        if !json_content_type(response.headers()) {
392            lease.finish(TransportDispatchOutcome::TransportFailure);
393            return finalize(
394                accumulator,
395                CertSpotterTerminal::TransportFailed,
396                accounting,
397                Some(ReconCtProviderFailureKind::UnsupportedMedia),
398            );
399        }
400        if !identity_content_coding(response.headers()) {
401            lease.finish(TransportDispatchOutcome::TransportFailure);
402            return finalize(
403                accumulator,
404                CertSpotterTerminal::TransportFailed,
405                accounting,
406                Some(ReconCtProviderFailureKind::UnsupportedContentCoding),
407            );
408        }
409        let remaining_total =
410            MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES.saturating_sub(accumulator.response_bytes());
411        if response.content_length().is_some_and(|length| {
412            length > MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES as u64 || length > remaining_total as u64
413        }) {
414            // This is the provider child's own representation ceiling, not the
415            // shared parent response-byte boundary.
416            lease.finish(TransportDispatchOutcome::TransportFailure);
417            return finalize(
418                accumulator,
419                CertSpotterTerminal::TransportFailed,
420                accounting,
421                Some(ReconCtProviderFailureKind::ResponseTooLarge),
422            );
423        }
424
425        let mut response = response;
426        let mut page = Vec::with_capacity(
427            response
428                .content_length()
429                .and_then(|length| usize::try_from(length).ok())
430                .unwrap_or(0)
431                .min(MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES)
432                .min(remaining_total),
433        );
434        loop {
435            // One shared read gate bounds aggregate overrun to one delivered
436            // chunk even if another child transport is active.
437            let _read_guard = lease.acquire_response_read().await;
438            if lease.remaining_response_bytes() == 0 {
439                lease.finish(TransportDispatchOutcome::ResponseBudgetReached);
440                return finalize(
441                    accumulator,
442                    CertSpotterTerminal::ParentAuthorityUnavailable,
443                    accounting,
444                    Some(ReconCtProviderFailureKind::ParentAuthorityUnavailable),
445                );
446            }
447            let chunk = tokio::select! {
448                biased;
449                () = authority.cancellation().cancelled() => {
450                    lease.finish(TransportDispatchOutcome::Cancelled);
451                    return finalize(
452                        accumulator,
453                        CertSpotterTerminal::Cancelled,
454                        accounting,
455                        Some(ReconCtProviderFailureKind::Cancelled),
456                    );
457                }
458                result = tokio::time::timeout_at(deadline, response.chunk()) => match result {
459                    Err(_) => {
460                        lease.finish(TransportDispatchOutcome::RequestTimeout);
461                        return finalize(
462                            accumulator,
463                            CertSpotterTerminal::DeadlineReached,
464                            accounting,
465                            Some(ReconCtProviderFailureKind::DeadlineReached),
466                        );
467                    }
468                    Ok(Err(_)) => {
469                        lease.finish(TransportDispatchOutcome::TransportFailure);
470                        return finalize(
471                            accumulator,
472                            CertSpotterTerminal::TransportFailed,
473                            accounting,
474                            Some(ReconCtProviderFailureKind::TransportFailed),
475                        );
476                    }
477                    Ok(Ok(chunk)) => chunk,
478                }
479            };
480            let Some(chunk) = chunk else {
481                accounting.response_completed_count =
482                    accounting.response_completed_count.saturating_add(1);
483                // Every delivered byte was retained and end-of-body was
484                // observed. A short parent retention allowance is handled in
485                // the chunk branch below as a shared-budget terminal.
486                lease.finish(TransportDispatchOutcome::Completed);
487                break;
488            };
489
490            let observed = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
491            accounting.observed_response_bytes =
492                accounting.observed_response_bytes.saturating_add(observed);
493            let parent_retained = lease.observe_response_bytes(observed);
494            let page_remaining = MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES.saturating_sub(page.len());
495            let collection_remaining = remaining_total.saturating_sub(page.len());
496            let local_retained = page_remaining.min(collection_remaining);
497            let retained = usize::try_from(parent_retained)
498                .unwrap_or(usize::MAX)
499                .min(local_retained)
500                .min(chunk.len());
501            page.extend_from_slice(&chunk[..retained]);
502            if retained < chunk.len() {
503                let (dispatch_outcome, terminal, failure) =
504                    retention_failure(parent_retained, observed);
505                lease.finish(dispatch_outcome);
506                return finalize(accumulator, terminal, accounting, Some(failure));
507            }
508        }
509
510        let outcome = match accumulator.ingest_page(&page) {
511            Ok(outcome) => outcome,
512            Err(_) => {
513                return finalize(
514                    accumulator,
515                    CertSpotterTerminal::TransportFailed,
516                    accounting,
517                    Some(ReconCtProviderFailureKind::InvalidResponse),
518                );
519            },
520        };
521        match outcome {
522            CertSpotterPageOutcome::Continue { .. } => {},
523            CertSpotterPageOutcome::Terminal(terminal) => {
524                return finalize(accumulator, terminal, accounting, None);
525            },
526        }
527    }
528}
529
530#[derive(Default)]
531struct ProviderAccounting {
532    request_attempt_count: usize,
533    request_admitted_count: usize,
534    response_completed_count: usize,
535    observed_response_bytes: u64,
536}
537
538fn finalize(
539    accumulator: CertSpotterResponseAccumulator,
540    terminal: CertSpotterTerminal,
541    accounting: ProviderAccounting,
542    first_failure: Option<ReconCtProviderFailureKind>,
543) -> WebAssessmentReconCtProviderAudit {
544    let result = accumulator
545        .into_result(terminal)
546        .expect("runtime preserves the parser-selected Cert Spotter terminal");
547    WebAssessmentReconCtProviderAudit {
548        result,
549        request_attempt_count: accounting.request_attempt_count,
550        request_admitted_count: accounting.request_admitted_count,
551        response_completed_count: accounting.response_completed_count,
552        observed_response_bytes: accounting.observed_response_bytes,
553        first_failure,
554    }
555}
556
557fn request_matches_policy(
558    url: &Url,
559    policy: &ReconCtProviderPolicy,
560    expected_after: Option<&str>,
561) -> bool {
562    let Ok(origin) = Url::parse(policy.provider_origin()) else {
563        return false;
564    };
565    let Ok(expected_url) = policy.build_request_url(expected_after) else {
566        return false;
567    };
568    let mut expected_query = vec![
569        ("domain".to_owned(), policy.query_domain().to_owned()),
570        ("expand".to_owned(), "dns_names".to_owned()),
571    ];
572    if let Some(after) = expected_after {
573        expected_query.push(("after".to_owned(), after.to_owned()));
574    }
575    let actual_query = url
576        .query_pairs()
577        .map(|(name, value)| (name.into_owned(), value.into_owned()))
578        .collect::<Vec<_>>();
579
580    same_origin(url, &origin)
581        && url.path() == "/v1/issuances"
582        && url.fragment().is_none()
583        && url.username().is_empty()
584        && url.password().is_none()
585        && url.query() == expected_url.query()
586        && actual_query == expected_query
587}
588
589fn same_origin(left: &Url, right: &Url) -> bool {
590    left.scheme() == right.scheme()
591        && left.host() == right.host()
592        && left.port_or_known_default() == right.port_or_known_default()
593}
594
595fn retention_failure(
596    parent_retained: u64,
597    delivered: u64,
598) -> (
599    TransportDispatchOutcome,
600    CertSpotterTerminal,
601    ReconCtProviderFailureKind,
602) {
603    if parent_retained < delivered {
604        (
605            TransportDispatchOutcome::ResponseBudgetReached,
606            CertSpotterTerminal::ParentAuthorityUnavailable,
607            ReconCtProviderFailureKind::ParentAuthorityUnavailable,
608        )
609    } else {
610        (
611            TransportDispatchOutcome::TransportFailure,
612            CertSpotterTerminal::TransportFailed,
613            ReconCtProviderFailureKind::ResponseTooLarge,
614        )
615    }
616}
617
618fn json_content_type(headers: &header::HeaderMap) -> bool {
619    let mut values = headers.get_all(header::CONTENT_TYPE).iter();
620    let Some(value) = values.next() else {
621        return false;
622    };
623    values.next().is_none()
624        && value
625            .to_str()
626            .ok()
627            .filter(|value| !value.contains(','))
628            .and_then(|value| value.split(';').next())
629            .is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case(CERT_SPOTTER_ACCEPT))
630}
631
632fn identity_content_coding(headers: &header::HeaderMap) -> bool {
633    let mut values = headers.get_all(header::CONTENT_ENCODING).iter();
634    match (values.next(), values.next()) {
635        (None, None) => true,
636        (Some(value), None) => value
637            .to_str()
638            .is_ok_and(|value| value.trim().eq_ignore_ascii_case("identity")),
639        _ => false,
640    }
641}
642
643fn status_failure(status: StatusCode) -> ReconCtProviderFailureKind {
644    if status.is_redirection() {
645        ReconCtProviderFailureKind::RedirectRefused
646    } else if status.as_u16() == 401 || status.as_u16() == 403 {
647        ReconCtProviderFailureKind::AccessRejected
648    } else if status.as_u16() == 404 {
649        ReconCtProviderFailureKind::ProviderNotFound
650    } else if status.as_u16() == 429 {
651        ReconCtProviderFailureKind::Throttled
652    } else if status.is_server_error() {
653        ReconCtProviderFailureKind::ServerFailure
654    } else {
655        ReconCtProviderFailureKind::UnexpectedStatus
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use crate::recon_ct_provider::parse_recon_ct_provider_policy;
663
664    fn policy(mode: &str, origin: Option<&str>) -> ReconCtProviderPolicy {
665        let origin = origin
666            .map(|value| format!(",\"provider_origin\":\"{value}\""))
667            .unwrap_or_default();
668        parse_recon_ct_provider_policy(
669            format!(
670                r#"{{"schema":"security.recon-certspotter-policy/v1","revision":"rev-1","query_domain":"example.test","query_reference":"query-1","execution_mode":"{mode}","provider_use_authorized":true,"privacy_disclosure_acknowledged":true{origin}}}"#
671            )
672            .as_bytes(),
673        )
674        .unwrap()
675    }
676
677    #[test]
678    fn production_scope_requires_exact_target_host_and_separate_origin() {
679        let policy = policy("production", None);
680        assert!(recon_ct_provider_scope_is_valid(
681            &policy,
682            &Url::parse("https://example.test/").unwrap()
683        ));
684        assert!(!recon_ct_provider_scope_is_valid(
685            &policy,
686            &Url::parse("https://other.test/").unwrap()
687        ));
688        assert!(!recon_ct_provider_scope_is_valid(
689            &parse_recon_ct_provider_policy(
690                br#"{"schema":"security.recon-certspotter-policy/v1","revision":"rev-1","query_domain":"api.certspotter.com","query_reference":"query-1","execution_mode":"production","provider_use_authorized":true,"privacy_disclosure_acknowledged":true}"#
691            )
692            .unwrap(),
693            &Url::parse("https://api.certspotter.com/").unwrap()
694        ));
695    }
696
697    #[test]
698    fn loopback_fixture_keeps_provider_and_target_exact_origins_separate() {
699        let policy = policy("owned_loopback_fixture", Some("http://127.0.0.1:39002"));
700        assert!(recon_ct_provider_scope_is_valid(
701            &policy,
702            &Url::parse("http://127.0.0.1:39001/").unwrap()
703        ));
704        assert!(!recon_ct_provider_scope_is_valid(
705            &policy,
706            &Url::parse("http://127.0.0.1:39002/").unwrap()
707        ));
708        for outside_fixture_scope in [
709            "https://127.0.0.1:39001/",
710            "http://127.0.0.1/",
711            "http://localhost:39001/",
712            "http://192.0.2.1:39001/",
713            "https://example.test/",
714        ] {
715            assert!(
716                !recon_ct_provider_scope_is_valid(
717                    &policy,
718                    &Url::parse(outside_fixture_scope).unwrap()
719                ),
720                "fixture policy accepted target {outside_fixture_scope}"
721            );
722        }
723    }
724
725    #[test]
726    fn dispatch_guard_requires_exact_fixed_route_and_current_cursor() {
727        let policy = policy("owned_loopback_fixture", Some("http://127.0.0.1:39002"));
728        let first = policy.build_request_url(None).unwrap();
729        assert!(request_matches_policy(&first, &policy, None));
730        assert!(!request_matches_policy(&first, &policy, Some("opaque-1")));
731
732        let next = policy.build_request_url(Some("opaque-1")).unwrap();
733        assert!(request_matches_policy(&next, &policy, Some("opaque-1")));
734        assert!(!request_matches_policy(&next, &policy, None));
735
736        let mut extra = next.clone();
737        extra.query_pairs_mut().append_pair("unexpected", "value");
738        assert!(!request_matches_policy(&extra, &policy, Some("opaque-1")));
739
740        let encoded_name = Url::parse(
741            "http://127.0.0.1:39002/v1/issuances?dom%61in=example.test&expand=dns_names",
742        )
743        .unwrap();
744        assert!(!request_matches_policy(&encoded_name, &policy, None));
745        let encoded_value = Url::parse(
746            "http://127.0.0.1:39002/v1/issuances?domain=example.test&expand=dns%5Fnames",
747        )
748        .unwrap();
749        assert!(!request_matches_policy(&encoded_value, &policy, None));
750        let encoded_cursor = Url::parse(
751            "http://127.0.0.1:39002/v1/issuances?domain=example.test&expand=dns_names&after=opaque%2D1",
752        )
753        .unwrap();
754        assert!(!request_matches_policy(
755            &encoded_cursor,
756            &policy,
757            Some("opaque-1")
758        ));
759
760        let mut wrong_path = next;
761        wrong_path.set_path("/v1/certificates");
762        assert!(!request_matches_policy(
763            &wrong_path,
764            &policy,
765            Some("opaque-1")
766        ));
767    }
768
769    #[test]
770    fn provider_client_construction_pins_retry_free_transport() {
771        let source = include_str!("recon_ct_runtime.rs");
772        let retry_free = concat!(".retry(reqwest::retry::", "never())");
773        assert_eq!(
774            source.matches(retry_free).count(),
775            1,
776            "the provider client must explicitly disable dependency-level retries"
777        );
778    }
779
780    #[test]
781    fn response_representation_headers_must_be_single_and_unambiguous() {
782        let mut headers = header::HeaderMap::new();
783        headers.insert(
784            header::CONTENT_TYPE,
785            header::HeaderValue::from_static("application/json; charset=utf-8"),
786        );
787        assert!(json_content_type(&headers));
788        assert!(identity_content_coding(&headers));
789
790        headers.append(
791            header::CONTENT_TYPE,
792            header::HeaderValue::from_static("text/html"),
793        );
794        assert!(!json_content_type(&headers));
795
796        let mut headers = header::HeaderMap::new();
797        headers.insert(
798            header::CONTENT_TYPE,
799            header::HeaderValue::from_static("application/json"),
800        );
801        headers.insert(
802            header::CONTENT_ENCODING,
803            header::HeaderValue::from_static("identity"),
804        );
805        assert!(identity_content_coding(&headers));
806        headers.append(
807            header::CONTENT_ENCODING,
808            header::HeaderValue::from_static("gzip"),
809        );
810        assert!(!identity_content_coding(&headers));
811
812        let mut compound = header::HeaderMap::new();
813        compound.insert(
814            header::CONTENT_TYPE,
815            header::HeaderValue::from_static("application/json; charset=utf-8, text/html"),
816        );
817        compound.insert(
818            header::CONTENT_ENCODING,
819            header::HeaderValue::from_static("identity, gzip"),
820        );
821        assert!(!json_content_type(&compound));
822        assert!(!identity_content_coding(&compound));
823    }
824
825    #[test]
826    fn response_overrun_distinguishes_parent_and_provider_local_limits() {
827        assert_eq!(
828            retention_failure(7, 8),
829            (
830                TransportDispatchOutcome::ResponseBudgetReached,
831                CertSpotterTerminal::ParentAuthorityUnavailable,
832                ReconCtProviderFailureKind::ParentAuthorityUnavailable,
833            )
834        );
835        assert_eq!(
836            retention_failure(8, 8),
837            (
838                TransportDispatchOutcome::TransportFailure,
839                CertSpotterTerminal::TransportFailed,
840                ReconCtProviderFailureKind::ResponseTooLarge,
841            )
842        );
843    }
844
845    #[test]
846    fn provider_audit_debug_never_emits_raw_issuance_or_name_values() {
847        let policy = policy("owned_loopback_fixture", Some("http://127.0.0.1:39002"));
848        let mut accumulator = CertSpotterResponseAccumulator::for_policy(&policy);
849        let page =
850            br#"[{"id":"opaque/\u202ename@example.com","dns_names":["private.example.test"]}]"#;
851        accumulator.ingest_page(page).unwrap();
852        let audit = finalize(
853            accumulator,
854            CertSpotterTerminal::TransportFailed,
855            ProviderAccounting {
856                request_attempt_count: 1,
857                request_admitted_count: 1,
858                response_completed_count: 1,
859                observed_response_bytes: page.len() as u64,
860            },
861            Some(ReconCtProviderFailureKind::TransportFailed),
862        );
863
864        let debug = format!("{audit:?}");
865        assert!(debug.contains("result: \"<redacted>\""));
866        assert!(!debug.contains("name@example.com"));
867        assert!(!debug.contains("private.example.test"));
868        assert!(!debug.contains("opaque/"));
869        assert!(!debug.contains('\u{202e}'));
870    }
871
872    #[tokio::test]
873    async fn exhausted_parent_response_budget_refuses_provider_before_dispatch() {
874        let target = Url::parse("http://127.0.0.1:39001/").unwrap();
875        let authority = SharedWebRuntimeAuthority::new_exact_origin(
876            &target,
877            crate::HttpEvidencePolicy::for_origin(target.clone()).unwrap(),
878            crate::RuntimeBudget::default().with_max_response_bytes(0),
879            tokio_util::sync::CancellationToken::new(),
880        )
881        .unwrap();
882        let audit = execute_recon_ct_provider(
883            policy("owned_loopback_fixture", Some("http://127.0.0.1:39002")),
884            &target,
885            &authority,
886        )
887        .await;
888
889        assert_eq!(
890            audit.result().audit().terminal(),
891            CertSpotterTerminal::ParentAuthorityUnavailable
892        );
893        assert_eq!(audit.request_attempt_count(), 1);
894        assert_eq!(audit.request_admitted_count(), 0);
895        assert_eq!(audit.response_completed_count(), 0);
896        assert_eq!(
897            audit.first_failure(),
898            Some(ReconCtProviderFailureKind::ParentAuthorityUnavailable)
899        );
900        assert_eq!(
901            authority.request_accounting().snapshot().total_requests(),
902            0
903        );
904    }
905
906    #[test]
907    fn failure_tokens_are_static_and_value_free() {
908        let failures = [
909            ReconCtProviderFailureKind::ClientInitialization,
910            ReconCtProviderFailureKind::RequestConstruction,
911            ReconCtProviderFailureKind::ParentAuthorityUnavailable,
912            ReconCtProviderFailureKind::Cancelled,
913            ReconCtProviderFailureKind::DeadlineReached,
914            ReconCtProviderFailureKind::TransportFailed,
915            ReconCtProviderFailureKind::RedirectRefused,
916            ReconCtProviderFailureKind::AccessRejected,
917            ReconCtProviderFailureKind::Throttled,
918            ReconCtProviderFailureKind::ProviderNotFound,
919            ReconCtProviderFailureKind::ServerFailure,
920            ReconCtProviderFailureKind::UnexpectedStatus,
921            ReconCtProviderFailureKind::UnsupportedMedia,
922            ReconCtProviderFailureKind::UnsupportedContentCoding,
923            ReconCtProviderFailureKind::ResponseTooLarge,
924            ReconCtProviderFailureKind::InvalidResponse,
925        ];
926        for failure in failures {
927            let token = failure.as_str();
928            assert!(!token.is_empty());
929            assert!(token
930                .bytes()
931                .all(|byte| byte.is_ascii_lowercase() || byte == b'_'));
932        }
933    }
934}