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