Skip to main content

termivar_scanner/
recon_ct_provider.rs

1//! Bounded, transport-neutral core for Cert Spotter reconnaissance queries.
2//!
3//! This module validates explicit operator policy, constructs the one permitted
4//! provider route, and reduces bounded provider pages to source-qualified DNS
5//! name hypotheses. It owns no network transport, target authority, retry
6//! policy, clock, or persistence. In particular, a successful query does not
7//! establish ownership, currentness, source authenticity, or scan authority.
8
9use std::{collections::BTreeSet, fmt, net::IpAddr, time::Duration};
10
11use serde::{
12    de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
13    Deserialize, Deserializer,
14};
15use serde_json::{Number, Value};
16use thiserror::Error;
17use url::{Host, Url};
18
19/// The only accepted local policy schema.
20pub const RECON_CT_PROVIDER_POLICY_SCHEMA: &str = "security.recon-certspotter-policy/v1";
21/// Fixed provider origin used by production execution.
22pub const CERT_SPOTTER_PRODUCTION_ORIGIN: &str = "https://api.certspotter.com";
23/// Maximum exact local policy size.
24pub const MAX_RECON_CT_PROVIDER_POLICY_BYTES: usize = 16 * 1024;
25/// Maximum accepted bytes in one provider response page.
26pub const MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES: usize = 256 * 1024;
27/// Maximum provider pages in one bounded collection.
28pub const MAX_CERT_SPOTTER_RESPONSE_PAGES: usize = 2;
29/// Maximum accepted provider response bytes over the whole collection.
30pub const MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES: usize = 512 * 1024;
31/// Maximum wall-clock time transport integration may spend on collection.
32pub const MAX_CERT_SPOTTER_ELAPSED: Duration = Duration::from_secs(10);
33/// Maximum simultaneous provider requests permitted by this contract.
34pub const MAX_CERT_SPOTTER_IN_FLIGHT_REQUESTS: usize = 1;
35/// Maximum unique in-scope DNS name hypotheses retained in one result.
36pub const MAX_CERT_SPOTTER_RETAINED_NAMES: usize = 256;
37
38const MAX_POLICY_OPAQUE_BYTES: usize = 128;
39const MAX_PROVIDER_ORIGIN_BYTES: usize = 256;
40const MAX_DNS_NAME_BYTES: usize = 253;
41const MAX_ISSUANCE_ID_BYTES: usize = 128;
42const MAX_ISSUANCES_PER_PAGE: usize = 4096;
43const MAX_JSON_DEPTH: usize = 16;
44const MAX_JSON_NODES: usize = 32_768;
45const MAX_JSON_MEMBERS: usize = 16_384;
46const MAX_JSON_ARRAY_ITEMS: usize = 16_384;
47const MAX_JSON_KEY_BYTES: usize = 128;
48const MAX_JSON_STRING_BYTES: usize = 4096;
49const DUPLICATE_KEY_MARKER: &str = "termivar_cert_spotter_duplicate_json_key";
50const JSON_LIMIT_MARKER: &str = "termivar_cert_spotter_json_limit";
51
52/// Closed execution environment selected by local operator policy.
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub enum ReconCtProviderExecutionMode {
55    /// Use the fixed HTTPS Cert Spotter production origin.
56    Production,
57    /// Use an explicitly owned, numeric-loopback HTTP fixture.
58    OwnedLoopbackFixture,
59}
60
61impl ReconCtProviderExecutionMode {
62    /// Returns the stable policy and reporting token.
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Production => "production",
67            Self::OwnedLoopbackFixture => "owned_loopback_fixture",
68        }
69    }
70}
71
72/// Concise provider-specific alias for the execution mode.
73pub type CertSpotterExecutionMode = ReconCtProviderExecutionMode;
74
75/// Validated local policy for one bounded Cert Spotter query.
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct CertSpotterPolicy {
78    revision: String,
79    query_domain: String,
80    query_reference: String,
81    execution_mode: ReconCtProviderExecutionMode,
82    provider_origin: String,
83}
84
85/// Integration-facing policy name used by CLI and runtime code.
86pub type ReconCtProviderPolicy = CertSpotterPolicy;
87
88impl CertSpotterPolicy {
89    /// Parses one exact, strict V1 JSON policy document.
90    pub fn parse_json(bytes: &[u8]) -> Result<Self, ReconCtProviderPolicyError> {
91        parse_recon_ct_provider_policy(bytes)
92    }
93
94    /// Returns the accepted policy schema.
95    #[must_use]
96    pub const fn schema(&self) -> &'static str {
97        RECON_CT_PROVIDER_POLICY_SCHEMA
98    }
99
100    /// Returns the bounded opaque operator revision.
101    #[must_use]
102    pub fn revision(&self) -> &str {
103        &self.revision
104    }
105
106    /// Returns the normalized lowercase ASCII DNS query.
107    #[must_use]
108    pub fn query_domain(&self) -> &str {
109        &self.query_domain
110    }
111
112    /// Returns the bounded opaque operator query reference.
113    #[must_use]
114    pub fn query_reference(&self) -> &str {
115        &self.query_reference
116    }
117
118    /// Returns the closed execution mode.
119    #[must_use]
120    pub const fn execution_mode(&self) -> ReconCtProviderExecutionMode {
121        self.execution_mode
122    }
123
124    /// Returns the effective provider origin.
125    ///
126    /// Production always returns [`CERT_SPOTTER_PRODUCTION_ORIGIN`]. Fixture
127    /// mode returns the canonical numeric-loopback origin declared by policy.
128    #[must_use]
129    pub fn provider_origin(&self) -> &str {
130        &self.provider_origin
131    }
132
133    /// Returns the mandatory provider-use authorization acknowledgement.
134    #[must_use]
135    pub const fn provider_use_authorized(&self) -> bool {
136        true
137    }
138
139    /// Returns the mandatory privacy-disclosure acknowledgement.
140    #[must_use]
141    pub const fn privacy_disclosure_acknowledged(&self) -> bool {
142        true
143    }
144
145    /// Builds the exact permitted provider URL, optionally after one cursor.
146    pub fn build_request_url(&self, after: Option<&str>) -> Result<Url, CertSpotterRequestError> {
147        if after.is_some_and(|cursor| !valid_issuance_id(cursor)) {
148            return Err(CertSpotterRequestError::InvalidAfterCursor);
149        }
150
151        let mut url = Url::parse(&self.provider_origin)
152            .map_err(|_| CertSpotterRequestError::InvalidProviderOrigin)?;
153        url.set_path("/v1/issuances");
154        url.set_query(None);
155        url.set_fragment(None);
156        {
157            let mut query = url.query_pairs_mut();
158            query.append_pair("domain", &self.query_domain);
159            query.append_pair("expand", "dns_names");
160            if let Some(cursor) = after {
161                query.append_pair("after", cursor);
162            }
163        }
164        Ok(url)
165    }
166}
167
168/// Parses one exact, strict V1 policy document.
169pub fn parse_recon_ct_provider_policy(
170    bytes: &[u8],
171) -> Result<ReconCtProviderPolicy, ReconCtProviderPolicyError> {
172    if bytes.is_empty() {
173        return Err(CertSpotterPolicyError::EmptyInput);
174    }
175    if bytes.len() > MAX_RECON_CT_PROVIDER_POLICY_BYTES {
176        return Err(CertSpotterPolicyError::InputTooLarge);
177    }
178
179    let value = parse_strict_json(bytes).map_err(CertSpotterPolicyError::from)?;
180    let provider_origin_present = value
181        .as_object()
182        .is_some_and(|object| object.contains_key("provider_origin"));
183    let wire: PolicyWire =
184        serde_json::from_value(value).map_err(|_| CertSpotterPolicyError::InvalidPolicy)?;
185    if wire.schema != RECON_CT_PROVIDER_POLICY_SCHEMA {
186        return Err(CertSpotterPolicyError::UnsupportedSchema);
187    }
188    if !valid_safe_opaque(&wire.revision) {
189        return Err(CertSpotterPolicyError::InvalidRevision);
190    }
191    if !valid_query_domain(&wire.query_domain) {
192        return Err(CertSpotterPolicyError::InvalidQueryDomain);
193    }
194    if !valid_safe_opaque(&wire.query_reference) {
195        return Err(CertSpotterPolicyError::InvalidQueryReference);
196    }
197    if !wire.provider_use_authorized {
198        return Err(CertSpotterPolicyError::ProviderUseNotAuthorized);
199    }
200    if !wire.privacy_disclosure_acknowledged {
201        return Err(CertSpotterPolicyError::PrivacyDisclosureNotAcknowledged);
202    }
203
204    let (execution_mode, provider_origin) = match wire.execution_mode {
205        ExecutionModeWire::Production => {
206            if provider_origin_present {
207                return Err(CertSpotterPolicyError::ProductionOriginMustBeAbsent);
208            }
209            (
210                ReconCtProviderExecutionMode::Production,
211                CERT_SPOTTER_PRODUCTION_ORIGIN.to_owned(),
212            )
213        },
214        ExecutionModeWire::OwnedLoopbackFixture => {
215            let declared = wire
216                .provider_origin
217                .as_deref()
218                .ok_or(CertSpotterPolicyError::FixtureOriginRequired)?;
219            let provider_origin = canonical_owned_loopback_origin(declared)
220                .ok_or(CertSpotterPolicyError::InvalidFixtureOrigin)?;
221            (
222                ReconCtProviderExecutionMode::OwnedLoopbackFixture,
223                provider_origin,
224            )
225        },
226    };
227
228    Ok(CertSpotterPolicy {
229        revision: tight_string(wire.revision),
230        query_domain: tight_string(wire.query_domain.to_ascii_lowercase()),
231        query_reference: tight_string(wire.query_reference),
232        execution_mode,
233        provider_origin: tight_string(provider_origin),
234    })
235}
236
237/// Builds the exact permitted provider URL from validated policy.
238pub fn build_cert_spotter_request_url(
239    policy: &CertSpotterPolicy,
240    after: Option<&str>,
241) -> Result<Url, CertSpotterRequestError> {
242    policy.build_request_url(after)
243}
244
245/// Static, redaction-safe policy rejection.
246#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
247#[non_exhaustive]
248pub enum CertSpotterPolicyError {
249    /// No exact policy bytes were supplied.
250    #[error("Cert Spotter policy input is empty")]
251    EmptyInput,
252    /// Exact policy bytes exceeded the 16-KiB ceiling.
253    #[error("Cert Spotter policy exceeds its byte limit")]
254    InputTooLarge,
255    /// The input was not one complete JSON value.
256    #[error("Cert Spotter policy is malformed JSON")]
257    MalformedJson,
258    /// A JSON object contained a duplicate decoded key.
259    #[error("Cert Spotter policy contains a duplicate JSON key")]
260    DuplicateJsonKey,
261    /// A JSON structural bound was exceeded.
262    #[error("Cert Spotter policy exceeds a JSON structural limit")]
263    StructuralLimitExceeded,
264    /// A required field, type, closed token, or unknown field was invalid.
265    #[error("Cert Spotter policy contains an invalid or unsupported value")]
266    InvalidPolicy,
267    /// The policy did not select the exact supported schema.
268    #[error("Cert Spotter policy schema is unsupported")]
269    UnsupportedSchema,
270    /// The opaque policy revision violated its safe bounded syntax.
271    #[error("Cert Spotter policy revision is invalid")]
272    InvalidRevision,
273    /// The query was not a multi-label, non-wildcard ASCII DNS name.
274    #[error("Cert Spotter policy query domain is invalid")]
275    InvalidQueryDomain,
276    /// The opaque query reference violated its safe bounded syntax.
277    #[error("Cert Spotter policy query reference is invalid")]
278    InvalidQueryReference,
279    /// Provider use was not affirmatively authorized.
280    #[error("Cert Spotter provider use is not authorized by policy")]
281    ProviderUseNotAuthorized,
282    /// The privacy disclosure was not affirmatively acknowledged.
283    #[error("Cert Spotter privacy disclosure is not acknowledged by policy")]
284    PrivacyDisclosureNotAcknowledged,
285    /// Production policy attempted to override the fixed provider origin.
286    #[error("Cert Spotter production policy must not declare provider_origin")]
287    ProductionOriginMustBeAbsent,
288    /// Fixture mode omitted its explicitly owned loopback origin.
289    #[error("Cert Spotter loopback fixture policy requires provider_origin")]
290    FixtureOriginRequired,
291    /// Fixture origin was not root-only HTTP on a numeric loopback and explicit port.
292    #[error("Cert Spotter fixture origin is not an owned numeric-loopback HTTP origin")]
293    InvalidFixtureOrigin,
294}
295
296/// Integration-facing alias for strict policy failures.
297pub type ReconCtProviderPolicyError = CertSpotterPolicyError;
298
299impl From<StrictJsonError> for CertSpotterPolicyError {
300    fn from(value: StrictJsonError) -> Self {
301        match value {
302            StrictJsonError::Malformed => Self::MalformedJson,
303            StrictJsonError::DuplicateKey => Self::DuplicateJsonKey,
304            StrictJsonError::LimitExceeded => Self::StructuralLimitExceeded,
305        }
306    }
307}
308
309/// Static request-URL construction failure.
310#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
311#[non_exhaustive]
312pub enum CertSpotterRequestError {
313    /// The optional opaque cursor was empty, oversized, or contained controls.
314    #[error("Cert Spotter after cursor is invalid")]
315    InvalidAfterCursor,
316    /// Validated policy unexpectedly did not contain a usable provider origin.
317    #[error("Cert Spotter provider origin cannot form a request URL")]
318    InvalidProviderOrigin,
319}
320
321#[derive(Deserialize)]
322#[serde(deny_unknown_fields)]
323struct PolicyWire {
324    schema: String,
325    revision: String,
326    query_domain: String,
327    query_reference: String,
328    execution_mode: ExecutionModeWire,
329    provider_origin: Option<String>,
330    provider_use_authorized: bool,
331    privacy_disclosure_acknowledged: bool,
332}
333
334#[derive(Deserialize)]
335#[serde(rename_all = "snake_case")]
336enum ExecutionModeWire {
337    Production,
338    OwnedLoopbackFixture,
339}
340
341/// Fixed interpretation limits attached to every provider-derived name.
342#[derive(Clone, Copy, Debug, Eq, PartialEq)]
343pub enum CertSpotterClaimLimit {
344    /// Returned names are hypotheses, not findings or verified assets.
345    HypothesesOnly,
346    /// Provider output cannot grant target scan authority.
347    ScanAuthorityNotGranted,
348    /// Domain or certificate ownership was not established.
349    OwnershipNotEstablished,
350    /// Currentness at report consumption time was not established.
351    CurrentnessNotEstablished,
352    /// TLS transport, if any, is not provider-source authentication evidence.
353    SourceAuthenticationNotEstablished,
354}
355
356impl CertSpotterClaimLimit {
357    /// Returns the stable reporting token.
358    #[must_use]
359    pub const fn as_str(self) -> &'static str {
360        match self {
361            Self::HypothesesOnly => "hypotheses_only",
362            Self::ScanAuthorityNotGranted => "scan_authority_not_granted",
363            Self::OwnershipNotEstablished => "ownership_not_established",
364            Self::CurrentnessNotEstablished => "currentness_not_established",
365            Self::SourceAuthenticationNotEstablished => "source_authentication_not_established",
366        }
367    }
368}
369
370const CERT_SPOTTER_CLAIM_LIMITS: [CertSpotterClaimLimit; 5] = [
371    CertSpotterClaimLimit::HypothesesOnly,
372    CertSpotterClaimLimit::ScanAuthorityNotGranted,
373    CertSpotterClaimLimit::OwnershipNotEstablished,
374    CertSpotterClaimLimit::CurrentnessNotEstablished,
375    CertSpotterClaimLimit::SourceAuthenticationNotEstablished,
376];
377
378/// Why a bounded collection stopped.
379#[derive(Clone, Copy, Debug, Eq, PartialEq)]
380pub enum CertSpotterTerminal {
381    /// The provider returned an empty page at the observed cursor.
382    ProviderExhausted,
383    /// The unique retained-name ceiling was reached.
384    HypothesisLimitReached,
385    /// The two-page ceiling was reached before an empty page.
386    PageLimitReached,
387    /// The ten-second integration deadline was reached.
388    DeadlineReached,
389    /// Parent request accounting did not permit another provider request.
390    ParentAuthorityUnavailable,
391    /// Provider transport failed within the authorized operation.
392    TransportFailed,
393    /// The host cancelled collection.
394    Cancelled,
395}
396
397impl CertSpotterTerminal {
398    /// Returns the stable reporting token.
399    #[must_use]
400    pub const fn as_str(self) -> &'static str {
401        match self {
402            Self::ProviderExhausted => "provider_exhausted",
403            Self::HypothesisLimitReached => "hypothesis_limit_reached",
404            Self::PageLimitReached => "page_limit_reached",
405            Self::DeadlineReached => "deadline_reached",
406            Self::ParentAuthorityUnavailable => "parent_authority_unavailable",
407            Self::TransportFailed => "transport_failed",
408            Self::Cancelled => "cancelled",
409        }
410    }
411
412    /// Returns the conservative completeness implied by this terminal state.
413    #[must_use]
414    pub const fn completeness(self) -> CertSpotterCompleteness {
415        match self {
416            Self::ProviderExhausted => CertSpotterCompleteness::ProviderExhaustedAsObserved,
417            Self::HypothesisLimitReached | Self::PageLimitReached => {
418                CertSpotterCompleteness::BoundedPartial
419            },
420            Self::DeadlineReached
421            | Self::ParentAuthorityUnavailable
422            | Self::TransportFailed
423            | Self::Cancelled => CertSpotterCompleteness::Unknown,
424        }
425    }
426
427    const fn is_external(self) -> bool {
428        matches!(
429            self,
430            Self::DeadlineReached
431                | Self::ParentAuthorityUnavailable
432                | Self::TransportFailed
433                | Self::Cancelled
434        )
435    }
436}
437
438/// Conservative collection completeness.
439#[derive(Clone, Copy, Debug, Eq, PartialEq)]
440pub enum CertSpotterCompleteness {
441    /// An empty provider page was observed within this bounded run.
442    ///
443    /// This is not a currentness or global CT completeness claim.
444    ProviderExhaustedAsObserved,
445    /// A compiled page or hypothesis ceiling stopped collection.
446    BoundedPartial,
447    /// Transport, authority, deadline, or cancellation prevented exhaustion.
448    Unknown,
449}
450
451impl CertSpotterCompleteness {
452    /// Returns the stable reporting token.
453    #[must_use]
454    pub const fn as_str(self) -> &'static str {
455        match self {
456            Self::ProviderExhaustedAsObserved => "provider_exhausted_as_observed",
457            Self::BoundedPartial => "bounded_partial",
458            Self::Unknown => "unknown",
459        }
460    }
461}
462
463/// One unique, source-qualified provider DNS name hypothesis.
464#[derive(Clone, Debug, Eq, PartialEq)]
465pub struct CertSpotterNameHypothesis {
466    name: String,
467    first_issuance_id: String,
468}
469
470impl CertSpotterNameHypothesis {
471    /// Returns the normalized lowercase ASCII DNS name.
472    ///
473    /// A leading `*.` is retained when the provider supplied a syntactically
474    /// valid wildcard subordinate to the policy query.
475    #[must_use]
476    pub fn name(&self) -> &str {
477        &self.name
478    }
479
480    /// Returns the first bounded provider issuance ID associated with the name.
481    #[must_use]
482    pub fn first_issuance_id(&self) -> &str {
483        &self.first_issuance_id
484    }
485
486    /// Returns all fixed interpretation boundaries for this hypothesis.
487    #[must_use]
488    pub const fn claim_limits(&self) -> &'static [CertSpotterClaimLimit] {
489        &CERT_SPOTTER_CLAIM_LIMITS
490    }
491}
492
493/// Outcome after accepting one complete provider response page.
494#[derive(Clone, Debug, Eq, PartialEq)]
495pub enum CertSpotterPageOutcome {
496    /// Another page is allowed using this opaque provider cursor.
497    Continue {
498        /// Last issuance ID from the accepted page.
499        after: String,
500    },
501    /// Collection reached one terminal condition.
502    Terminal(CertSpotterTerminal),
503}
504
505/// Stateful, transactional parser for at most two provider response pages.
506///
507/// Failed page parsing does not mutate accumulated state. Transport code owns
508/// request execution and calls [`Self::into_result`] with an external terminal
509/// when collection ends before a parser-selected terminal.
510#[derive(Clone, Debug, Eq, PartialEq)]
511pub struct CertSpotterResponseAccumulator {
512    policy_revision: String,
513    query_domain: String,
514    query_reference: String,
515    execution_mode: ReconCtProviderExecutionMode,
516    provider_origin: String,
517    initial_after: Option<String>,
518    next_after: Option<String>,
519    page_count: usize,
520    response_bytes: usize,
521    issuance_count: usize,
522    seen_issuance_ids: BTreeSet<String>,
523    seen_names: BTreeSet<String>,
524    hypotheses: Vec<CertSpotterNameHypothesis>,
525    foreign_name_count: usize,
526    duplicate_name_count: usize,
527    omitted_name_count: usize,
528    terminal: Option<CertSpotterTerminal>,
529}
530
531impl CertSpotterResponseAccumulator {
532    /// Creates an empty first-page accumulator from validated policy.
533    #[must_use]
534    pub fn for_policy(policy: &CertSpotterPolicy) -> Self {
535        Self::from_policy_and_after(policy, None)
536    }
537
538    /// Creates an accumulator for an optional validated starting cursor.
539    pub fn with_after(
540        policy: &CertSpotterPolicy,
541        after: Option<&str>,
542    ) -> Result<Self, CertSpotterRequestError> {
543        if after.is_some_and(|cursor| !valid_issuance_id(cursor)) {
544            return Err(CertSpotterRequestError::InvalidAfterCursor);
545        }
546        Ok(Self::from_policy_and_after(policy, after))
547    }
548
549    fn from_policy_and_after(policy: &CertSpotterPolicy, after: Option<&str>) -> Self {
550        Self {
551            policy_revision: policy.revision.clone(),
552            query_domain: policy.query_domain.clone(),
553            query_reference: policy.query_reference.clone(),
554            execution_mode: policy.execution_mode,
555            provider_origin: policy.provider_origin.clone(),
556            initial_after: after.map(ToOwned::to_owned),
557            next_after: after.map(ToOwned::to_owned),
558            page_count: 0,
559            response_bytes: 0,
560            issuance_count: 0,
561            seen_issuance_ids: BTreeSet::new(),
562            seen_names: BTreeSet::new(),
563            hypotheses: Vec::new(),
564            foreign_name_count: 0,
565            duplicate_name_count: 0,
566            omitted_name_count: 0,
567            terminal: None,
568        }
569    }
570
571    /// Returns the cursor for the next request, if one exists.
572    #[must_use]
573    pub fn next_after(&self) -> Option<&str> {
574        self.next_after.as_deref()
575    }
576
577    /// Returns accepted provider page count.
578    #[must_use]
579    pub const fn page_count(&self) -> usize {
580        self.page_count
581    }
582
583    /// Returns accepted provider bytes over all pages.
584    #[must_use]
585    pub const fn response_bytes(&self) -> usize {
586        self.response_bytes
587    }
588
589    /// Returns unique retained hypotheses accumulated so far.
590    #[must_use]
591    pub fn hypotheses(&self) -> &[CertSpotterNameHypothesis] {
592        &self.hypotheses
593    }
594
595    /// Parses and transactionally accepts one complete provider JSON page.
596    pub fn ingest_page(
597        &mut self,
598        bytes: &[u8],
599    ) -> Result<CertSpotterPageOutcome, CertSpotterResponseError> {
600        if self.terminal.is_some() {
601            return Err(CertSpotterResponseError::CollectionAlreadyTerminal);
602        }
603        if self.page_count >= MAX_CERT_SPOTTER_RESPONSE_PAGES {
604            return Err(CertSpotterResponseError::TooManyPages);
605        }
606        if bytes.len() > MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES {
607            return Err(CertSpotterResponseError::PageTooLarge);
608        }
609        let total_response_bytes = self
610            .response_bytes
611            .checked_add(bytes.len())
612            .filter(|total| *total <= MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES)
613            .ok_or(CertSpotterResponseError::TotalTooLarge)?;
614
615        let value = parse_strict_json(bytes).map_err(CertSpotterResponseError::from)?;
616        let Value::Array(issuances) = value else {
617            return Err(CertSpotterResponseError::InvalidRoot);
618        };
619        if issuances.len() > MAX_ISSUANCES_PER_PAGE {
620            return Err(CertSpotterResponseError::TooManyIssuances);
621        }
622
623        let mut seen_issuance_ids = self.seen_issuance_ids.clone();
624        let mut seen_names = self.seen_names.clone();
625        let mut hypotheses = self.hypotheses.clone();
626        let mut foreign_name_count = self.foreign_name_count;
627        let mut duplicate_name_count = self.duplicate_name_count;
628        let mut omitted_name_count = self.omitted_name_count;
629        let mut page_cursor = self.next_after.clone();
630
631        for issuance in &issuances {
632            let object = issuance
633                .as_object()
634                .ok_or(CertSpotterResponseError::InvalidIssuance)?;
635            let issuance_id = object
636                .get("id")
637                .and_then(Value::as_str)
638                .ok_or(CertSpotterResponseError::InvalidIssuanceId)?;
639            if !valid_issuance_id(issuance_id) {
640                return Err(CertSpotterResponseError::InvalidIssuanceId);
641            }
642            if seen_issuance_ids.contains(issuance_id) {
643                return Err(CertSpotterResponseError::DuplicateIssuanceId);
644            }
645            // The provider contract calls this ID opaque. Equality with the
646            // active cursor is therefore the only ordering statement we can
647            // make independently; cross-page reuse is rejected by the seen set.
648            if page_cursor.as_deref() == Some(issuance_id) {
649                return Err(CertSpotterResponseError::NonProgressingIssuanceId);
650            }
651            let dns_names = object
652                .get("dns_names")
653                .and_then(Value::as_array)
654                .ok_or(CertSpotterResponseError::InvalidDnsNames)?;
655
656            for dns_name in dns_names {
657                let Some(dns_name) = dns_name.as_str() else {
658                    return Err(CertSpotterResponseError::InvalidDnsNames);
659                };
660                match classify_provider_name(dns_name, &self.query_domain) {
661                    ProviderName::InScope(normalized) => {
662                        if !seen_names.insert(normalized.clone()) {
663                            duplicate_name_count = checked_increment(duplicate_name_count)?;
664                        } else if hypotheses.len() < MAX_CERT_SPOTTER_RETAINED_NAMES {
665                            hypotheses.push(CertSpotterNameHypothesis {
666                                name: tight_string(normalized),
667                                first_issuance_id: issuance_id.to_owned(),
668                            });
669                        } else {
670                            omitted_name_count = checked_increment(omitted_name_count)?;
671                        }
672                    },
673                    ProviderName::Foreign => {
674                        foreign_name_count = checked_increment(foreign_name_count)?;
675                    },
676                    ProviderName::Invalid => {
677                        omitted_name_count = checked_increment(omitted_name_count)?;
678                    },
679                }
680            }
681
682            seen_issuance_ids.insert(issuance_id.to_owned());
683            page_cursor = Some(issuance_id.to_owned());
684        }
685
686        let page_count = checked_increment(self.page_count)?;
687        let issuance_count = self
688            .issuance_count
689            .checked_add(issuances.len())
690            .ok_or(CertSpotterResponseError::StructuralLimitExceeded)?;
691
692        self.page_count = page_count;
693        self.response_bytes = total_response_bytes;
694        self.issuance_count = issuance_count;
695        self.seen_issuance_ids = seen_issuance_ids;
696        self.seen_names = seen_names;
697        self.hypotheses = hypotheses;
698        self.foreign_name_count = foreign_name_count;
699        self.duplicate_name_count = duplicate_name_count;
700        self.omitted_name_count = omitted_name_count;
701        self.next_after = page_cursor;
702
703        let outcome = if issuances.is_empty() {
704            CertSpotterPageOutcome::Terminal(CertSpotterTerminal::ProviderExhausted)
705        } else if self.hypotheses.len() >= MAX_CERT_SPOTTER_RETAINED_NAMES {
706            CertSpotterPageOutcome::Terminal(CertSpotterTerminal::HypothesisLimitReached)
707        } else if self.page_count >= MAX_CERT_SPOTTER_RESPONSE_PAGES {
708            CertSpotterPageOutcome::Terminal(CertSpotterTerminal::PageLimitReached)
709        } else {
710            CertSpotterPageOutcome::Continue {
711                after: self
712                    .next_after
713                    .clone()
714                    .ok_or(CertSpotterResponseError::InvalidIssuance)?,
715            }
716        };
717        if let CertSpotterPageOutcome::Terminal(terminal) = outcome {
718            self.terminal = Some(terminal);
719        }
720        Ok(outcome)
721    }
722
723    /// Finalizes accumulated hypotheses with one terminal transport outcome.
724    ///
725    /// A parser-selected terminal must be passed back unchanged. Before such a
726    /// terminal, only deadline, parent-authority, transport, or cancellation
727    /// outcomes may be supplied by integration code.
728    pub fn into_result(
729        self,
730        terminal: CertSpotterTerminal,
731    ) -> Result<CertSpotterResult, CertSpotterResponseError> {
732        match self.terminal {
733            Some(selected) if selected != terminal => {
734                return Err(CertSpotterResponseError::TerminalMismatch);
735            },
736            None if !terminal.is_external() => {
737                return Err(CertSpotterResponseError::TerminalMismatch);
738            },
739            _ => {},
740        }
741
742        let audit = CertSpotterAudit {
743            policy_revision: self.policy_revision,
744            query_domain: self.query_domain,
745            query_reference: self.query_reference,
746            execution_mode: self.execution_mode,
747            provider_origin: self.provider_origin,
748            initial_after: self.initial_after,
749            last_cursor: self.next_after,
750            page_count: self.page_count,
751            response_bytes: self.response_bytes,
752            issuance_count: self.issuance_count,
753            retained_name_count: self.hypotheses.len(),
754            foreign_name_count: self.foreign_name_count,
755            duplicate_name_count: self.duplicate_name_count,
756            omitted_name_count: self.omitted_name_count,
757            terminal,
758            completeness: terminal.completeness(),
759        };
760        Ok(CertSpotterResult {
761            hypotheses: self.hypotheses,
762            audit,
763        })
764    }
765}
766
767/// Final bounded, source-qualified provider result.
768#[derive(Clone, Debug, Eq, PartialEq)]
769pub struct CertSpotterResult {
770    hypotheses: Vec<CertSpotterNameHypothesis>,
771    audit: CertSpotterAudit,
772}
773
774impl CertSpotterResult {
775    /// Returns unique hypotheses in first-observed provider order.
776    #[must_use]
777    pub fn hypotheses(&self) -> &[CertSpotterNameHypothesis] {
778        &self.hypotheses
779    }
780
781    /// Returns the value-free collection audit and counters.
782    #[must_use]
783    pub const fn audit(&self) -> &CertSpotterAudit {
784        &self.audit
785    }
786
787    /// Returns all fixed interpretation boundaries for the result.
788    #[must_use]
789    pub const fn claim_limits(&self) -> &'static [CertSpotterClaimLimit] {
790        &CERT_SPOTTER_CLAIM_LIMITS
791    }
792}
793
794/// Typed audit for one finalized bounded provider collection.
795#[derive(Clone, Debug, Eq, PartialEq)]
796pub struct CertSpotterAudit {
797    policy_revision: String,
798    query_domain: String,
799    query_reference: String,
800    execution_mode: ReconCtProviderExecutionMode,
801    provider_origin: String,
802    initial_after: Option<String>,
803    last_cursor: Option<String>,
804    page_count: usize,
805    response_bytes: usize,
806    issuance_count: usize,
807    retained_name_count: usize,
808    foreign_name_count: usize,
809    duplicate_name_count: usize,
810    omitted_name_count: usize,
811    terminal: CertSpotterTerminal,
812    completeness: CertSpotterCompleteness,
813}
814
815impl CertSpotterAudit {
816    /// Returns the policy schema governing this collection.
817    #[must_use]
818    pub const fn schema(&self) -> &'static str {
819        RECON_CT_PROVIDER_POLICY_SCHEMA
820    }
821
822    /// Returns the opaque local policy revision.
823    #[must_use]
824    pub fn policy_revision(&self) -> &str {
825        &self.policy_revision
826    }
827
828    /// Returns the normalized query domain.
829    #[must_use]
830    pub fn query_domain(&self) -> &str {
831        &self.query_domain
832    }
833
834    /// Returns the opaque local query reference.
835    #[must_use]
836    pub fn query_reference(&self) -> &str {
837        &self.query_reference
838    }
839
840    /// Returns the selected execution mode.
841    #[must_use]
842    pub const fn execution_mode(&self) -> ReconCtProviderExecutionMode {
843        self.execution_mode
844    }
845
846    /// Returns the effective provider origin used for request construction.
847    #[must_use]
848    pub fn provider_origin(&self) -> &str {
849        &self.provider_origin
850    }
851
852    /// Returns the optional starting cursor supplied by integration code.
853    #[must_use]
854    pub fn initial_after(&self) -> Option<&str> {
855        self.initial_after.as_deref()
856    }
857
858    /// Returns the last accepted cursor, if any.
859    #[must_use]
860    pub fn last_cursor(&self) -> Option<&str> {
861        self.last_cursor.as_deref()
862    }
863
864    /// Returns the number of accepted response pages/provider requests.
865    #[must_use]
866    pub const fn page_count(&self) -> usize {
867        self.page_count
868    }
869
870    /// Returns exact accepted response bytes across pages.
871    #[must_use]
872    pub const fn response_bytes(&self) -> usize {
873        self.response_bytes
874    }
875
876    /// Returns the number of accepted issuance objects.
877    #[must_use]
878    pub const fn issuance_count(&self) -> usize {
879        self.issuance_count
880    }
881
882    /// Returns unique in-scope names retained as hypotheses.
883    #[must_use]
884    pub const fn retained_name_count(&self) -> usize {
885        self.retained_name_count
886    }
887
888    /// Returns syntactically valid names outside the query domain.
889    #[must_use]
890    pub const fn foreign_name_count(&self) -> usize {
891        self.foreign_name_count
892    }
893
894    /// Returns repeated normalized in-scope names.
895    #[must_use]
896    pub const fn duplicate_name_count(&self) -> usize {
897        self.duplicate_name_count
898    }
899
900    /// Returns invalid names plus unique in-scope names over the retain limit.
901    #[must_use]
902    pub const fn omitted_name_count(&self) -> usize {
903        self.omitted_name_count
904    }
905
906    /// Returns the terminal collection condition.
907    #[must_use]
908    pub const fn terminal(&self) -> CertSpotterTerminal {
909        self.terminal
910    }
911
912    /// Returns conservative completeness implied by the terminal condition.
913    #[must_use]
914    pub const fn completeness(&self) -> CertSpotterCompleteness {
915        self.completeness
916    }
917
918    /// Returns all fixed interpretation boundaries for reporting.
919    #[must_use]
920    pub const fn claim_limits(&self) -> &'static [CertSpotterClaimLimit] {
921        &CERT_SPOTTER_CLAIM_LIMITS
922    }
923
924    /// Returns the compiled page ceiling.
925    #[must_use]
926    pub const fn maximum_pages(&self) -> usize {
927        MAX_CERT_SPOTTER_RESPONSE_PAGES
928    }
929
930    /// Returns the compiled total provider-byte ceiling.
931    #[must_use]
932    pub const fn maximum_response_bytes(&self) -> usize {
933        MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES
934    }
935
936    /// Returns the compiled provider deadline.
937    #[must_use]
938    pub const fn maximum_elapsed(&self) -> Duration {
939        MAX_CERT_SPOTTER_ELAPSED
940    }
941
942    /// Returns the compiled in-flight request ceiling.
943    #[must_use]
944    pub const fn maximum_in_flight_requests(&self) -> usize {
945        MAX_CERT_SPOTTER_IN_FLIGHT_REQUESTS
946    }
947}
948
949/// Static, redaction-safe response parsing or finalization failure.
950#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
951#[non_exhaustive]
952pub enum CertSpotterResponseError {
953    /// One page exceeded 256 KiB.
954    #[error("Cert Spotter response page exceeds its byte limit")]
955    PageTooLarge,
956    /// Accepted page bytes exceeded the 512-KiB collection ceiling.
957    #[error("Cert Spotter response collection exceeds its byte limit")]
958    TotalTooLarge,
959    /// More than two pages were presented.
960    #[error("Cert Spotter response exceeds its page limit")]
961    TooManyPages,
962    /// A page was supplied after a terminal parser outcome.
963    #[error("Cert Spotter response collection is already terminal")]
964    CollectionAlreadyTerminal,
965    /// The input was not one complete JSON value.
966    #[error("Cert Spotter response is malformed JSON")]
967    MalformedJson,
968    /// A JSON object contained a duplicate decoded key.
969    #[error("Cert Spotter response contains a duplicate JSON key")]
970    DuplicateJsonKey,
971    /// A JSON structural bound or counter bound was exceeded.
972    #[error("Cert Spotter response exceeds a JSON structural limit")]
973    StructuralLimitExceeded,
974    /// The response root was not an array.
975    #[error("Cert Spotter response root must be an array")]
976    InvalidRoot,
977    /// A page contained more issuance objects than permitted.
978    #[error("Cert Spotter response contains too many issuances")]
979    TooManyIssuances,
980    /// An array element was not an issuance object.
981    #[error("Cert Spotter response contains an invalid issuance object")]
982    InvalidIssuance,
983    /// An issuance lacked a canonical bounded string ID.
984    #[error("Cert Spotter response contains an invalid issuance ID")]
985    InvalidIssuanceId,
986    /// An issuance ID repeated within or across accepted pages.
987    #[error("Cert Spotter response contains a duplicate issuance ID")]
988    DuplicateIssuanceId,
989    /// The response repeated the exact opaque cursor used to request it.
990    #[error("Cert Spotter response repeats the active issuance cursor")]
991    NonProgressingIssuanceId,
992    /// An issuance lacked a string-only `dns_names` array.
993    #[error("Cert Spotter response contains invalid dns_names")]
994    InvalidDnsNames,
995    /// Finalization did not preserve the selected terminal condition.
996    #[error("Cert Spotter result terminal condition does not match parser state")]
997    TerminalMismatch,
998}
999
1000impl From<StrictJsonError> for CertSpotterResponseError {
1001    fn from(value: StrictJsonError) -> Self {
1002        match value {
1003            StrictJsonError::Malformed => Self::MalformedJson,
1004            StrictJsonError::DuplicateKey => Self::DuplicateJsonKey,
1005            StrictJsonError::LimitExceeded => Self::StructuralLimitExceeded,
1006        }
1007    }
1008}
1009
1010fn checked_increment(value: usize) -> Result<usize, CertSpotterResponseError> {
1011    value
1012        .checked_add(1)
1013        .ok_or(CertSpotterResponseError::StructuralLimitExceeded)
1014}
1015
1016fn valid_safe_opaque(value: &str) -> bool {
1017    !value.is_empty()
1018        && value.len() <= MAX_POLICY_OPAQUE_BYTES
1019        && value.as_bytes()[0].is_ascii_alphanumeric()
1020        && value
1021            .bytes()
1022            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-'))
1023}
1024
1025fn valid_query_domain(value: &str) -> bool {
1026    value.contains('.')
1027        && !value.starts_with("*.")
1028        && value.parse::<IpAddr>().is_err()
1029        && valid_dns_name(value)
1030}
1031
1032fn valid_dns_name(value: &str) -> bool {
1033    if value.is_empty()
1034        || value.len() > MAX_DNS_NAME_BYTES
1035        || !value.is_ascii()
1036        || value.starts_with('.')
1037        || value.ends_with('.')
1038    {
1039        return false;
1040    }
1041    value.split('.').all(|label| {
1042        !label.is_empty()
1043            && label.len() <= 63
1044            && label.as_bytes()[0].is_ascii_alphanumeric()
1045            && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
1046            && label
1047                .bytes()
1048                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1049    })
1050}
1051
1052fn canonical_owned_loopback_origin(value: &str) -> Option<String> {
1053    if value.is_empty() || value.len() > MAX_PROVIDER_ORIGIN_BYTES || !value.is_ascii() {
1054        return None;
1055    }
1056    let parsed = Url::parse(value).ok()?;
1057    if parsed.scheme() != "http"
1058        || !parsed.username().is_empty()
1059        || parsed.password().is_some()
1060        || parsed.port().is_none()
1061        || parsed.port() == Some(0)
1062        || parsed.path() != "/"
1063        || parsed.query().is_some()
1064        || parsed.fragment().is_some()
1065    {
1066        return None;
1067    }
1068    let is_loopback = match parsed.host()? {
1069        Host::Ipv4(address) => address.is_loopback(),
1070        Host::Ipv6(address) => address.is_loopback(),
1071        Host::Domain(_) => false,
1072    };
1073    if !is_loopback {
1074        return None;
1075    }
1076    Some(parsed.origin().ascii_serialization())
1077}
1078
1079fn valid_issuance_id(value: &str) -> bool {
1080    !value.is_empty()
1081        && value.len() <= MAX_ISSUANCE_ID_BYTES
1082        && value
1083            .chars()
1084            .all(|character| !character.is_control() && character != '\u{7f}')
1085}
1086
1087enum ProviderName {
1088    InScope(String),
1089    Foreign,
1090    Invalid,
1091}
1092
1093fn classify_provider_name(value: &str, query_domain: &str) -> ProviderName {
1094    if value.is_empty() || value.len() > MAX_DNS_NAME_BYTES || !value.is_ascii() {
1095        return ProviderName::Invalid;
1096    }
1097    let (wildcard, bare_name) = match value.strip_prefix("*.") {
1098        Some(name) => (true, name),
1099        None => (false, value),
1100    };
1101    if !valid_dns_name(bare_name) || bare_name.parse::<IpAddr>().is_ok() {
1102        return ProviderName::Invalid;
1103    }
1104    let bare_name = bare_name.to_ascii_lowercase();
1105    let in_scope = bare_name == query_domain
1106        || bare_name
1107            .strip_suffix(query_domain)
1108            .is_some_and(|prefix| prefix.ends_with('.') && prefix.len() > 1);
1109    if !in_scope {
1110        return ProviderName::Foreign;
1111    }
1112    if wildcard {
1113        ProviderName::InScope(format!("*.{bare_name}"))
1114    } else {
1115        ProviderName::InScope(bare_name)
1116    }
1117}
1118
1119fn tight_string(mut value: String) -> String {
1120    value.shrink_to_fit();
1121    value
1122}
1123
1124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1125enum StrictJsonError {
1126    Malformed,
1127    DuplicateKey,
1128    LimitExceeded,
1129}
1130
1131#[derive(Default)]
1132struct JsonBudget {
1133    nodes: usize,
1134    members: usize,
1135    array_items: usize,
1136}
1137
1138impl JsonBudget {
1139    fn enter<E: de::Error>(&mut self, depth: usize) -> Result<(), E> {
1140        if depth > MAX_JSON_DEPTH || self.nodes >= MAX_JSON_NODES {
1141            return Err(E::custom(JSON_LIMIT_MARKER));
1142        }
1143        self.nodes += 1;
1144        Ok(())
1145    }
1146
1147    fn member<E: de::Error>(&mut self) -> Result<(), E> {
1148        if self.members >= MAX_JSON_MEMBERS {
1149            return Err(E::custom(JSON_LIMIT_MARKER));
1150        }
1151        self.members += 1;
1152        Ok(())
1153    }
1154
1155    fn array_item<E: de::Error>(&mut self) -> Result<(), E> {
1156        if self.array_items >= MAX_JSON_ARRAY_ITEMS {
1157            return Err(E::custom(JSON_LIMIT_MARKER));
1158        }
1159        self.array_items += 1;
1160        Ok(())
1161    }
1162}
1163
1164struct StrictJsonSeed<'a> {
1165    budget: &'a mut JsonBudget,
1166    depth: usize,
1167}
1168
1169impl<'de> DeserializeSeed<'de> for StrictJsonSeed<'_> {
1170    type Value = Value;
1171
1172    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1173    where
1174        D: Deserializer<'de>,
1175    {
1176        self.budget.enter::<D::Error>(self.depth)?;
1177        deserializer.deserialize_any(StrictJsonVisitor {
1178            budget: self.budget,
1179            depth: self.depth,
1180        })
1181    }
1182}
1183
1184struct StrictJsonVisitor<'a> {
1185    budget: &'a mut JsonBudget,
1186    depth: usize,
1187}
1188
1189impl<'de> Visitor<'de> for StrictJsonVisitor<'_> {
1190    type Value = Value;
1191
1192    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1193        formatter.write_str("bounded Cert Spotter JSON")
1194    }
1195
1196    fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1197        Ok(Value::Null)
1198    }
1199
1200    fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1201        Ok(Value::Null)
1202    }
1203
1204    fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
1205        Ok(Value::Bool(value))
1206    }
1207
1208    fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
1209        Ok(Value::Number(Number::from(value)))
1210    }
1211
1212    fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
1213        Ok(Value::Number(Number::from(value)))
1214    }
1215
1216    fn visit_f64<E: de::Error>(self, value: f64) -> Result<Self::Value, E> {
1217        Number::from_f64(value)
1218            .map(Value::Number)
1219            .ok_or_else(|| E::custom("non-finite JSON number"))
1220    }
1221
1222    fn visit_borrowed_str<E: de::Error>(self, value: &'de str) -> Result<Self::Value, E> {
1223        self.visit_str(value)
1224    }
1225
1226    fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
1227        if value.len() > MAX_JSON_STRING_BYTES {
1228            return Err(E::custom(JSON_LIMIT_MARKER));
1229        }
1230        Ok(Value::String(value.to_owned()))
1231    }
1232
1233    fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
1234        if value.len() > MAX_JSON_STRING_BYTES {
1235            return Err(E::custom(JSON_LIMIT_MARKER));
1236        }
1237        Ok(Value::String(value))
1238    }
1239
1240    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1241    where
1242        A: SeqAccess<'de>,
1243    {
1244        let mut values = Vec::new();
1245        while let Some(value) = sequence.next_element_seed(StrictJsonSeed {
1246            budget: &mut *self.budget,
1247            depth: self.depth + 1,
1248        })? {
1249            self.budget.array_item::<A::Error>()?;
1250            values.push(value);
1251        }
1252        Ok(Value::Array(values))
1253    }
1254
1255    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1256    where
1257        A: MapAccess<'de>,
1258    {
1259        let mut values = serde_json::Map::new();
1260        while let Some(key) = map.next_key_seed(StrictKeySeed)? {
1261            if values.contains_key(&key) {
1262                return Err(de::Error::custom(DUPLICATE_KEY_MARKER));
1263            }
1264            self.budget.member::<A::Error>()?;
1265            let value = map.next_value_seed(StrictJsonSeed {
1266                budget: &mut *self.budget,
1267                depth: self.depth + 1,
1268            })?;
1269            values.insert(key, value);
1270        }
1271        Ok(Value::Object(values))
1272    }
1273}
1274
1275struct StrictKeySeed;
1276
1277impl<'de> DeserializeSeed<'de> for StrictKeySeed {
1278    type Value = String;
1279
1280    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1281    where
1282        D: Deserializer<'de>,
1283    {
1284        deserializer.deserialize_str(StrictKeyVisitor)
1285    }
1286}
1287
1288struct StrictKeyVisitor;
1289
1290impl Visitor<'_> for StrictKeyVisitor {
1291    type Value = String;
1292
1293    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1294        formatter.write_str("a bounded Cert Spotter object key")
1295    }
1296
1297    fn visit_borrowed_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
1298        self.visit_str(value)
1299    }
1300
1301    fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
1302        if value.len() > MAX_JSON_KEY_BYTES {
1303            return Err(E::custom(JSON_LIMIT_MARKER));
1304        }
1305        Ok(value.to_owned())
1306    }
1307
1308    fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
1309        if value.len() > MAX_JSON_KEY_BYTES {
1310            return Err(E::custom(JSON_LIMIT_MARKER));
1311        }
1312        Ok(value)
1313    }
1314}
1315
1316fn parse_strict_json(bytes: &[u8]) -> Result<Value, StrictJsonError> {
1317    let mut budget = JsonBudget::default();
1318    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
1319    let value = StrictJsonSeed {
1320        budget: &mut budget,
1321        depth: 0,
1322    }
1323    .deserialize(&mut deserializer)
1324    .map_err(classify_json_error)?;
1325    deserializer.end().map_err(classify_json_error)?;
1326    Ok(value)
1327}
1328
1329fn classify_json_error(error: serde_json::Error) -> StrictJsonError {
1330    let message = error.to_string();
1331    if message.contains(DUPLICATE_KEY_MARKER) {
1332        StrictJsonError::DuplicateKey
1333    } else if message.contains(JSON_LIMIT_MARKER) {
1334        StrictJsonError::LimitExceeded
1335    } else {
1336        StrictJsonError::Malformed
1337    }
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342    use serde_json::{json, Value};
1343
1344    use super::*;
1345
1346    fn production_policy_value() -> Value {
1347        json!({
1348            "schema": RECON_CT_PROVIDER_POLICY_SCHEMA,
1349            "revision": "rev-2026.09",
1350            "query_domain": "Example.COM",
1351            "query_reference": "scope-reference-1",
1352            "execution_mode": "production",
1353            "provider_use_authorized": true,
1354            "privacy_disclosure_acknowledged": true
1355        })
1356    }
1357
1358    fn production_policy() -> CertSpotterPolicy {
1359        parse_recon_ct_provider_policy(&serde_json::to_vec(&production_policy_value()).unwrap())
1360            .unwrap()
1361    }
1362
1363    fn accumulator() -> CertSpotterResponseAccumulator {
1364        CertSpotterResponseAccumulator::for_policy(&production_policy())
1365    }
1366
1367    #[test]
1368    fn parses_production_policy_and_builds_exact_urls() {
1369        let policy = production_policy();
1370        assert_eq!(policy.schema(), RECON_CT_PROVIDER_POLICY_SCHEMA);
1371        assert_eq!(policy.revision(), "rev-2026.09");
1372        assert_eq!(policy.query_domain(), "example.com");
1373        assert_eq!(policy.query_reference(), "scope-reference-1");
1374        assert_eq!(
1375            policy.execution_mode(),
1376            ReconCtProviderExecutionMode::Production
1377        );
1378        assert_eq!(policy.provider_origin(), CERT_SPOTTER_PRODUCTION_ORIGIN);
1379        assert!(policy.provider_use_authorized());
1380        assert!(policy.privacy_disclosure_acknowledged());
1381        assert_eq!(
1382            policy.build_request_url(None).unwrap().as_str(),
1383            "https://api.certspotter.com/v1/issuances?domain=example.com&expand=dns_names"
1384        );
1385        assert_eq!(
1386            build_cert_spotter_request_url(&policy, Some("42"))
1387                .unwrap()
1388                .as_str(),
1389            "https://api.certspotter.com/v1/issuances?domain=example.com&expand=dns_names&after=42"
1390        );
1391    }
1392
1393    #[test]
1394    fn enforces_production_and_owned_loopback_origins() {
1395        let mut production = production_policy_value();
1396        production["provider_origin"] = json!("https://api.certspotter.com");
1397        assert_eq!(
1398            parse_recon_ct_provider_policy(&serde_json::to_vec(&production).unwrap()),
1399            Err(CertSpotterPolicyError::ProductionOriginMustBeAbsent)
1400        );
1401        production["provider_origin"] = Value::Null;
1402        assert_eq!(
1403            parse_recon_ct_provider_policy(&serde_json::to_vec(&production).unwrap()),
1404            Err(CertSpotterPolicyError::ProductionOriginMustBeAbsent)
1405        );
1406
1407        let mut fixture = production_policy_value();
1408        fixture["execution_mode"] = json!("owned_loopback_fixture");
1409        assert_eq!(
1410            parse_recon_ct_provider_policy(&serde_json::to_vec(&fixture).unwrap()),
1411            Err(CertSpotterPolicyError::FixtureOriginRequired)
1412        );
1413
1414        for origin in [
1415            "https://127.0.0.1:43123",
1416            "http://localhost:43123",
1417            "http://192.0.2.10:43123",
1418            "http://127.0.0.1",
1419            "http://127.0.0.1:43123/path",
1420            "http://user@127.0.0.1:43123",
1421            "http://127.0.0.1:43123?query",
1422        ] {
1423            fixture["provider_origin"] = json!(origin);
1424            assert_eq!(
1425                parse_recon_ct_provider_policy(&serde_json::to_vec(&fixture).unwrap()),
1426                Err(CertSpotterPolicyError::InvalidFixtureOrigin),
1427                "origin {origin} unexpectedly passed"
1428            );
1429        }
1430
1431        fixture["provider_origin"] = json!("http://127.0.0.1:43123/");
1432        let policy =
1433            parse_recon_ct_provider_policy(&serde_json::to_vec(&fixture).unwrap()).unwrap();
1434        assert_eq!(
1435            policy.execution_mode(),
1436            ReconCtProviderExecutionMode::OwnedLoopbackFixture
1437        );
1438        assert_eq!(policy.provider_origin(), "http://127.0.0.1:43123");
1439        assert_eq!(
1440            policy.build_request_url(None).unwrap().as_str(),
1441            "http://127.0.0.1:43123/v1/issuances?domain=example.com&expand=dns_names"
1442        );
1443    }
1444
1445    #[test]
1446    fn rejects_invalid_policy_acknowledgements_and_closed_fields() {
1447        let mut value = production_policy_value();
1448        value["provider_use_authorized"] = json!(false);
1449        assert_eq!(
1450            parse_recon_ct_provider_policy(&serde_json::to_vec(&value).unwrap()),
1451            Err(CertSpotterPolicyError::ProviderUseNotAuthorized)
1452        );
1453        value = production_policy_value();
1454        value["privacy_disclosure_acknowledged"] = json!(false);
1455        assert_eq!(
1456            parse_recon_ct_provider_policy(&serde_json::to_vec(&value).unwrap()),
1457            Err(CertSpotterPolicyError::PrivacyDisclosureNotAcknowledged)
1458        );
1459        value = production_policy_value();
1460        value["future"] = json!(true);
1461        assert_eq!(
1462            parse_recon_ct_provider_policy(&serde_json::to_vec(&value).unwrap()),
1463            Err(CertSpotterPolicyError::InvalidPolicy)
1464        );
1465    }
1466
1467    #[test]
1468    fn rejects_invalid_query_domains_and_opaque_values() {
1469        for domain in [
1470            "localhost",
1471            "*.example.com",
1472            "127.0.0.1",
1473            "2001:db8::1",
1474            "example.com.",
1475            "-bad.example",
1476            "bad-.example",
1477            "münich.example",
1478        ] {
1479            let mut value = production_policy_value();
1480            value["query_domain"] = json!(domain);
1481            assert_eq!(
1482                parse_recon_ct_provider_policy(&serde_json::to_vec(&value).unwrap()),
1483                Err(CertSpotterPolicyError::InvalidQueryDomain),
1484                "domain {domain} unexpectedly passed"
1485            );
1486        }
1487
1488        let mut value = production_policy_value();
1489        value["revision"] = json!(" revision");
1490        assert_eq!(
1491            parse_recon_ct_provider_policy(&serde_json::to_vec(&value).unwrap()),
1492            Err(CertSpotterPolicyError::InvalidRevision)
1493        );
1494        for reference in [
1495            "scope?secret=1",
1496            "C:/Users/name/private",
1497            "name@example.com",
1498        ] {
1499            value = production_policy_value();
1500            value["query_reference"] = json!(reference);
1501            assert_eq!(
1502                parse_recon_ct_provider_policy(&serde_json::to_vec(&value).unwrap()),
1503                Err(CertSpotterPolicyError::InvalidQueryReference),
1504                "reference {reference} unexpectedly passed"
1505            );
1506        }
1507    }
1508
1509    #[test]
1510    fn policy_parser_rejects_duplicate_keys_and_size_limit() {
1511        let duplicate = br#"{
1512            "schema":"security.recon-certspotter-policy/v1",
1513            "revision":"one",
1514            "\u0072evision":"two",
1515            "query_domain":"example.com",
1516            "query_reference":"scope-1",
1517            "execution_mode":"production",
1518            "provider_use_authorized":true,
1519            "privacy_disclosure_acknowledged":true
1520        }"#;
1521        assert_eq!(
1522            parse_recon_ct_provider_policy(duplicate),
1523            Err(CertSpotterPolicyError::DuplicateJsonKey)
1524        );
1525        assert_eq!(
1526            parse_recon_ct_provider_policy(&vec![b' '; MAX_RECON_CT_PROVIDER_POLICY_BYTES + 1]),
1527            Err(CertSpotterPolicyError::InputTooLarge)
1528        );
1529        assert_eq!(
1530            parse_recon_ct_provider_policy(b"{"),
1531            Err(CertSpotterPolicyError::MalformedJson)
1532        );
1533    }
1534
1535    #[test]
1536    fn validates_opaque_after_cursor_without_query_interpolation() {
1537        let policy = production_policy();
1538        for cursor in ["", "line\nbreak", "null\0byte"] {
1539            assert_eq!(
1540                policy.build_request_url(Some(cursor)),
1541                Err(CertSpotterRequestError::InvalidAfterCursor),
1542                "cursor {cursor} unexpectedly passed"
1543            );
1544            assert_eq!(
1545                CertSpotterResponseAccumulator::with_after(&policy, Some(cursor)),
1546                Err(CertSpotterRequestError::InvalidAfterCursor)
1547            );
1548        }
1549        for cursor in ["01", "-1", "1&expand=cert", "opaque/雪 ?"] {
1550            let url = policy.build_request_url(Some(cursor)).unwrap();
1551            let pairs = url.query_pairs().collect::<Vec<_>>();
1552            assert_eq!(
1553                pairs,
1554                vec![
1555                    ("domain".into(), "example.com".into()),
1556                    ("expand".into(), "dns_names".into()),
1557                    ("after".into(), cursor.into())
1558                ]
1559            );
1560        }
1561        let oversized = "a".repeat(MAX_ISSUANCE_ID_BYTES + 1);
1562        assert_eq!(
1563            policy.build_request_url(Some(&oversized)),
1564            Err(CertSpotterRequestError::InvalidAfterCursor)
1565        );
1566    }
1567
1568    #[test]
1569    fn retains_only_unique_equal_or_subordinate_ascii_names() {
1570        let mut accumulator = accumulator();
1571        let page = json!([{
1572            "id": "10",
1573            "dns_names": [
1574                "example.com",
1575                "WWW.Example.COM",
1576                "*.example.com",
1577                "evil.test",
1578                "münich.example.com",
1579                "192.0.2.1",
1580                "www.example.com"
1581            ],
1582            "future": {"nested": [true, null, 7]}
1583        }]);
1584        assert_eq!(
1585            accumulator
1586                .ingest_page(&serde_json::to_vec(&page).unwrap())
1587                .unwrap(),
1588            CertSpotterPageOutcome::Continue {
1589                after: "10".to_owned()
1590            }
1591        );
1592        assert_eq!(accumulator.next_after(), Some("10"));
1593        assert_eq!(
1594            accumulator
1595                .hypotheses()
1596                .iter()
1597                .map(CertSpotterNameHypothesis::name)
1598                .collect::<Vec<_>>(),
1599            vec!["example.com", "www.example.com", "*.example.com"]
1600        );
1601
1602        assert_eq!(
1603            accumulator.ingest_page(b"[]").unwrap(),
1604            CertSpotterPageOutcome::Terminal(CertSpotterTerminal::ProviderExhausted)
1605        );
1606        let result = accumulator
1607            .into_result(CertSpotterTerminal::ProviderExhausted)
1608            .unwrap();
1609        let audit = result.audit();
1610        assert_eq!(audit.page_count(), 2);
1611        assert_eq!(audit.issuance_count(), 1);
1612        assert_eq!(audit.retained_name_count(), 3);
1613        assert_eq!(audit.foreign_name_count(), 1);
1614        assert_eq!(audit.duplicate_name_count(), 1);
1615        assert_eq!(audit.omitted_name_count(), 2);
1616        assert_eq!(audit.last_cursor(), Some("10"));
1617        assert_eq!(
1618            audit.completeness(),
1619            CertSpotterCompleteness::ProviderExhaustedAsObserved
1620        );
1621        assert_eq!(
1622            audit
1623                .claim_limits()
1624                .iter()
1625                .map(|limit| limit.as_str())
1626                .collect::<Vec<_>>(),
1627            vec![
1628                "hypotheses_only",
1629                "scan_authority_not_granted",
1630                "ownership_not_established",
1631                "currentness_not_established",
1632                "source_authentication_not_established"
1633            ]
1634        );
1635    }
1636
1637    #[test]
1638    fn opaque_cursor_rejects_repetition_without_inventing_ordering() {
1639        let policy = production_policy();
1640        let mut repeated =
1641            CertSpotterResponseAccumulator::with_after(&policy, Some("opaque-z")).unwrap();
1642        assert_eq!(
1643            repeated.ingest_page(br#"[{"id":"opaque-z","dns_names":[]}]"#),
1644            Err(CertSpotterResponseError::NonProgressingIssuanceId)
1645        );
1646
1647        let mut resumed =
1648            CertSpotterResponseAccumulator::with_after(&policy, Some("opaque-z")).unwrap();
1649        assert_eq!(
1650            resumed
1651                .ingest_page(br#"[{"id":"opaque-a","dns_names":[]}]"#)
1652                .unwrap(),
1653            CertSpotterPageOutcome::Continue {
1654                after: "opaque-a".to_owned()
1655            }
1656        );
1657        assert_eq!(
1658            resumed.ingest_page(br#"[{"id":"opaque-a","dns_names":[]}]"#),
1659            Err(CertSpotterResponseError::DuplicateIssuanceId)
1660        );
1661        assert_eq!(resumed.page_count(), 1);
1662        assert_eq!(resumed.next_after(), Some("opaque-a"));
1663
1664        let mut accumulator = accumulator();
1665        assert_eq!(
1666            accumulator
1667                .ingest_page(
1668                    br#"[{"id":"opaque-z","dns_names":[]},{"id":"opaque-a","dns_names":[]}]"#
1669                )
1670                .unwrap(),
1671            CertSpotterPageOutcome::Continue {
1672                after: "opaque-a".to_owned()
1673            }
1674        );
1675        assert_eq!(accumulator.page_count(), 1);
1676        assert_eq!(accumulator.next_after(), Some("opaque-a"));
1677    }
1678
1679    #[test]
1680    fn rejects_duplicate_keys_at_any_response_depth() {
1681        let mut accumulator = accumulator();
1682        assert_eq!(
1683            accumulator.ingest_page(br#"[{"id":"1","\u0069d":"2","dns_names":[]}]"#),
1684            Err(CertSpotterResponseError::DuplicateJsonKey)
1685        );
1686        assert_eq!(
1687            accumulator.ingest_page(br#"[{"id":"1","dns_names":[],"future":{"a":1,"a":2}}]"#),
1688            Err(CertSpotterResponseError::DuplicateJsonKey)
1689        );
1690    }
1691
1692    #[test]
1693    fn rejects_malformed_roots_and_required_field_types() {
1694        let cases: &[(&[u8], CertSpotterResponseError)] = &[
1695            (b"{", CertSpotterResponseError::MalformedJson),
1696            (b"{}", CertSpotterResponseError::InvalidRoot),
1697            (b"[null]", CertSpotterResponseError::InvalidIssuance),
1698            (
1699                br#"[{"dns_names":[]}]"#,
1700                CertSpotterResponseError::InvalidIssuanceId,
1701            ),
1702            (
1703                br#"[{"id":1,"dns_names":[]}]"#,
1704                CertSpotterResponseError::InvalidIssuanceId,
1705            ),
1706            (
1707                br#"[{"id":"\u0000","dns_names":[]}]"#,
1708                CertSpotterResponseError::InvalidIssuanceId,
1709            ),
1710            (
1711                br#"[{"id":"1"}]"#,
1712                CertSpotterResponseError::InvalidDnsNames,
1713            ),
1714            (
1715                br#"[{"id":"1","dns_names":"example.com"}]"#,
1716                CertSpotterResponseError::InvalidDnsNames,
1717            ),
1718            (
1719                br#"[{"id":"1","dns_names":[7]}]"#,
1720                CertSpotterResponseError::InvalidDnsNames,
1721            ),
1722        ];
1723        for (bytes, expected) in cases {
1724            assert_eq!(accumulator().ingest_page(bytes), Err(*expected));
1725        }
1726    }
1727
1728    #[test]
1729    fn rejects_page_and_json_structure_bounds() {
1730        assert_eq!(
1731            accumulator().ingest_page(&vec![b' '; MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES + 1]),
1732            Err(CertSpotterResponseError::PageTooLarge)
1733        );
1734
1735        let oversized_string = "x".repeat(MAX_JSON_STRING_BYTES + 1);
1736        let page = json!([{"id":"1", "dns_names":[], "future":oversized_string}]);
1737        assert_eq!(
1738            accumulator().ingest_page(&serde_json::to_vec(&page).unwrap()),
1739            Err(CertSpotterResponseError::StructuralLimitExceeded)
1740        );
1741
1742        let oversized_key = "k".repeat(MAX_JSON_KEY_BYTES + 1);
1743        let page = format!(r#"[{{"id":"1","dns_names":[],"{oversized_key}":true}}]"#);
1744        assert_eq!(
1745            accumulator().ingest_page(page.as_bytes()),
1746            Err(CertSpotterResponseError::StructuralLimitExceeded)
1747        );
1748
1749        let mut nested = json!(null);
1750        for _ in 0..=MAX_JSON_DEPTH {
1751            nested = json!([nested]);
1752        }
1753        assert_eq!(
1754            accumulator().ingest_page(&serde_json::to_vec(&nested).unwrap()),
1755            Err(CertSpotterResponseError::StructuralLimitExceeded)
1756        );
1757    }
1758
1759    #[test]
1760    fn caps_unique_names_and_counts_omissions() {
1761        let mut names = Vec::new();
1762        for index in 0..(MAX_CERT_SPOTTER_RETAINED_NAMES + 2) {
1763            names.push(format!("host-{index}.example.com"));
1764        }
1765        let page = json!([{"id":"1", "dns_names":names}]);
1766        let mut accumulator = accumulator();
1767        assert_eq!(
1768            accumulator
1769                .ingest_page(&serde_json::to_vec(&page).unwrap())
1770                .unwrap(),
1771            CertSpotterPageOutcome::Terminal(CertSpotterTerminal::HypothesisLimitReached)
1772        );
1773        let result = accumulator
1774            .into_result(CertSpotterTerminal::HypothesisLimitReached)
1775            .unwrap();
1776        assert_eq!(result.hypotheses().len(), MAX_CERT_SPOTTER_RETAINED_NAMES);
1777        assert_eq!(result.audit().omitted_name_count(), 2);
1778        assert_eq!(
1779            result.audit().completeness(),
1780            CertSpotterCompleteness::BoundedPartial
1781        );
1782    }
1783
1784    #[test]
1785    fn page_limit_is_terminal_and_finalization_is_consistent() {
1786        let mut accumulator = accumulator();
1787        assert!(matches!(
1788            accumulator
1789                .ingest_page(br#"[{"id":"1","dns_names":[]}]"#)
1790                .unwrap(),
1791            CertSpotterPageOutcome::Continue { .. }
1792        ));
1793        assert_eq!(
1794            accumulator
1795                .ingest_page(br#"[{"id":"2","dns_names":[]}]"#)
1796                .unwrap(),
1797            CertSpotterPageOutcome::Terminal(CertSpotterTerminal::PageLimitReached)
1798        );
1799        assert_eq!(
1800            accumulator.ingest_page(b"[]"),
1801            Err(CertSpotterResponseError::CollectionAlreadyTerminal)
1802        );
1803        assert_eq!(
1804            accumulator
1805                .clone()
1806                .into_result(CertSpotterTerminal::ProviderExhausted),
1807            Err(CertSpotterResponseError::TerminalMismatch)
1808        );
1809        let result = accumulator
1810            .into_result(CertSpotterTerminal::PageLimitReached)
1811            .unwrap();
1812        assert_eq!(result.audit().page_count(), MAX_CERT_SPOTTER_RESPONSE_PAGES);
1813    }
1814
1815    #[test]
1816    fn external_terminal_can_finalize_partial_or_empty_state() {
1817        let result = accumulator()
1818            .into_result(CertSpotterTerminal::TransportFailed)
1819            .unwrap();
1820        assert_eq!(result.audit().page_count(), 0);
1821        assert_eq!(
1822            result.audit().completeness(),
1823            CertSpotterCompleteness::Unknown
1824        );
1825        assert_eq!(
1826            accumulator().into_result(CertSpotterTerminal::ProviderExhausted),
1827            Err(CertSpotterResponseError::TerminalMismatch)
1828        );
1829    }
1830}