Skip to main content

termivar_scanner/api_evidence/
profiled.rs

1//! Versioned, policy-projected API visibility comparison.
2//!
3//! This module is additive to the legacy comparator. It keeps the legacy wire
4//! contract and signatures unchanged while giving replayable comparisons an
5//! explicit projection policy, canonicalization version, and bounded path-only
6//! explanation. Raw JSON values and clear-text observed paths are never stored.
7
8use 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/// Availability of a bounded path explanation for one comparison result.
43///
44/// This derived disposition prevents consumers from treating an empty path
45/// list as proof that two views were equivalent. Status-only differences and
46/// structural changes that cannot be represented by the current path index
47/// deliberately remain [`Self::DifferenceWithoutPathSummary`].
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum VisibilityExplanationDisposition {
52    /// The selected comparison dimension was equivalent.
53    NoDifference,
54    /// At least one path difference was observed, retained, or quota-omitted.
55    PathSummary {
56        /// Number of redacted path digests retained by the comparison.
57        retained: u16,
58        /// Number of observed path differences omitted by the profile quota.
59        omitted: u32,
60    },
61    /// The dimension differed without a representable path-level summary.
62    DifferenceWithoutPathSummary,
63}
64
65/// Raw-value-free view captured under one explicit projection profile.
66#[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    /// Returns the opaque authorization or presentation context.
83    pub fn context_id(&self) -> &str {
84        &self.context_id
85    }
86
87    /// Returns the opaque logical-resource scope.
88    pub fn resource_scope_id(&self) -> &str {
89        &self.resource_scope_id
90    }
91
92    /// Returns the declared API surface.
93    pub const fn surface(&self) -> ApiSurfaceKind {
94        self.surface
95    }
96
97    /// Returns the exact validated HTTP response status.
98    pub const fn status(&self) -> u16 {
99        self.status
100    }
101
102    /// Returns the capture resource envelope.
103    pub const fn limits(&self) -> ApiVisibilityLimits {
104        self.limits
105    }
106
107    /// Returns the comparator version used to capture this view.
108    pub const fn comparator_version(&self) -> ComparisonAlgorithmVersion {
109        self.comparator_version
110    }
111
112    /// Returns the canonicalization version used to capture this view.
113    pub const fn canonicalization_version(&self) -> CanonicalizationVersion {
114        self.canonicalization_version
115    }
116
117    /// Returns the projection policy digest used to capture this view.
118    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/// Replayable profiled-comparator envelope.
143///
144/// The nested `comparison` intentionally preserves the legacy nine-field core
145/// contract. Version and projection metadata live beside it so a legacy
146/// deserializer cannot silently discard them. Persist this envelope, not only
147/// the nested comparison, when deterministic replay matters.
148#[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    /// Returns the legacy typed comparison nested in this envelope.
160    pub const fn comparison(&self) -> &ApiVisibilityComparison {
161        &self.comparison
162    }
163
164    /// Returns the deterministic comparator version.
165    pub const fn comparator_version(&self) -> ComparisonAlgorithmVersion {
166        self.comparator_version
167    }
168
169    /// Returns the deterministic canonicalization version.
170    pub const fn canonicalization_version(&self) -> CanonicalizationVersion {
171        self.canonicalization_version
172    }
173
174    /// Returns the exact projection policy digest.
175    pub const fn projection_policy_id(&self) -> ProjectionPolicyId {
176        self.projection_policy_id
177    }
178
179    /// Returns the resource envelope used for both captured views.
180    pub const fn limits(&self) -> ApiVisibilityLimits {
181        self.limits
182    }
183
184    /// Returns the bounded, raw-value-free explanation.
185    pub const fn diff(&self) -> &RedactedVisibilityDiff {
186        &self.diff
187    }
188
189    /// Classifies whether this result has a bounded path-level explanation.
190    ///
191    /// An empty [`RedactedVisibilityDiff`] is not equivalent to an equivalent
192    /// comparison. For example, status differences and ordered-array reorders
193    /// can be real differences without a path summary in the current model.
194    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    /// Consumes the envelope and returns its explicit components.
211    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    /// Emits the legacy observation while the caller retains this envelope for replay.
232    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    /// Emits detached legacy evidence while the caller retains this envelope for replay.
241    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/// Validation, capture, and comparison failures for the profiled comparator.
329#[derive(Debug, Clone, PartialEq, Eq, Error)]
330#[non_exhaustive]
331pub enum ProfiledApiVisibilityError {
332    /// A legacy capture invariant or bounded canonicalization check failed.
333    #[error(transparent)]
334    Evidence(#[from] ApiVisibilityEvidenceError),
335
336    /// A JSON Pointer pattern was syntactically invalid.
337    #[error("invalid API comparison path pattern: {reason}")]
338    InvalidPathPattern {
339        /// Stable reason without echoing the rejected path.
340        reason: &'static str,
341    },
342
343    /// A path pattern exceeded the compiled byte ceiling.
344    #[error("API comparison path pattern exceeds the {maximum}-byte limit")]
345    PathTooLong {
346        /// Inclusive compiled ceiling.
347        maximum: usize,
348    },
349
350    /// A path pattern exceeded the compiled segment ceiling.
351    #[error("API comparison path pattern exceeds the {maximum}-segment limit")]
352    PathTooDeep {
353        /// Inclusive compiled ceiling.
354        maximum: usize,
355    },
356
357    /// A profile contained too many path patterns.
358    #[error("API comparison profile exceeds the {maximum}-pattern limit")]
359    TooManyProfilePaths {
360        /// Inclusive compiled ceiling across all profile lists.
361        maximum: usize,
362    },
363
364    /// A profile or persisted diff exceeded the compiled explanation ceiling.
365    #[error("API visibility diff exceeds the {maximum}-path limit")]
366    TooManyDiffPaths {
367        /// Inclusive compiled ceiling.
368        maximum: u16,
369    },
370
371    /// A selected subtree was already removed by an ignored ancestor.
372    #[error("API comparison selected path is inside an ignored subtree")]
373    ConflictingPathPolicy,
374
375    /// A persisted diff vector was unsorted or contained duplicate digests.
376    #[error("API visibility diff path lists must be sorted and unique")]
377    NonCanonicalDiffPathOrder,
378
379    /// Persisted diff categories reused one path digest ambiguously.
380    #[error("API visibility diff categories must be mutually exclusive")]
381    OverlappingDiffCategories,
382
383    /// Views did not use the requested deterministic comparator version.
384    #[error("API visibility comparator versions do not match")]
385    ComparatorVersionMismatch,
386
387    /// Views did not use the requested canonicalization version.
388    #[error("API visibility canonicalization versions do not match")]
389    CanonicalizationVersionMismatch,
390
391    /// Views did not use the requested projection policy.
392    #[error("API visibility projection policies do not match")]
393    ProjectionPolicyMismatch,
394
395    /// The nested core comparison rejected a typed value.
396    #[error(transparent)]
397    Vocabulary(#[from] ApiVocabularyError),
398}
399
400impl ApiVisibilityComparator {
401    /// Captures a bounded view under one explicit projection profile.
402    ///
403    /// The legacy canonicalizer first validates the complete snapshot against
404    /// this comparator's resource envelope. Projection therefore cannot hide
405    /// oversized input. The second pass retains only signatures and a bounded
406    /// digest/type/value index; raw values and clear-text observed paths are
407    /// discarded before this method returns.
408    #[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    /// Captures the existing view plus value-free selected-material truth for
430    /// an in-crate policy that must reject effectively empty projections.
431    #[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        // This full-document pass makes selected/ignored paths incapable of
448        // bypassing the existing depth, node, field, or canonical-byte limits.
449        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    /// Compares two profiled views and emits a replayable metadata envelope.
472    ///
473    /// Version, policy, and limit mismatches fail closed before a result is
474    /// emitted. The caller-owned comparison handle is domain-hashed together
475    /// with the complete metadata tuple before constructing the nested legacy
476    /// comparison, preventing evidence identity aliases across profiles.
477    #[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;