1use 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#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct PathDigest([u8; 32]);
32
33impl PathDigest {
34 pub fn for_pattern(pattern: &JsonPathPattern) -> Self {
36 digest_path(&pattern.tokens)
37 }
38
39 pub const fn as_bytes(&self) -> &[u8; 32] {
41 &self.0
42 }
43
44 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#[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 pub fn added_path_hashes(&self) -> &[PathDigest] {
100 &self.added_path_hashes
101 }
102
103 pub fn removed_path_hashes(&self) -> &[PathDigest] {
105 &self.removed_path_hashes
106 }
107
108 pub fn changed_type_path_hashes(&self) -> &[PathDigest] {
110 &self.changed_type_path_hashes
111 }
112
113 pub fn changed_value_path_hashes(&self) -> &[PathDigest] {
115 &self.changed_value_path_hashes
116 }
117
118 pub const fn omitted_diff_count(&self) -> u32 {
120 self.omitted_diff_count
121 }
122
123 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 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 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}