Skip to main content

termivar_scanner/api_evidence/profiled/
diff.rs

1//! Bounded, redacted path fingerprints and visibility explanations.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    fmt,
6};
7
8use serde::{
9    de::{IgnoredAny, SeqAccess, Visitor},
10    Deserialize, Deserializer, Serialize, Serializer,
11};
12use serde_json::Value;
13use sha2::{Digest, Sha256};
14use termivar_core::ApiVisibilityDimension;
15
16use crate::api_evidence::profiled::{
17    policy::{
18        deserialize_digest, encode_digest, update_framed, JsonPathPattern,
19        HARD_MAX_API_VISIBILITY_DIFF_PATHS,
20    },
21    ProfiledApiVisibilityError,
22};
23
24const PATH_DIGEST_DOMAIN: &[u8] = b"venom.api-visibility.path.v2\0";
25
26/// Pseudonymous digest of one structural JSON path.
27///
28/// The digest contains no clear-text field names. Hosts with a safe path
29/// allowlist can hash a [`JsonPathPattern`] locally to resolve explanations.
30#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct PathDigest([u8; 32]);
32
33impl PathDigest {
34    /// Computes the digest used for a canonical path pattern.
35    pub fn for_pattern(pattern: &JsonPathPattern) -> Self {
36        digest_path(&pattern.tokens)
37    }
38
39    /// Returns the raw SHA-256 digest bytes.
40    pub const fn as_bytes(&self) -> &[u8; 32] {
41        &self.0
42    }
43
44    /// Returns a lowercase hexadecimal representation.
45    pub fn to_hex(self) -> String {
46        encode_digest(self.0)
47    }
48}
49
50impl fmt::Debug for PathDigest {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        formatter
53            .debug_tuple("PathDigest")
54            .field(&self.to_hex())
55            .finish()
56    }
57}
58
59impl fmt::Display for PathDigest {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        formatter.write_str(&self.to_hex())
62    }
63}
64
65impl Serialize for PathDigest {
66    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
67    where
68        S: Serializer,
69    {
70        serializer.serialize_str(&self.to_hex())
71    }
72}
73
74impl<'de> Deserialize<'de> for PathDigest {
75    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
76    where
77        D: Deserializer<'de>,
78    {
79        deserialize_digest(deserializer).map(Self)
80    }
81}
82
83/// Bounded path-only explanation for one visibility comparison.
84///
85/// Digests are sorted, unique, and mutually exclusive across categories. The
86/// combined vector length never exceeds the profile's global quota. Scalar
87/// values and clear-text paths are not retained.
88#[derive(Clone, PartialEq, Eq, Serialize)]
89pub struct RedactedVisibilityDiff {
90    added_path_hashes: Vec<PathDigest>,
91    removed_path_hashes: Vec<PathDigest>,
92    changed_type_path_hashes: Vec<PathDigest>,
93    changed_value_path_hashes: Vec<PathDigest>,
94    omitted_diff_count: u32,
95}
96
97impl RedactedVisibilityDiff {
98    /// Returns paths present only in the candidate view.
99    pub fn added_path_hashes(&self) -> &[PathDigest] {
100        &self.added_path_hashes
101    }
102
103    /// Returns paths present only in the baseline view.
104    pub fn removed_path_hashes(&self) -> &[PathDigest] {
105        &self.removed_path_hashes
106    }
107
108    /// Returns shared paths whose JSON type set changed.
109    pub fn changed_type_path_hashes(&self) -> &[PathDigest] {
110        &self.changed_type_path_hashes
111    }
112
113    /// Returns shared scalar paths whose value digest changed.
114    pub fn changed_value_path_hashes(&self) -> &[PathDigest] {
115        &self.changed_value_path_hashes
116    }
117
118    /// Returns the exact number of differences excluded by the global quota.
119    pub const fn omitted_diff_count(&self) -> u32 {
120        self.omitted_diff_count
121    }
122
123    /// Returns the number of path differences retained in this explanation.
124    ///
125    /// This count excludes [`Self::omitted_diff_count`]. The compiled global
126    /// path ceiling guarantees that the result fits in `u16`.
127    pub fn retained_diff_count(&self) -> u16 {
128        let retained = self
129            .added_path_hashes
130            .len()
131            .saturating_add(self.removed_path_hashes.len())
132            .saturating_add(self.changed_type_path_hashes.len())
133            .saturating_add(self.changed_value_path_hashes.len());
134        u16::try_from(retained).expect("bounded API visibility path count fits in u16")
135    }
136
137    /// Returns whether no path-level difference was observed or retained.
138    pub fn is_empty(&self) -> bool {
139        self.added_path_hashes.is_empty()
140            && self.removed_path_hashes.is_empty()
141            && self.changed_type_path_hashes.is_empty()
142            && self.changed_value_path_hashes.is_empty()
143            && self.omitted_diff_count == 0
144    }
145
146    fn from_parts(
147        mut added_path_hashes: Vec<PathDigest>,
148        mut removed_path_hashes: Vec<PathDigest>,
149        mut changed_type_path_hashes: Vec<PathDigest>,
150        mut changed_value_path_hashes: Vec<PathDigest>,
151        omitted_diff_count: u32,
152    ) -> Result<Self, ProfiledApiVisibilityError> {
153        for values in [
154            &mut added_path_hashes,
155            &mut removed_path_hashes,
156            &mut changed_type_path_hashes,
157            &mut changed_value_path_hashes,
158        ] {
159            values.sort_unstable();
160            values.dedup();
161        }
162
163        let total = added_path_hashes
164            .len()
165            .saturating_add(removed_path_hashes.len())
166            .saturating_add(changed_type_path_hashes.len())
167            .saturating_add(changed_value_path_hashes.len());
168        if total > usize::from(HARD_MAX_API_VISIBILITY_DIFF_PATHS) {
169            return Err(ProfiledApiVisibilityError::TooManyDiffPaths {
170                maximum: HARD_MAX_API_VISIBILITY_DIFF_PATHS,
171            });
172        }
173
174        let mut unique = BTreeSet::new();
175        if [
176            &added_path_hashes,
177            &removed_path_hashes,
178            &changed_type_path_hashes,
179            &changed_value_path_hashes,
180        ]
181        .into_iter()
182        .flatten()
183        .any(|digest| !unique.insert(*digest))
184        {
185            return Err(ProfiledApiVisibilityError::OverlappingDiffCategories);
186        }
187
188        Ok(Self {
189            added_path_hashes,
190            removed_path_hashes,
191            changed_type_path_hashes,
192            changed_value_path_hashes,
193            omitted_diff_count,
194        })
195    }
196
197    fn from_wire_parts(
198        added_path_hashes: Vec<PathDigest>,
199        removed_path_hashes: Vec<PathDigest>,
200        changed_type_path_hashes: Vec<PathDigest>,
201        changed_value_path_hashes: Vec<PathDigest>,
202        omitted_diff_count: u32,
203    ) -> Result<Self, ProfiledApiVisibilityError> {
204        if [
205            &added_path_hashes,
206            &removed_path_hashes,
207            &changed_type_path_hashes,
208            &changed_value_path_hashes,
209        ]
210        .into_iter()
211        .any(|values| !strictly_sorted_unique(values))
212        {
213            return Err(ProfiledApiVisibilityError::NonCanonicalDiffPathOrder);
214        }
215        Self::from_parts(
216            added_path_hashes,
217            removed_path_hashes,
218            changed_type_path_hashes,
219            changed_value_path_hashes,
220            omitted_diff_count,
221        )
222    }
223}
224
225impl fmt::Debug for RedactedVisibilityDiff {
226    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
227        formatter
228            .debug_struct("RedactedVisibilityDiff")
229            .field("added_path_count", &self.added_path_hashes.len())
230            .field("removed_path_count", &self.removed_path_hashes.len())
231            .field(
232                "changed_type_path_count",
233                &self.changed_type_path_hashes.len(),
234            )
235            .field(
236                "changed_value_path_count",
237                &self.changed_value_path_hashes.len(),
238            )
239            .field("omitted_diff_count", &self.omitted_diff_count)
240            .finish()
241    }
242}
243
244impl<'de> Deserialize<'de> for RedactedVisibilityDiff {
245    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
246    where
247        D: Deserializer<'de>,
248    {
249        #[derive(Deserialize)]
250        #[serde(deny_unknown_fields)]
251        struct WireDiff {
252            #[serde(deserialize_with = "deserialize_path_digests")]
253            added_path_hashes: Vec<PathDigest>,
254            #[serde(deserialize_with = "deserialize_path_digests")]
255            removed_path_hashes: Vec<PathDigest>,
256            #[serde(deserialize_with = "deserialize_path_digests")]
257            changed_type_path_hashes: Vec<PathDigest>,
258            #[serde(deserialize_with = "deserialize_path_digests")]
259            changed_value_path_hashes: Vec<PathDigest>,
260            omitted_diff_count: u32,
261        }
262
263        let wire = WireDiff::deserialize(deserializer)?;
264        Self::from_wire_parts(
265            wire.added_path_hashes,
266            wire.removed_path_hashes,
267            wire.changed_type_path_hashes,
268            wire.changed_value_path_hashes,
269            wire.omitted_diff_count,
270        )
271        .map_err(serde::de::Error::custom)
272    }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub(super) struct PathFingerprint {
277    type_mask: u8,
278    scalar_value_digests: Vec<[u8; 32]>,
279}
280
281impl PathFingerprint {
282    pub(super) fn new(type_mask: u8, scalar_value_digest: Option<[u8; 32]>) -> Self {
283        Self {
284            type_mask,
285            scalar_value_digests: scalar_value_digest.into_iter().collect(),
286        }
287    }
288
289    pub(super) fn merge(&mut self, other: Self) {
290        self.type_mask |= other.type_mask;
291        self.scalar_value_digests.extend(other.scalar_value_digests);
292    }
293
294    pub(super) fn canonicalize(&mut self) {
295        // Preserve duplicate digests: array multiplicity is part of the
296        // comparison contract even when element order is ignored. Sorting once
297        // after capture avoids repeatedly re-sorting the growing aggregate for
298        // every element that shares one structural path.
299        self.scalar_value_digests.sort_unstable();
300    }
301}
302
303#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
304enum DiffKind {
305    Added,
306    Removed,
307    ChangedType,
308    ChangedValue,
309}
310
311pub(super) fn visibility_diff(
312    dimension: ApiVisibilityDimension,
313    baseline: &BTreeMap<PathDigest, PathFingerprint>,
314    candidate: &BTreeMap<PathDigest, PathFingerprint>,
315    max_diff_paths: u16,
316) -> Result<RedactedVisibilityDiff, ProfiledApiVisibilityError> {
317    if dimension == ApiVisibilityDimension::Status {
318        return RedactedVisibilityDiff::from_parts(
319            Vec::new(),
320            Vec::new(),
321            Vec::new(),
322            Vec::new(),
323            0,
324        );
325    }
326
327    let mut differences = Vec::new();
328    for (path, baseline_fingerprint) in baseline {
329        match candidate.get(path) {
330            None => differences.push((*path, DiffKind::Removed)),
331            Some(candidate_fingerprint)
332                if baseline_fingerprint.type_mask != candidate_fingerprint.type_mask =>
333            {
334                differences.push((*path, DiffKind::ChangedType));
335            },
336            Some(candidate_fingerprint)
337                if dimension == ApiVisibilityDimension::Resources
338                    && baseline_fingerprint.scalar_value_digests
339                        != candidate_fingerprint.scalar_value_digests
340                    && (!baseline_fingerprint.scalar_value_digests.is_empty()
341                        || !candidate_fingerprint.scalar_value_digests.is_empty()) =>
342            {
343                differences.push((*path, DiffKind::ChangedValue));
344            },
345            Some(_) => {},
346        }
347    }
348    for path in candidate.keys() {
349        if !baseline.contains_key(path) {
350            differences.push((*path, DiffKind::Added));
351        }
352    }
353    differences.sort_unstable();
354
355    let retained = differences.len().min(usize::from(max_diff_paths));
356    let omitted_diff_count =
357        u32::try_from(differences.len().saturating_sub(retained)).unwrap_or(u32::MAX);
358    let mut added = Vec::new();
359    let mut removed = Vec::new();
360    let mut changed_type = Vec::new();
361    let mut changed_value = Vec::new();
362    for (path, kind) in differences.into_iter().take(retained) {
363        match kind {
364            DiffKind::Added => added.push(path),
365            DiffKind::Removed => removed.push(path),
366            DiffKind::ChangedType => changed_type.push(path),
367            DiffKind::ChangedValue => changed_value.push(path),
368        }
369    }
370    RedactedVisibilityDiff::from_parts(
371        added,
372        removed,
373        changed_type,
374        changed_value,
375        omitted_diff_count,
376    )
377}
378
379pub(super) fn fingerprint(value: &Value) -> (u8, Option<[u8; 32]>) {
380    let (mask, scalar): (u8, Option<Vec<u8>>) = match value {
381        Value::Null => (1 << 0, Some(b"null".to_vec())),
382        Value::Bool(value) => (
383            1 << 1,
384            Some(if *value {
385                b"true".to_vec()
386            } else {
387                b"false".to_vec()
388            }),
389        ),
390        Value::Number(value) if value.is_i64() || value.is_u64() => {
391            (1 << 2, Some(value.to_string().into_bytes()))
392        },
393        Value::Number(value) => (1 << 3, Some(value.to_string().into_bytes())),
394        Value::String(value) => (1 << 4, Some(value.as_bytes().to_vec())),
395        Value::Array(_) => (1 << 5, None),
396        Value::Object(_) => (1 << 6, None),
397    };
398    let digest = scalar.map(|value| {
399        let mut hasher = Sha256::new();
400        hasher.update(b"venom.api-visibility.scalar-value.v2\0");
401        hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
402        hasher.update(value);
403        hasher.finalize().into()
404    });
405    (mask, digest)
406}
407
408pub(super) fn digest_path(tokens: &[String]) -> PathDigest {
409    let mut hasher = Sha256::new();
410    hasher.update(PATH_DIGEST_DOMAIN);
411    hasher.update(
412        u64::try_from(tokens.len())
413            .unwrap_or(u64::MAX)
414            .to_be_bytes(),
415    );
416    for token in tokens {
417        update_framed(&mut hasher, token.as_bytes());
418    }
419    PathDigest(hasher.finalize().into())
420}
421
422fn strictly_sorted_unique(values: &[PathDigest]) -> bool {
423    values.windows(2).all(|pair| pair[0] < pair[1])
424}
425
426fn deserialize_path_digests<'de, D>(deserializer: D) -> Result<Vec<PathDigest>, D::Error>
427where
428    D: Deserializer<'de>,
429{
430    struct DigestVisitor;
431
432    impl<'de> Visitor<'de> for DigestVisitor {
433        type Value = Vec<PathDigest>;
434
435        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
436            write!(
437                formatter,
438                "at most {HARD_MAX_API_VISIBILITY_DIFF_PATHS} path digests"
439            )
440        }
441
442        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
443        where
444            A: SeqAccess<'de>,
445        {
446            let mut digests = Vec::new();
447            while digests.len() < usize::from(HARD_MAX_API_VISIBILITY_DIFF_PATHS) {
448                match sequence.next_element()? {
449                    Some(digest) => digests.push(digest),
450                    None => return Ok(digests),
451                }
452            }
453            if sequence.next_element::<IgnoredAny>()?.is_some() {
454                return Err(serde::de::Error::custom(
455                    "API visibility path-digest list exceeds compiled limit",
456                ));
457            }
458            Ok(digests)
459        }
460    }
461
462    deserializer.deserialize_seq(DigestVisitor)
463}