1use std::{collections::BTreeMap, fmt};
9
10use serde::{Deserialize, Deserializer, Serialize};
11use serde_json::Value;
12use sha2::{Digest, Sha256};
13use termivar_core::{
14 ApiSurfaceKind, ApiVisibilityComparison, ApiVisibilityDimension, ApiVisibilityObservation,
15 ApiVisibilityPairKind, ApiVisibilityResult, ApiVocabularyError, ConfidenceScore, Evidence,
16};
17use thiserror::Error;
18
19use crate::api_evidence::{
20 canonical_signatures, opaque_handle, ApiVisibilityComparator, ApiVisibilityEvidenceError,
21 ApiVisibilityLimits,
22};
23
24mod canonical;
25mod diff;
26mod policy;
27
28use canonical::ProfiledCanonicalState;
29use diff::{visibility_diff, PathFingerprint};
30pub use diff::{PathDigest, RedactedVisibilityDiff};
31use policy::{decode_digest, encode_digest, update_framed};
32pub use policy::{
33 ApiComparisonProfile, CanonicalizationVersion, ComparisonAlgorithmVersion, JsonPathPattern,
34 ProjectionPolicyId, CURRENT_API_COMPARISON_ALGORITHM_VERSION,
35 CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION, DEFAULT_API_VISIBILITY_DIFF_PATHS,
36 HARD_MAX_API_COMPARISON_PATH_BYTES, HARD_MAX_API_COMPARISON_PATH_DEPTH,
37 HARD_MAX_API_COMPARISON_PROFILE_PATHS, HARD_MAX_API_VISIBILITY_DIFF_PATHS,
38};
39
40const PROFILED_COMPARISON_ID_DOMAIN: &[u8] = b"venom.api-visibility.comparison-id.v3\0";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum VisibilityExplanationDisposition {
52 NoDifference,
54 PathSummary {
56 retained: u16,
58 omitted: u32,
60 },
61 DifferenceWithoutPathSummary,
63}
64
65#[derive(Clone, PartialEq, Eq)]
67pub struct ProfiledApiVisibilityView {
68 context_id: String,
69 resource_scope_id: String,
70 surface: ApiSurfaceKind,
71 status: u16,
72 resource_signature: [u8; 32],
73 field_signature: [u8; 32],
74 path_index: BTreeMap<PathDigest, PathFingerprint>,
75 limits: ApiVisibilityLimits,
76 comparator_version: ComparisonAlgorithmVersion,
77 canonicalization_version: CanonicalizationVersion,
78 projection_policy_id: ProjectionPolicyId,
79}
80
81impl ProfiledApiVisibilityView {
82 pub fn context_id(&self) -> &str {
84 &self.context_id
85 }
86
87 pub fn resource_scope_id(&self) -> &str {
89 &self.resource_scope_id
90 }
91
92 pub const fn surface(&self) -> ApiSurfaceKind {
94 self.surface
95 }
96
97 pub const fn status(&self) -> u16 {
99 self.status
100 }
101
102 pub const fn limits(&self) -> ApiVisibilityLimits {
104 self.limits
105 }
106
107 pub const fn comparator_version(&self) -> ComparisonAlgorithmVersion {
109 self.comparator_version
110 }
111
112 pub const fn canonicalization_version(&self) -> CanonicalizationVersion {
114 self.canonicalization_version
115 }
116
117 pub const fn projection_policy_id(&self) -> ProjectionPolicyId {
119 self.projection_policy_id
120 }
121}
122
123impl fmt::Debug for ProfiledApiVisibilityView {
124 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125 formatter
126 .debug_struct("ProfiledApiVisibilityView")
127 .field("context_id", &"<redacted>")
128 .field("resource_scope_id", &"<redacted>")
129 .field("surface", &self.surface)
130 .field("status", &self.status)
131 .field("resource_signature", &"<redacted>")
132 .field("field_signature", &"<redacted>")
133 .field("path_index", &"<redacted>")
134 .field("limits", &self.limits)
135 .field("comparator_version", &self.comparator_version)
136 .field("canonicalization_version", &self.canonicalization_version)
137 .field("projection_policy_id", &self.projection_policy_id)
138 .finish()
139 }
140}
141
142#[derive(Clone, PartialEq, Eq, Serialize)]
149pub struct ProfiledApiVisibilityComparison {
150 comparison: ApiVisibilityComparison,
151 comparator_version: ComparisonAlgorithmVersion,
152 canonicalization_version: CanonicalizationVersion,
153 projection_policy_id: ProjectionPolicyId,
154 limits: ApiVisibilityLimits,
155 diff: RedactedVisibilityDiff,
156}
157
158impl ProfiledApiVisibilityComparison {
159 pub const fn comparison(&self) -> &ApiVisibilityComparison {
161 &self.comparison
162 }
163
164 pub const fn comparator_version(&self) -> ComparisonAlgorithmVersion {
166 self.comparator_version
167 }
168
169 pub const fn canonicalization_version(&self) -> CanonicalizationVersion {
171 self.canonicalization_version
172 }
173
174 pub const fn projection_policy_id(&self) -> ProjectionPolicyId {
176 self.projection_policy_id
177 }
178
179 pub const fn limits(&self) -> ApiVisibilityLimits {
181 self.limits
182 }
183
184 pub const fn diff(&self) -> &RedactedVisibilityDiff {
186 &self.diff
187 }
188
189 pub fn explanation_disposition(&self) -> VisibilityExplanationDisposition {
195 match self.comparison.result() {
196 ApiVisibilityResult::Equivalent => VisibilityExplanationDisposition::NoDifference,
197 ApiVisibilityResult::Different => {
198 let retained = self.diff.retained_diff_count();
199 let omitted = self.diff.omitted_diff_count();
200 if retained == 0 && omitted == 0 {
201 VisibilityExplanationDisposition::DifferenceWithoutPathSummary
202 } else {
203 VisibilityExplanationDisposition::PathSummary { retained, omitted }
204 }
205 },
206 _ => VisibilityExplanationDisposition::DifferenceWithoutPathSummary,
207 }
208 }
209
210 pub fn into_parts(
212 self,
213 ) -> (
214 ApiVisibilityComparison,
215 ComparisonAlgorithmVersion,
216 CanonicalizationVersion,
217 ProjectionPolicyId,
218 ApiVisibilityLimits,
219 RedactedVisibilityDiff,
220 ) {
221 (
222 self.comparison,
223 self.comparator_version,
224 self.canonicalization_version,
225 self.projection_policy_id,
226 self.limits,
227 self.diff,
228 )
229 }
230
231 pub fn to_observation(
233 &self,
234 component: impl Into<String>,
235 reliability: ConfidenceScore,
236 ) -> Result<ApiVisibilityObservation, ApiVocabularyError> {
237 self.comparison.to_observation(component, reliability)
238 }
239
240 pub fn to_evidence(
242 &self,
243 component: impl Into<String>,
244 reliability: ConfidenceScore,
245 ) -> Result<Evidence, ApiVocabularyError> {
246 self.comparison.to_evidence(component, reliability)
247 }
248}
249
250impl fmt::Debug for ProfiledApiVisibilityComparison {
251 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
252 formatter
253 .debug_struct("ProfiledApiVisibilityComparison")
254 .field("comparison", &"<redacted>")
255 .field("comparator_version", &self.comparator_version)
256 .field("canonicalization_version", &self.canonicalization_version)
257 .field("projection_policy_id", &self.projection_policy_id)
258 .field("limits", &self.limits)
259 .field("diff", &self.diff)
260 .finish()
261 }
262}
263
264impl<'de> Deserialize<'de> for ProfiledApiVisibilityComparison {
265 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
266 where
267 D: Deserializer<'de>,
268 {
269 #[derive(Deserialize)]
270 #[serde(deny_unknown_fields)]
271 struct WireComparison {
272 comparison: ApiVisibilityComparison,
273 comparator_version: ComparisonAlgorithmVersion,
274 canonicalization_version: CanonicalizationVersion,
275 projection_policy_id: ProjectionPolicyId,
276 limits: ApiVisibilityLimits,
277 diff: RedactedVisibilityDiff,
278 }
279
280 let wire = WireComparison::deserialize(deserializer)?;
281 if wire.comparator_version != CURRENT_API_COMPARISON_ALGORITHM_VERSION {
282 return Err(serde::de::Error::custom(
283 "persisted API comparison uses an unsupported comparator version",
284 ));
285 }
286 if wire.canonicalization_version != CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION {
287 return Err(serde::de::Error::custom(
288 "persisted API comparison uses an unsupported canonicalization version",
289 ));
290 }
291 if !comparison_id_matches_metadata(
292 wire.comparison.comparison_id(),
293 wire.comparator_version,
294 wire.canonicalization_version,
295 wire.projection_policy_id,
296 ) {
297 return Err(serde::de::Error::custom(
298 "profiled API comparison identity is not bound to its replay metadata",
299 ));
300 }
301 let diff_matches_semantics = match (wire.comparison.result(), wire.comparison.dimension()) {
302 (ApiVisibilityResult::Equivalent, _) => wire.diff.is_empty(),
303 (ApiVisibilityResult::Different, ApiVisibilityDimension::Status) => {
304 wire.diff.is_empty()
305 },
306 (ApiVisibilityResult::Different, ApiVisibilityDimension::Fields) => {
307 wire.diff.changed_value_path_hashes().is_empty()
308 },
309 (ApiVisibilityResult::Different, ApiVisibilityDimension::Resources) => true,
310 _ => false,
311 };
312 if !diff_matches_semantics {
313 return Err(serde::de::Error::custom(
314 "profiled API comparison diff is incompatible with v3 result semantics",
315 ));
316 }
317 Ok(Self {
318 comparison: wire.comparison,
319 comparator_version: wire.comparator_version,
320 canonicalization_version: wire.canonicalization_version,
321 projection_policy_id: wire.projection_policy_id,
322 limits: wire.limits,
323 diff: wire.diff,
324 })
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Error)]
330#[non_exhaustive]
331pub enum ProfiledApiVisibilityError {
332 #[error(transparent)]
334 Evidence(#[from] ApiVisibilityEvidenceError),
335
336 #[error("invalid API comparison path pattern: {reason}")]
338 InvalidPathPattern {
339 reason: &'static str,
341 },
342
343 #[error("API comparison path pattern exceeds the {maximum}-byte limit")]
345 PathTooLong {
346 maximum: usize,
348 },
349
350 #[error("API comparison path pattern exceeds the {maximum}-segment limit")]
352 PathTooDeep {
353 maximum: usize,
355 },
356
357 #[error("API comparison profile exceeds the {maximum}-pattern limit")]
359 TooManyProfilePaths {
360 maximum: usize,
362 },
363
364 #[error("API visibility diff exceeds the {maximum}-path limit")]
366 TooManyDiffPaths {
367 maximum: u16,
369 },
370
371 #[error("API comparison selected path is inside an ignored subtree")]
373 ConflictingPathPolicy,
374
375 #[error("API visibility diff path lists must be sorted and unique")]
377 NonCanonicalDiffPathOrder,
378
379 #[error("API visibility diff categories must be mutually exclusive")]
381 OverlappingDiffCategories,
382
383 #[error("API visibility comparator versions do not match")]
385 ComparatorVersionMismatch,
386
387 #[error("API visibility canonicalization versions do not match")]
389 CanonicalizationVersionMismatch,
390
391 #[error("API visibility projection policies do not match")]
393 ProjectionPolicyMismatch,
394
395 #[error(transparent)]
397 Vocabulary(#[from] ApiVocabularyError),
398}
399
400impl ApiVisibilityComparator {
401 #[allow(clippy::too_many_arguments)]
409 pub fn capture_profiled_view(
410 &self,
411 profile: &ApiComparisonProfile,
412 context_id: impl Into<String>,
413 resource_scope_id: impl Into<String>,
414 surface: ApiSurfaceKind,
415 status: u16,
416 snapshot: &Value,
417 ) -> Result<ProfiledApiVisibilityView, ProfiledApiVisibilityError> {
418 self.capture_profiled_view_with_material(
419 profile,
420 context_id,
421 resource_scope_id,
422 surface,
423 status,
424 snapshot,
425 )
426 .map(|(view, _)| view)
427 }
428
429 #[allow(clippy::too_many_arguments)]
432 pub(crate) fn capture_profiled_view_with_material(
433 &self,
434 profile: &ApiComparisonProfile,
435 context_id: impl Into<String>,
436 resource_scope_id: impl Into<String>,
437 surface: ApiSurfaceKind,
438 status: u16,
439 snapshot: &Value,
440 ) -> Result<(ProfiledApiVisibilityView, bool), ProfiledApiVisibilityError> {
441 let context_id = opaque_handle(context_id, "context id")?;
442 let resource_scope_id = opaque_handle(resource_scope_id, "resource scope id")?;
443 if !(100..=599).contains(&status) {
444 return Err(ApiVisibilityEvidenceError::InvalidHttpStatus { status }.into());
445 }
446
447 let _ = canonical_signatures(snapshot, self.limits())?;
450
451 let canonical = ProfiledCanonicalState::new(profile, self.limits()).capture(snapshot)?;
452 let material_selected_value = canonical.material_selected_value;
453 Ok((
454 ProfiledApiVisibilityView {
455 context_id,
456 resource_scope_id,
457 surface,
458 status,
459 resource_signature: canonical.resource,
460 field_signature: canonical.fields,
461 path_index: canonical.path_index,
462 limits: self.limits(),
463 comparator_version: CURRENT_API_COMPARISON_ALGORITHM_VERSION,
464 canonicalization_version: CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION,
465 projection_policy_id: profile.projection_policy_id(),
466 },
467 material_selected_value,
468 ))
469 }
470
471 #[allow(clippy::too_many_arguments)]
478 pub fn compare_profiled(
479 &self,
480 profile: &ApiComparisonProfile,
481 comparison_id: impl Into<String>,
482 pair: ApiVisibilityPairKind,
483 dimension: ApiVisibilityDimension,
484 baseline: &ProfiledApiVisibilityView,
485 candidate: &ProfiledApiVisibilityView,
486 observed_at_ms: u64,
487 ) -> Result<ProfiledApiVisibilityComparison, ProfiledApiVisibilityError> {
488 for view in [baseline, candidate] {
489 if view.comparator_version != CURRENT_API_COMPARISON_ALGORITHM_VERSION
490 || view.comparator_version != profile.algorithm_version()
491 {
492 return Err(ProfiledApiVisibilityError::ComparatorVersionMismatch);
493 }
494 if view.canonicalization_version != CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION {
495 return Err(ProfiledApiVisibilityError::CanonicalizationVersionMismatch);
496 }
497 if view.projection_policy_id != profile.projection_policy_id() {
498 return Err(ProfiledApiVisibilityError::ProjectionPolicyMismatch);
499 }
500 if view.limits != self.limits() {
501 return Err(ApiVisibilityEvidenceError::LimitsMismatch.into());
502 }
503 }
504 if baseline.context_id == candidate.context_id {
505 return Err(ApiVisibilityEvidenceError::IdenticalContexts.into());
506 }
507 if baseline.resource_scope_id != candidate.resource_scope_id {
508 return Err(ApiVisibilityEvidenceError::ResourceScopeMismatch.into());
509 }
510 if baseline.surface != candidate.surface {
511 return Err(ApiVisibilityEvidenceError::SurfaceMismatch.into());
512 }
513
514 let equivalent = match dimension {
515 ApiVisibilityDimension::Fields => baseline.field_signature == candidate.field_signature,
516 ApiVisibilityDimension::Resources => {
517 baseline.resource_signature == candidate.resource_signature
518 },
519 ApiVisibilityDimension::Status => baseline.status == candidate.status,
520 _ => return Err(ApiVisibilityEvidenceError::UnsupportedDimension.into()),
521 };
522 let result = if equivalent {
523 ApiVisibilityResult::Equivalent
524 } else {
525 ApiVisibilityResult::Different
526 };
527 let diff = visibility_diff(
528 dimension,
529 &baseline.path_index,
530 &candidate.path_index,
531 profile.max_diff_paths(),
532 )?;
533
534 let comparison_id = profiled_comparison_id(
535 comparison_id,
536 profile.projection_policy_id(),
537 CURRENT_API_COMPARISON_ALGORITHM_VERSION,
538 CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION,
539 )?;
540 let comparison = ApiVisibilityComparison::new(
541 comparison_id,
542 baseline.surface,
543 pair,
544 result,
545 dimension,
546 baseline.context_id.clone(),
547 candidate.context_id.clone(),
548 baseline.resource_scope_id.clone(),
549 )?
550 .with_observed_at_ms(observed_at_ms);
551
552 Ok(ProfiledApiVisibilityComparison {
553 comparison,
554 comparator_version: CURRENT_API_COMPARISON_ALGORITHM_VERSION,
555 canonicalization_version: CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION,
556 projection_policy_id: profile.projection_policy_id(),
557 limits: self.limits(),
558 diff,
559 })
560 }
561}
562
563fn profiled_comparison_id(
564 comparison_id: impl Into<String>,
565 policy_id: ProjectionPolicyId,
566 comparator_version: ComparisonAlgorithmVersion,
567 canonicalization_version: CanonicalizationVersion,
568) -> Result<String, ProfiledApiVisibilityError> {
569 let comparison_id = opaque_handle(comparison_id, "comparison id")?;
570 let mut hasher = Sha256::new();
571 hasher.update(PROFILED_COMPARISON_ID_DOMAIN);
572 update_framed(&mut hasher, comparison_id.as_bytes());
573 Ok(format!(
574 "profiled:{}:{}:{}:{}",
575 comparator_version.as_str(),
576 canonicalization_version.as_str(),
577 policy_id,
578 encode_digest(hasher.finalize().into())
579 ))
580}
581
582fn comparison_id_matches_metadata(
583 comparison_id: &str,
584 comparator_version: ComparisonAlgorithmVersion,
585 canonicalization_version: CanonicalizationVersion,
586 policy_id: ProjectionPolicyId,
587) -> bool {
588 let prefix = format!(
589 "profiled:{}:{}:{}:",
590 comparator_version.as_str(),
591 canonicalization_version.as_str(),
592 policy_id
593 );
594 comparison_id
595 .strip_prefix(&prefix)
596 .is_some_and(|digest| digest.len() == 64 && decode_digest(digest).is_ok())
597}
598
599#[cfg(test)]
600#[path = "profiled_tests.rs"]
601mod tests;