1use std::{collections::BTreeSet, fmt};
8
9use serde::Serialize;
10use sha2::{Digest, Sha256};
11use termivar_core::{
12 ApiVisibilityDimension, ApiVocabularyError, ComparisonId, EntityId, OpaqueContextId,
13 ResourceScopeId,
14};
15use thiserror::Error;
16
17use crate::web_runtime::authority::authenticated_transport_is_allowed;
18use crate::{
19 ApiComparisonProfile, ApiObservationCommitReceipt, ApiObservationError, ApiObservationReceipt,
20 ApiVisibilityReview, CanonicalizationVersion, ComparisonAlgorithmVersion, HttpEvidenceError,
21 HttpProbe, HttpProbeMethod, ProfiledApiVisibilityComparison, ProfiledApiVisibilityError,
22 ProjectionPolicyId, RuntimeLimitExceeded, RuntimeUsage, TransportDispatchAudit,
23 CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION,
24};
25
26const TEMPLATE_DIGEST_DOMAIN: &[u8] = b"venom.api-visibility.request-template.v1\0";
27const OPERATION_DIGEST_DOMAIN: &[u8] = b"venom.api-visibility.runtime-operation.v1\0";
28const MAX_CONTEXT_HEADER_NAMES: usize = 32;
29const MAX_DIFFERENTIAL_HEADER_COUNT: usize = 64;
30const MAX_DIFFERENTIAL_HEADER_VALUE_BYTES: usize = 16 * 1024;
31const MAX_DIFFERENTIAL_TOTAL_HEADER_BYTES: usize = 64 * 1024;
32const MAX_DIFFERENTIAL_URL_BYTES: usize = 8 * 1024;
33const PRIMARY_AUTH_CONTEXT_HEADERS: &[&str] = &[
34 "authorization",
35 "cookie",
36 "x-access-token",
37 "x-api-key",
38 "x-auth-token",
39 "x-session-token",
40];
41const SUPPORTING_AUTH_CONTEXT_HEADERS: &[&str] = &["x-csrf-token", "x-xsrf-token"];
42
43mod execution;
44
45pub struct ApiVisibilityContextProbe {
51 context_id: OpaqueContextId,
52 probe: HttpProbe,
53}
54
55impl ApiVisibilityContextProbe {
56 pub fn new(
58 context_id: impl Into<String>,
59 probe: HttpProbe,
60 ) -> Result<Self, ApiVisibilityDifferentialRequestError> {
61 Ok(Self {
62 context_id: OpaqueContextId::new(context_id)?,
63 probe,
64 })
65 }
66
67 pub fn context_id(&self) -> &str {
69 self.context_id.as_str()
70 }
71
72 pub fn probe(&self) -> &HttpProbe {
74 &self.probe
75 }
76}
77
78impl fmt::Debug for ApiVisibilityContextProbe {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 formatter
81 .debug_struct("ApiVisibilityContextProbe")
82 .field("context_id", &"<redacted>")
83 .field("probe", &"<redacted>")
84 .finish()
85 }
86}
87
88pub struct ApiVisibilityDifferentialRequest {
96 comparison_id: ComparisonId,
97 resource_scope: EntityId,
98 resource_scope_id: ResourceScopeId,
99 control: ApiVisibilityContextProbe,
100 candidate: ApiVisibilityContextProbe,
101 context_header_names: BTreeSet<String>,
102 profile: ApiComparisonProfile,
103 dimension: ApiVisibilityDimension,
104 observed_at_ms: u64,
105 request_template_sha256: String,
106 operation_sha256: String,
107}
108
109impl ApiVisibilityDifferentialRequest {
110 #[allow(clippy::too_many_arguments)]
112 pub fn new<I, S>(
113 comparison_id: impl Into<String>,
114 resource_scope: EntityId,
115 control: ApiVisibilityContextProbe,
116 candidate: ApiVisibilityContextProbe,
117 context_header_names: I,
118 profile: ApiComparisonProfile,
119 dimension: ApiVisibilityDimension,
120 observed_at_ms: u64,
121 ) -> Result<Self, ApiVisibilityDifferentialRequestError>
122 where
123 I: IntoIterator<Item = S>,
124 S: Into<String>,
125 {
126 let comparison_id = ComparisonId::new(comparison_id)?;
127 let resource_scope_id = ResourceScopeId::new(resource_scope.as_str())?;
128 if control.context_id == candidate.context_id {
129 return Err(ApiVisibilityDifferentialRequestError::IdenticalContextHandles);
130 }
131 for probe in [&control.probe, &candidate.probe] {
132 if probe.method() != HttpProbeMethod::Get {
133 return Err(ApiVisibilityDifferentialRequestError::UnsupportedMethod);
134 }
135 if probe.url().fragment().is_some() {
136 return Err(ApiVisibilityDifferentialRequestError::UrlFragment);
137 }
138 }
139 if control.probe.url() != candidate.probe.url() {
140 return Err(ApiVisibilityDifferentialRequestError::RequestTargetMismatch);
141 }
142 if control.probe.url().as_str().len() > MAX_DIFFERENTIAL_URL_BYTES {
143 return Err(ApiVisibilityDifferentialRequestError::RequestTargetTooLong);
144 }
145 if !authenticated_transport_is_allowed(control.probe.url()) {
146 return Err(ApiVisibilityDifferentialRequestError::InsecureAuthenticatedTransport);
147 }
148 if !matches!(
149 dimension,
150 ApiVisibilityDimension::Fields
151 | ApiVisibilityDimension::Resources
152 | ApiVisibilityDimension::Status
153 ) {
154 return Err(ApiVisibilityDifferentialRequestError::UnsupportedDimension);
155 }
156
157 let context_header_names = context_header_names
158 .into_iter()
159 .map(Into::into)
160 .map(|name: String| name.trim().to_ascii_lowercase())
161 .collect::<BTreeSet<_>>();
162 if context_header_names.is_empty() {
163 return Err(ApiVisibilityDifferentialRequestError::EmptyContextHeaders);
164 }
165 if context_header_names.len() > MAX_CONTEXT_HEADER_NAMES
166 || context_header_names.iter().any(String::is_empty)
167 {
168 return Err(ApiVisibilityDifferentialRequestError::InvalidContextHeaders);
169 }
170 if context_header_names
171 .iter()
172 .any(|name| !is_auth_context_header(name))
173 || !context_header_names
174 .iter()
175 .any(|name| PRIMARY_AUTH_CONTEXT_HEADERS.contains(&name.as_str()))
176 {
177 return Err(ApiVisibilityDifferentialRequestError::UnsupportedContextHeader);
178 }
179
180 let all_header_names = control
181 .probe
182 .headers()
183 .keys()
184 .chain(candidate.probe.headers().keys())
185 .collect::<BTreeSet<_>>();
186 if all_header_names.len() > MAX_DIFFERENTIAL_HEADER_COUNT {
187 return Err(ApiVisibilityDifferentialRequestError::TooManyHeaders);
188 }
189 for probe in [&control.probe, &candidate.probe] {
190 let mut total_header_bytes = 0_usize;
191 for (name, value) in probe.headers() {
192 if value.len() > MAX_DIFFERENTIAL_HEADER_VALUE_BYTES {
193 return Err(ApiVisibilityDifferentialRequestError::HeaderValueTooLong);
194 }
195 total_header_bytes = total_header_bytes
196 .saturating_add(name.len())
197 .saturating_add(value.len());
198 }
199 if total_header_bytes > MAX_DIFFERENTIAL_TOTAL_HEADER_BYTES {
200 return Err(ApiVisibilityDifferentialRequestError::TotalHeaderBytesTooLarge);
201 }
202 }
203 if context_header_names.iter().any(|name| {
204 !all_header_names
205 .iter()
206 .any(|present| present.as_str() == name)
207 }) {
208 return Err(ApiVisibilityDifferentialRequestError::UnusedContextHeader);
209 }
210 if all_header_names.iter().any(|name| {
211 !context_header_names.contains(name.as_str())
212 && control.probe.headers().get(name.as_str())
213 != candidate.probe.headers().get(name.as_str())
214 }) {
215 return Err(ApiVisibilityDifferentialRequestError::RequestTemplateMismatch);
216 }
217
218 if !primary_context_headers_differ(&control.probe, &candidate.probe, &context_header_names)
219 {
220 return Err(ApiVisibilityDifferentialRequestError::IdenticalAuthorizationContext);
221 }
222
223 let request_template_sha256 =
224 request_template_digest(&control.probe, &context_header_names, &all_header_names);
225 let operation_sha256 = operation_digest(
226 &comparison_id,
227 &resource_scope,
228 &control.context_id,
229 &candidate.context_id,
230 dimension,
231 observed_at_ms,
232 profile.projection_policy_id().as_bytes(),
233 &request_template_sha256,
234 );
235 Ok(Self {
236 comparison_id,
237 resource_scope,
238 resource_scope_id,
239 control,
240 candidate,
241 context_header_names,
242 profile,
243 dimension,
244 observed_at_ms,
245 request_template_sha256,
246 operation_sha256,
247 })
248 }
249
250 pub fn resource_scope(&self) -> &EntityId {
252 &self.resource_scope
253 }
254
255 pub const fn dimension(&self) -> ApiVisibilityDimension {
257 self.dimension
258 }
259
260 pub const fn profile(&self) -> &ApiComparisonProfile {
262 &self.profile
263 }
264
265 pub const fn observed_at_ms(&self) -> u64 {
267 self.observed_at_ms
268 }
269}
270
271impl fmt::Debug for ApiVisibilityDifferentialRequest {
272 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
273 formatter
274 .debug_struct("ApiVisibilityDifferentialRequest")
275 .field("comparison_id", &"<redacted>")
276 .field("resource_scope", &"<redacted>")
277 .field("control", &"<redacted>")
278 .field("candidate", &"<redacted>")
279 .field("context_header_names", &self.context_header_names)
280 .field("profile", &self.profile)
281 .field("dimension", &self.dimension)
282 .field("observed_at_ms", &self.observed_at_ms)
283 .field("request_template_sha256", &"<redacted>")
284 .field("operation_sha256", &"<redacted>")
285 .finish()
286 }
287}
288
289#[derive(Debug, Error)]
291#[non_exhaustive]
292pub enum ApiVisibilityDifferentialRequestError {
293 #[error(transparent)]
295 Vocabulary(#[from] ApiVocabularyError),
296 #[error("API visibility contexts must use distinct opaque handles")]
298 IdenticalContextHandles,
299 #[error("API visibility differential requests must use GET")]
301 UnsupportedMethod,
302 #[error("API visibility differential requests must not contain URL fragments")]
304 UrlFragment,
305 #[error("API visibility authorization contexts must use the same exact request target")]
307 RequestTargetMismatch,
308 #[error("API visibility request target exceeds the compiled length limit")]
310 RequestTargetTooLong,
311 #[error("API visibility authorization contexts require HTTPS outside loopback fixtures")]
313 InsecureAuthenticatedTransport,
314 #[error("API visibility differential request uses an unsupported dimension")]
316 UnsupportedDimension,
317 #[error("API visibility differential request requires context-owned header names")]
319 EmptyContextHeaders,
320 #[error("API visibility context-header set is invalid")]
322 InvalidContextHeaders,
323 #[error("API visibility context-header set contains a non-credential header")]
325 UnsupportedContextHeader,
326 #[error("API visibility request contains too many headers")]
328 TooManyHeaders,
329 #[error("API visibility request header value exceeds the compiled byte limit")]
331 HeaderValueTooLong,
332 #[error("API visibility request headers exceed the compiled total byte limit")]
334 TotalHeaderBytesTooLarge,
335 #[error("API visibility context-header set contains an unused name")]
337 UnusedContextHeader,
338 #[error("API visibility request templates differ outside authorization context headers")]
340 RequestTemplateMismatch,
341 #[error("API visibility probes contain identical authorization context headers")]
343 IdenticalAuthorizationContext,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
348#[serde(rename_all = "snake_case")]
349#[non_exhaustive]
350pub enum ApiVisibilityLeg {
351 Control,
353 Candidate,
355}
356
357#[derive(Clone, PartialEq, Eq, Serialize)]
362pub struct ApiVisibilityLegReceipt {
363 leg: ApiVisibilityLeg,
364 status: u16,
365 retained_body_bytes: u64,
366 body_truncated: bool,
367 json_compatible_media_type: bool,
368 request_template_sha256: String,
369 response_body_sha256: String,
370}
371
372impl ApiVisibilityLegReceipt {
373 pub const fn leg(&self) -> ApiVisibilityLeg {
375 self.leg
376 }
377
378 pub const fn status(&self) -> u16 {
380 self.status
381 }
382
383 pub const fn retained_body_bytes(&self) -> u64 {
385 self.retained_body_bytes
386 }
387
388 pub const fn body_truncated(&self) -> bool {
390 self.body_truncated
391 }
392
393 pub const fn json_compatible_media_type(&self) -> bool {
395 self.json_compatible_media_type
396 }
397
398 pub fn request_template_sha256(&self) -> &str {
400 &self.request_template_sha256
401 }
402
403 pub fn response_body_sha256(&self) -> &str {
405 &self.response_body_sha256
406 }
407}
408
409impl fmt::Debug for ApiVisibilityLegReceipt {
410 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
411 formatter
412 .debug_struct("ApiVisibilityLegReceipt")
413 .field("leg", &self.leg)
414 .field("status", &self.status)
415 .field("retained_body_bytes", &self.retained_body_bytes)
416 .field("body_truncated", &self.body_truncated)
417 .field(
418 "json_compatible_media_type",
419 &self.json_compatible_media_type,
420 )
421 .field("request_template_sha256", &"<redacted>")
422 .field("response_body_sha256", &"<redacted>")
423 .finish()
424 }
425}
426
427#[derive(Clone, PartialEq, Eq, Serialize)]
429pub struct ApiVisibilityDifferentialAudit {
430 comparison_id: ComparisonId,
431 resource_scope_id: ResourceScopeId,
432 control_context_id: OpaqueContextId,
433 candidate_context_id: OpaqueContextId,
434 dimension: ApiVisibilityDimension,
435 observed_at_ms: u64,
436 comparator_version: ComparisonAlgorithmVersion,
437 canonicalization_version: CanonicalizationVersion,
438 projection_policy_id: ProjectionPolicyId,
439 request_template_sha256: String,
440 operation_sha256: String,
441 control: Option<ApiVisibilityLegReceipt>,
442 candidate: Option<ApiVisibilityLegReceipt>,
443 usage: RuntimeUsage,
444 transport: TransportDispatchAudit,
445}
446
447impl ApiVisibilityDifferentialAudit {
448 fn for_request(request: &ApiVisibilityDifferentialRequest) -> Self {
449 Self {
450 comparison_id: request.comparison_id.clone(),
451 resource_scope_id: request.resource_scope_id.clone(),
452 control_context_id: request.control.context_id.clone(),
453 candidate_context_id: request.candidate.context_id.clone(),
454 dimension: request.dimension,
455 observed_at_ms: request.observed_at_ms,
456 comparator_version: request.profile.algorithm_version(),
457 canonicalization_version: CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION,
458 projection_policy_id: request.profile.projection_policy_id(),
459 request_template_sha256: request.request_template_sha256.clone(),
460 operation_sha256: request.operation_sha256.clone(),
461 control: None,
462 candidate: None,
463 usage: RuntimeUsage::default(),
464 transport: TransportDispatchAudit::default(),
465 }
466 }
467
468 pub const fn comparison_id(&self) -> &ComparisonId {
470 &self.comparison_id
471 }
472
473 pub const fn resource_scope_id(&self) -> &ResourceScopeId {
475 &self.resource_scope_id
476 }
477
478 pub const fn control_context_id(&self) -> &OpaqueContextId {
480 &self.control_context_id
481 }
482
483 pub const fn candidate_context_id(&self) -> &OpaqueContextId {
485 &self.candidate_context_id
486 }
487
488 pub const fn dimension(&self) -> ApiVisibilityDimension {
490 self.dimension
491 }
492
493 pub const fn observed_at_ms(&self) -> u64 {
495 self.observed_at_ms
496 }
497
498 pub const fn comparator_version(&self) -> ComparisonAlgorithmVersion {
500 self.comparator_version
501 }
502
503 pub const fn canonicalization_version(&self) -> CanonicalizationVersion {
505 self.canonicalization_version
506 }
507
508 pub const fn projection_policy_id(&self) -> ProjectionPolicyId {
510 self.projection_policy_id
511 }
512
513 pub fn request_template_sha256(&self) -> &str {
515 &self.request_template_sha256
516 }
517
518 pub fn operation_sha256(&self) -> &str {
520 &self.operation_sha256
521 }
522
523 pub const fn control(&self) -> Option<&ApiVisibilityLegReceipt> {
525 self.control.as_ref()
526 }
527
528 pub const fn candidate(&self) -> Option<&ApiVisibilityLegReceipt> {
530 self.candidate.as_ref()
531 }
532
533 pub const fn usage(&self) -> &RuntimeUsage {
535 &self.usage
536 }
537
538 pub const fn transport(&self) -> &TransportDispatchAudit {
540 &self.transport
541 }
542}
543
544impl fmt::Debug for ApiVisibilityDifferentialAudit {
545 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
546 formatter
547 .debug_struct("ApiVisibilityDifferentialAudit")
548 .field("comparison_id", &self.comparison_id)
549 .field("resource_scope_id", &self.resource_scope_id)
550 .field("control_context_id", &self.control_context_id)
551 .field("candidate_context_id", &self.candidate_context_id)
552 .field("dimension", &self.dimension)
553 .field("observed_at_ms", &self.observed_at_ms)
554 .field("comparator_version", &self.comparator_version)
555 .field("canonicalization_version", &self.canonicalization_version)
556 .field("projection_policy_id", &self.projection_policy_id)
557 .field("request_template_sha256", &"<redacted>")
558 .field("operation_sha256", &"<redacted>")
559 .field("control", &self.control)
560 .field("candidate", &self.candidate)
561 .field("usage", &self.usage)
562 .field("transport", &self.transport)
563 .finish()
564 }
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
569#[serde(rename_all = "snake_case")]
570#[non_exhaustive]
571pub enum ApiVisibilityInconclusiveReason {
572 NotApplicable,
574 BlockedByPolicy,
576 TransportFailure,
578 RequestTimeout,
580 ExecutorFailure,
582 RateLimited,
584 ServerError,
586 TruncatedResponse,
588 NonJsonResponse,
590 MalformedJson,
592 CanonicalizationRejected,
594}
595
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
598#[serde(rename_all = "snake_case")]
599#[non_exhaustive]
600pub enum ApiVisibilityDifferentialDisposition {
601 NoDifferenceObserved,
603 UnresolvedDifference,
605 AwaitHumanReview,
607 Inconclusive,
609 CancelledByHost,
611 RuntimeBudgetLimit,
613}
614
615#[derive(Clone, Serialize)]
617pub struct RuntimeApiVisibilityRunReport {
618 disposition: ApiVisibilityDifferentialDisposition,
619 stopped_leg: Option<ApiVisibilityLeg>,
620 inconclusive_reason: Option<ApiVisibilityInconclusiveReason>,
621 audit: ApiVisibilityDifferentialAudit,
622 comparison: Option<ProfiledApiVisibilityComparison>,
623 observation: Option<ApiObservationReceipt>,
624 review: Option<ApiVisibilityReview>,
625 limit_exceeded: Option<RuntimeLimitExceeded>,
626}
627
628impl RuntimeApiVisibilityRunReport {
629 pub const fn disposition(&self) -> ApiVisibilityDifferentialDisposition {
631 self.disposition
632 }
633
634 pub const fn stopped_leg(&self) -> Option<ApiVisibilityLeg> {
636 self.stopped_leg
637 }
638
639 pub const fn inconclusive_reason(&self) -> Option<ApiVisibilityInconclusiveReason> {
641 self.inconclusive_reason
642 }
643
644 pub const fn audit(&self) -> &ApiVisibilityDifferentialAudit {
646 &self.audit
647 }
648
649 pub const fn comparison(&self) -> Option<&ProfiledApiVisibilityComparison> {
651 self.comparison.as_ref()
652 }
653
654 pub const fn observation(&self) -> Option<&ApiObservationReceipt> {
656 self.observation.as_ref()
657 }
658
659 pub const fn review(&self) -> Option<&ApiVisibilityReview> {
661 self.review.as_ref()
662 }
663
664 pub const fn limit_exceeded(&self) -> Option<&RuntimeLimitExceeded> {
666 self.limit_exceeded.as_ref()
667 }
668}
669
670impl fmt::Debug for RuntimeApiVisibilityRunReport {
671 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
672 formatter
673 .debug_struct("RuntimeApiVisibilityRunReport")
674 .field("disposition", &self.disposition)
675 .field("stopped_leg", &self.stopped_leg)
676 .field("inconclusive_reason", &self.inconclusive_reason)
677 .field("audit", &self.audit)
678 .field("comparison", &self.comparison)
679 .field("observation", &self.observation)
680 .field("review", &self.review)
681 .field("limit_exceeded", &self.limit_exceeded)
682 .finish()
683 }
684}
685
686#[derive(Debug, Error)]
689#[non_exhaustive]
690pub enum RuntimeApiVisibilityExecutionError {
691 #[error("API visibility reasoning is disabled for this runtime")]
693 ApiReasoningDisabled,
694 #[error("standard web decision runtime has already started")]
696 AlreadyStarted,
697 #[error("API visibility pair target does not match the runtime target")]
699 RuntimeTargetMismatch,
700 #[error("failed to construct isolated API visibility transport: {source}")]
702 TransportSetup {
703 #[source]
705 source: HttpEvidenceError,
706 },
707 #[error("API visibility comparison failed after transport completed: {source}")]
709 Comparison {
710 audit: Box<ApiVisibilityDifferentialAudit>,
712 #[source]
714 source: ProfiledApiVisibilityError,
715 },
716 #[error("API visibility observation construction failed after comparison: {source}")]
718 ObservationBuild {
719 audit: Box<ApiVisibilityDifferentialAudit>,
721 comparison: Box<ProfiledApiVisibilityComparison>,
723 #[source]
725 source: ProfiledApiVisibilityError,
726 },
727 #[error("API visibility observation failed after transport completed: {source}")]
729 Observation {
730 audit: Box<ApiVisibilityDifferentialAudit>,
732 comparison: Box<ProfiledApiVisibilityComparison>,
734 #[source]
736 source: ApiObservationError,
737 },
738 #[error("committed API visibility observation has no canonical review projection")]
740 ReviewProjection {
741 audit: Box<ApiVisibilityDifferentialAudit>,
743 comparison: Box<ProfiledApiVisibilityComparison>,
745 observation: Box<ApiObservationReceipt>,
747 },
748}
749
750impl RuntimeApiVisibilityExecutionError {
751 pub fn audit(&self) -> Option<&ApiVisibilityDifferentialAudit> {
753 match self {
754 Self::Comparison { audit, .. }
755 | Self::ObservationBuild { audit, .. }
756 | Self::Observation { audit, .. }
757 | Self::ReviewProjection { audit, .. } => Some(audit),
758 Self::ApiReasoningDisabled
759 | Self::AlreadyStarted
760 | Self::RuntimeTargetMismatch
761 | Self::TransportSetup { .. } => None,
762 }
763 }
764
765 pub fn comparison(&self) -> Option<&ProfiledApiVisibilityComparison> {
767 match self {
768 Self::ObservationBuild { comparison, .. }
769 | Self::Observation { comparison, .. }
770 | Self::ReviewProjection { comparison, .. } => Some(comparison),
771 Self::ApiReasoningDisabled
772 | Self::AlreadyStarted
773 | Self::RuntimeTargetMismatch
774 | Self::TransportSetup { .. }
775 | Self::Comparison { .. } => None,
776 }
777 }
778
779 pub fn committed_observation(&self) -> Option<&ApiObservationCommitReceipt> {
781 match self {
782 Self::Observation { source, .. } => source.committed_observation(),
783 Self::ReviewProjection { observation, .. } => Some(observation.commit()),
784 Self::ApiReasoningDisabled
785 | Self::AlreadyStarted
786 | Self::RuntimeTargetMismatch
787 | Self::TransportSetup { .. }
788 | Self::ObservationBuild { .. }
789 | Self::Comparison { .. } => None,
790 }
791 }
792}
793
794fn primary_context_headers_differ(
795 control: &HttpProbe,
796 candidate: &HttpProbe,
797 context_header_names: &BTreeSet<String>,
798) -> bool {
799 context_header_names
800 .iter()
801 .filter(|name| PRIMARY_AUTH_CONTEXT_HEADERS.contains(&name.as_str()))
802 .any(|name| control.headers().get(name) != candidate.headers().get(name))
803}
804
805fn is_auth_context_header(name: &str) -> bool {
806 PRIMARY_AUTH_CONTEXT_HEADERS.contains(&name) || SUPPORTING_AUTH_CONTEXT_HEADERS.contains(&name)
807}
808
809fn request_template_digest(
810 probe: &HttpProbe,
811 context_header_names: &BTreeSet<String>,
812 all_header_names: &BTreeSet<&String>,
813) -> String {
814 let mut hasher = Sha256::new();
815 hasher.update(TEMPLATE_DIGEST_DOMAIN);
816 update_framed(&mut hasher, probe.method().as_str().as_bytes());
817 update_framed(&mut hasher, probe.url().as_str().as_bytes());
818 for name in all_header_names {
819 update_framed(&mut hasher, name.as_bytes());
820 if context_header_names.contains(name.as_str()) {
821 update_framed(&mut hasher, b"<context-owned>");
822 } else if let Some(value) = probe.headers().get(name.as_str()) {
823 update_framed(&mut hasher, value.as_bytes());
824 }
825 }
826 encode_digest(hasher.finalize().as_slice())
827}
828
829#[allow(clippy::too_many_arguments)]
830fn operation_digest(
831 comparison_id: &ComparisonId,
832 resource_scope: &EntityId,
833 control_context_id: &OpaqueContextId,
834 candidate_context_id: &OpaqueContextId,
835 dimension: ApiVisibilityDimension,
836 observed_at_ms: u64,
837 projection_policy_id: &[u8; 32],
838 request_template_sha256: &str,
839) -> String {
840 let mut hasher = Sha256::new();
841 hasher.update(OPERATION_DIGEST_DOMAIN);
842 for bytes in [
843 comparison_id.as_str().as_bytes(),
844 resource_scope.as_str().as_bytes(),
845 control_context_id.as_str().as_bytes(),
846 candidate_context_id.as_str().as_bytes(),
847 dimension.as_str().as_bytes(),
848 request_template_sha256.as_bytes(),
849 ] {
850 update_framed(&mut hasher, bytes);
851 }
852 update_framed(&mut hasher, &observed_at_ms.to_be_bytes());
853 update_framed(&mut hasher, projection_policy_id);
854 encode_digest(hasher.finalize().as_slice())
855}
856
857fn update_framed(hasher: &mut Sha256, bytes: &[u8]) {
858 hasher.update(u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_be_bytes());
859 hasher.update(bytes);
860}
861
862fn encode_digest(bytes: &[u8]) -> String {
863 const HEX: &[u8; 16] = b"0123456789abcdef";
864 let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
865 for byte in bytes {
866 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
867 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
868 }
869 encoded
870}
871
872#[cfg(test)]
873#[path = "differential_tests.rs"]
874mod tests;