1use 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
19pub const RECON_CT_PROVIDER_POLICY_SCHEMA: &str = "security.recon-certspotter-policy/v1";
21pub const CERT_SPOTTER_PRODUCTION_ORIGIN: &str = "https://api.certspotter.com";
23pub const MAX_RECON_CT_PROVIDER_POLICY_BYTES: usize = 16 * 1024;
25pub const MAX_CERT_SPOTTER_RESPONSE_PAGE_BYTES: usize = 256 * 1024;
27pub const MAX_CERT_SPOTTER_RESPONSE_PAGES: usize = 2;
29pub const MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES: usize = 512 * 1024;
31pub const MAX_CERT_SPOTTER_ELAPSED: Duration = Duration::from_secs(10);
33pub const MAX_CERT_SPOTTER_IN_FLIGHT_REQUESTS: usize = 1;
35pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub enum ReconCtProviderExecutionMode {
55 Production,
57 OwnedLoopbackFixture,
59}
60
61impl ReconCtProviderExecutionMode {
62 #[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
72pub type CertSpotterExecutionMode = ReconCtProviderExecutionMode;
74
75#[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
85pub type ReconCtProviderPolicy = CertSpotterPolicy;
87
88impl CertSpotterPolicy {
89 pub fn parse_json(bytes: &[u8]) -> Result<Self, ReconCtProviderPolicyError> {
91 parse_recon_ct_provider_policy(bytes)
92 }
93
94 #[must_use]
96 pub const fn schema(&self) -> &'static str {
97 RECON_CT_PROVIDER_POLICY_SCHEMA
98 }
99
100 #[must_use]
102 pub fn revision(&self) -> &str {
103 &self.revision
104 }
105
106 #[must_use]
108 pub fn query_domain(&self) -> &str {
109 &self.query_domain
110 }
111
112 #[must_use]
114 pub fn query_reference(&self) -> &str {
115 &self.query_reference
116 }
117
118 #[must_use]
120 pub const fn execution_mode(&self) -> ReconCtProviderExecutionMode {
121 self.execution_mode
122 }
123
124 #[must_use]
129 pub fn provider_origin(&self) -> &str {
130 &self.provider_origin
131 }
132
133 #[must_use]
135 pub const fn provider_use_authorized(&self) -> bool {
136 true
137 }
138
139 #[must_use]
141 pub const fn privacy_disclosure_acknowledged(&self) -> bool {
142 true
143 }
144
145 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
168pub 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
237pub 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#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
247#[non_exhaustive]
248pub enum CertSpotterPolicyError {
249 #[error("Cert Spotter policy input is empty")]
251 EmptyInput,
252 #[error("Cert Spotter policy exceeds its byte limit")]
254 InputTooLarge,
255 #[error("Cert Spotter policy is malformed JSON")]
257 MalformedJson,
258 #[error("Cert Spotter policy contains a duplicate JSON key")]
260 DuplicateJsonKey,
261 #[error("Cert Spotter policy exceeds a JSON structural limit")]
263 StructuralLimitExceeded,
264 #[error("Cert Spotter policy contains an invalid or unsupported value")]
266 InvalidPolicy,
267 #[error("Cert Spotter policy schema is unsupported")]
269 UnsupportedSchema,
270 #[error("Cert Spotter policy revision is invalid")]
272 InvalidRevision,
273 #[error("Cert Spotter policy query domain is invalid")]
275 InvalidQueryDomain,
276 #[error("Cert Spotter policy query reference is invalid")]
278 InvalidQueryReference,
279 #[error("Cert Spotter provider use is not authorized by policy")]
281 ProviderUseNotAuthorized,
282 #[error("Cert Spotter privacy disclosure is not acknowledged by policy")]
284 PrivacyDisclosureNotAcknowledged,
285 #[error("Cert Spotter production policy must not declare provider_origin")]
287 ProductionOriginMustBeAbsent,
288 #[error("Cert Spotter loopback fixture policy requires provider_origin")]
290 FixtureOriginRequired,
291 #[error("Cert Spotter fixture origin is not an owned numeric-loopback HTTP origin")]
293 InvalidFixtureOrigin,
294}
295
296pub 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#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
311#[non_exhaustive]
312pub enum CertSpotterRequestError {
313 #[error("Cert Spotter after cursor is invalid")]
315 InvalidAfterCursor,
316 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
343pub enum CertSpotterClaimLimit {
344 HypothesesOnly,
346 ScanAuthorityNotGranted,
348 OwnershipNotEstablished,
350 CurrentnessNotEstablished,
352 SourceAuthenticationNotEstablished,
354}
355
356impl CertSpotterClaimLimit {
357 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
380pub enum CertSpotterTerminal {
381 ProviderExhausted,
383 HypothesisLimitReached,
385 PageLimitReached,
387 DeadlineReached,
389 ParentAuthorityUnavailable,
391 TransportFailed,
393 Cancelled,
395}
396
397impl CertSpotterTerminal {
398 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
440pub enum CertSpotterCompleteness {
441 ProviderExhaustedAsObserved,
445 BoundedPartial,
447 Unknown,
449}
450
451impl CertSpotterCompleteness {
452 #[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#[derive(Clone, Debug, Eq, PartialEq)]
465pub struct CertSpotterNameHypothesis {
466 name: String,
467 first_issuance_id: String,
468}
469
470impl CertSpotterNameHypothesis {
471 #[must_use]
476 pub fn name(&self) -> &str {
477 &self.name
478 }
479
480 #[must_use]
482 pub fn first_issuance_id(&self) -> &str {
483 &self.first_issuance_id
484 }
485
486 #[must_use]
488 pub const fn claim_limits(&self) -> &'static [CertSpotterClaimLimit] {
489 &CERT_SPOTTER_CLAIM_LIMITS
490 }
491}
492
493#[derive(Clone, Debug, Eq, PartialEq)]
495pub enum CertSpotterPageOutcome {
496 Continue {
498 after: String,
500 },
501 Terminal(CertSpotterTerminal),
503}
504
505#[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 #[must_use]
534 pub fn for_policy(policy: &CertSpotterPolicy) -> Self {
535 Self::from_policy_and_after(policy, None)
536 }
537
538 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 #[must_use]
573 pub fn next_after(&self) -> Option<&str> {
574 self.next_after.as_deref()
575 }
576
577 #[must_use]
579 pub const fn page_count(&self) -> usize {
580 self.page_count
581 }
582
583 #[must_use]
585 pub const fn response_bytes(&self) -> usize {
586 self.response_bytes
587 }
588
589 #[must_use]
591 pub fn hypotheses(&self) -> &[CertSpotterNameHypothesis] {
592 &self.hypotheses
593 }
594
595 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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
769pub struct CertSpotterResult {
770 hypotheses: Vec<CertSpotterNameHypothesis>,
771 audit: CertSpotterAudit,
772}
773
774impl CertSpotterResult {
775 #[must_use]
777 pub fn hypotheses(&self) -> &[CertSpotterNameHypothesis] {
778 &self.hypotheses
779 }
780
781 #[must_use]
783 pub const fn audit(&self) -> &CertSpotterAudit {
784 &self.audit
785 }
786
787 #[must_use]
789 pub const fn claim_limits(&self) -> &'static [CertSpotterClaimLimit] {
790 &CERT_SPOTTER_CLAIM_LIMITS
791 }
792}
793
794#[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 #[must_use]
818 pub const fn schema(&self) -> &'static str {
819 RECON_CT_PROVIDER_POLICY_SCHEMA
820 }
821
822 #[must_use]
824 pub fn policy_revision(&self) -> &str {
825 &self.policy_revision
826 }
827
828 #[must_use]
830 pub fn query_domain(&self) -> &str {
831 &self.query_domain
832 }
833
834 #[must_use]
836 pub fn query_reference(&self) -> &str {
837 &self.query_reference
838 }
839
840 #[must_use]
842 pub const fn execution_mode(&self) -> ReconCtProviderExecutionMode {
843 self.execution_mode
844 }
845
846 #[must_use]
848 pub fn provider_origin(&self) -> &str {
849 &self.provider_origin
850 }
851
852 #[must_use]
854 pub fn initial_after(&self) -> Option<&str> {
855 self.initial_after.as_deref()
856 }
857
858 #[must_use]
860 pub fn last_cursor(&self) -> Option<&str> {
861 self.last_cursor.as_deref()
862 }
863
864 #[must_use]
866 pub const fn page_count(&self) -> usize {
867 self.page_count
868 }
869
870 #[must_use]
872 pub const fn response_bytes(&self) -> usize {
873 self.response_bytes
874 }
875
876 #[must_use]
878 pub const fn issuance_count(&self) -> usize {
879 self.issuance_count
880 }
881
882 #[must_use]
884 pub const fn retained_name_count(&self) -> usize {
885 self.retained_name_count
886 }
887
888 #[must_use]
890 pub const fn foreign_name_count(&self) -> usize {
891 self.foreign_name_count
892 }
893
894 #[must_use]
896 pub const fn duplicate_name_count(&self) -> usize {
897 self.duplicate_name_count
898 }
899
900 #[must_use]
902 pub const fn omitted_name_count(&self) -> usize {
903 self.omitted_name_count
904 }
905
906 #[must_use]
908 pub const fn terminal(&self) -> CertSpotterTerminal {
909 self.terminal
910 }
911
912 #[must_use]
914 pub const fn completeness(&self) -> CertSpotterCompleteness {
915 self.completeness
916 }
917
918 #[must_use]
920 pub const fn claim_limits(&self) -> &'static [CertSpotterClaimLimit] {
921 &CERT_SPOTTER_CLAIM_LIMITS
922 }
923
924 #[must_use]
926 pub const fn maximum_pages(&self) -> usize {
927 MAX_CERT_SPOTTER_RESPONSE_PAGES
928 }
929
930 #[must_use]
932 pub const fn maximum_response_bytes(&self) -> usize {
933 MAX_CERT_SPOTTER_TOTAL_RESPONSE_BYTES
934 }
935
936 #[must_use]
938 pub const fn maximum_elapsed(&self) -> Duration {
939 MAX_CERT_SPOTTER_ELAPSED
940 }
941
942 #[must_use]
944 pub const fn maximum_in_flight_requests(&self) -> usize {
945 MAX_CERT_SPOTTER_IN_FLIGHT_REQUESTS
946 }
947}
948
949#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
951#[non_exhaustive]
952pub enum CertSpotterResponseError {
953 #[error("Cert Spotter response page exceeds its byte limit")]
955 PageTooLarge,
956 #[error("Cert Spotter response collection exceeds its byte limit")]
958 TotalTooLarge,
959 #[error("Cert Spotter response exceeds its page limit")]
961 TooManyPages,
962 #[error("Cert Spotter response collection is already terminal")]
964 CollectionAlreadyTerminal,
965 #[error("Cert Spotter response is malformed JSON")]
967 MalformedJson,
968 #[error("Cert Spotter response contains a duplicate JSON key")]
970 DuplicateJsonKey,
971 #[error("Cert Spotter response exceeds a JSON structural limit")]
973 StructuralLimitExceeded,
974 #[error("Cert Spotter response root must be an array")]
976 InvalidRoot,
977 #[error("Cert Spotter response contains too many issuances")]
979 TooManyIssuances,
980 #[error("Cert Spotter response contains an invalid issuance object")]
982 InvalidIssuance,
983 #[error("Cert Spotter response contains an invalid issuance ID")]
985 InvalidIssuanceId,
986 #[error("Cert Spotter response contains a duplicate issuance ID")]
988 DuplicateIssuanceId,
989 #[error("Cert Spotter response repeats the active issuance cursor")]
991 NonProgressingIssuanceId,
992 #[error("Cert Spotter response contains invalid dns_names")]
994 InvalidDnsNames,
995 #[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}