1use std::fmt;
4
5use serde::{
6 de::{IgnoredAny, SeqAccess, Visitor},
7 Deserialize, Deserializer, Serialize, Serializer,
8};
9use sha2::{Digest, Sha256};
10
11use crate::api_evidence::profiled::ProfiledApiVisibilityError;
12
13const PROFILE_ID_DOMAIN: &[u8] = b"venom.api-visibility.projection-policy.v3\0";
14
15pub const HARD_MAX_API_COMPARISON_PROFILE_PATHS: usize = 256;
17pub const HARD_MAX_API_COMPARISON_PATH_BYTES: usize = 512;
19pub const HARD_MAX_API_COMPARISON_PATH_DEPTH: usize = 128;
21pub const HARD_MAX_API_VISIBILITY_DIFF_PATHS: u16 = 1_024;
23pub const DEFAULT_API_VISIBILITY_DIFF_PATHS: u16 = 64;
25
26pub const CURRENT_API_COMPARISON_ALGORITHM_VERSION: ComparisonAlgorithmVersion =
28 ComparisonAlgorithmVersion::V3;
29pub const CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION: CanonicalizationVersion =
31 CanonicalizationVersion::V2;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36#[non_exhaustive]
37pub enum ComparisonAlgorithmVersion {
38 V2,
40 V3,
42}
43
44impl ComparisonAlgorithmVersion {
45 pub(super) fn as_str(self) -> &'static str {
46 match self {
47 Self::V2 => "v2",
48 Self::V3 => "v3",
49 }
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56#[non_exhaustive]
57pub enum CanonicalizationVersion {
58 V2,
60}
61
62impl CanonicalizationVersion {
63 pub(super) fn as_str(self) -> &'static str {
64 match self {
65 Self::V2 => "v2",
66 }
67 }
68}
69
70#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub struct JsonPathPattern {
79 canonical: String,
80 pub(super) tokens: Vec<String>,
81}
82
83impl JsonPathPattern {
84 pub fn new(value: impl Into<String>) -> Result<Self, ProfiledApiVisibilityError> {
86 let value = value.into();
87 if value.len() > HARD_MAX_API_COMPARISON_PATH_BYTES {
88 return Err(ProfiledApiVisibilityError::PathTooLong {
89 maximum: HARD_MAX_API_COMPARISON_PATH_BYTES,
90 });
91 }
92 if !value.is_empty() && !value.starts_with('/') {
93 return Err(ProfiledApiVisibilityError::InvalidPathPattern {
94 reason: "non-root patterns must start with '/'",
95 });
96 }
97
98 let tokens = if value.is_empty() {
99 Vec::new()
100 } else {
101 value
102 .split('/')
103 .skip(1)
104 .map(decode_pointer_token)
105 .collect::<Result<Vec<_>, _>>()?
106 };
107 if tokens.len() > HARD_MAX_API_COMPARISON_PATH_DEPTH {
108 return Err(ProfiledApiVisibilityError::PathTooDeep {
109 maximum: HARD_MAX_API_COMPARISON_PATH_DEPTH,
110 });
111 }
112
113 let canonical = encode_pointer(&tokens);
114 if canonical.len() > HARD_MAX_API_COMPARISON_PATH_BYTES {
115 return Err(ProfiledApiVisibilityError::PathTooLong {
116 maximum: HARD_MAX_API_COMPARISON_PATH_BYTES,
117 });
118 }
119 Ok(Self { canonical, tokens })
120 }
121
122 pub fn as_str(&self) -> &str {
124 &self.canonical
125 }
126
127 fn matches_prefix_of(&self, path: &[String]) -> bool {
128 self.tokens.len() <= path.len() && tokens_match(&self.tokens, &path[..self.tokens.len()])
129 }
130
131 fn path_is_prefix(&self, path: &[String]) -> bool {
132 path.len() <= self.tokens.len() && tokens_match(&self.tokens[..path.len()], path)
133 }
134
135 fn matches_exactly(&self, path: &[String]) -> bool {
136 self.tokens.len() == path.len() && tokens_match(&self.tokens, path)
137 }
138}
139
140impl fmt::Debug for JsonPathPattern {
141 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142 formatter.write_str("JsonPathPattern(<redacted>)")
143 }
144}
145
146impl Serialize for JsonPathPattern {
147 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
148 where
149 S: Serializer,
150 {
151 serializer.serialize_str(&self.canonical)
152 }
153}
154
155impl<'de> Deserialize<'de> for JsonPathPattern {
156 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157 where
158 D: Deserializer<'de>,
159 {
160 let value = String::deserialize(deserializer)?;
161 Self::new(value).map_err(serde::de::Error::custom)
162 }
163}
164
165#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
167pub struct ProjectionPolicyId([u8; 32]);
168
169impl ProjectionPolicyId {
170 pub const fn as_bytes(&self) -> &[u8; 32] {
172 &self.0
173 }
174
175 pub fn to_hex(self) -> String {
177 encode_digest(self.0)
178 }
179}
180
181impl fmt::Debug for ProjectionPolicyId {
182 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183 formatter
184 .debug_tuple("ProjectionPolicyId")
185 .field(&self.to_hex())
186 .finish()
187 }
188}
189
190impl fmt::Display for ProjectionPolicyId {
191 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192 formatter.write_str(&self.to_hex())
193 }
194}
195
196impl Serialize for ProjectionPolicyId {
197 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
198 where
199 S: Serializer,
200 {
201 serializer.serialize_str(&self.to_hex())
202 }
203}
204
205impl<'de> Deserialize<'de> for ProjectionPolicyId {
206 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
207 where
208 D: Deserializer<'de>,
209 {
210 deserialize_digest(deserializer).map(Self)
211 }
212}
213
214#[derive(Clone, PartialEq, Eq, Serialize)]
216pub struct ApiComparisonProfile {
217 algorithm_version: ComparisonAlgorithmVersion,
218 selected_paths: Vec<JsonPathPattern>,
219 ignored_paths: Vec<JsonPathPattern>,
220 unordered_arrays: Vec<JsonPathPattern>,
221 max_diff_paths: u16,
222 #[serde(skip)]
223 projection_policy_id: ProjectionPolicyId,
224}
225
226impl ApiComparisonProfile {
227 pub fn new(
233 selected_paths: Vec<JsonPathPattern>,
234 ignored_paths: Vec<JsonPathPattern>,
235 unordered_arrays: Vec<JsonPathPattern>,
236 max_diff_paths: u16,
237 ) -> Result<Self, ProfiledApiVisibilityError> {
238 if max_diff_paths > HARD_MAX_API_VISIBILITY_DIFF_PATHS {
239 return Err(ProfiledApiVisibilityError::TooManyDiffPaths {
240 maximum: HARD_MAX_API_VISIBILITY_DIFF_PATHS,
241 });
242 }
243
244 let selected_paths = normalized_subtrees(selected_paths);
245 let ignored_paths = normalized_subtrees(ignored_paths);
246 let unordered_arrays = normalized_patterns(unordered_arrays);
247 let total = selected_paths
248 .len()
249 .saturating_add(ignored_paths.len())
250 .saturating_add(unordered_arrays.len());
251 if total > HARD_MAX_API_COMPARISON_PROFILE_PATHS {
252 return Err(ProfiledApiVisibilityError::TooManyProfilePaths {
253 maximum: HARD_MAX_API_COMPARISON_PROFILE_PATHS,
254 });
255 }
256 if selected_paths.iter().any(|selected| {
257 ignored_paths
258 .iter()
259 .any(|ignored| ignored.matches_prefix_of(&selected.tokens))
260 }) {
261 return Err(ProfiledApiVisibilityError::ConflictingPathPolicy);
262 }
263
264 let algorithm_version = CURRENT_API_COMPARISON_ALGORITHM_VERSION;
265 let projection_policy_id = projection_policy_id(
266 algorithm_version,
267 &selected_paths,
268 &ignored_paths,
269 &unordered_arrays,
270 max_diff_paths,
271 );
272 Ok(Self {
273 algorithm_version,
274 selected_paths,
275 ignored_paths,
276 unordered_arrays,
277 max_diff_paths,
278 projection_policy_id,
279 })
280 }
281
282 pub const fn algorithm_version(&self) -> ComparisonAlgorithmVersion {
284 self.algorithm_version
285 }
286
287 pub fn selected_paths(&self) -> &[JsonPathPattern] {
289 &self.selected_paths
290 }
291
292 pub fn ignored_paths(&self) -> &[JsonPathPattern] {
294 &self.ignored_paths
295 }
296
297 pub fn unordered_arrays(&self) -> &[JsonPathPattern] {
299 &self.unordered_arrays
300 }
301
302 pub const fn max_diff_paths(&self) -> u16 {
304 self.max_diff_paths
305 }
306
307 pub const fn projection_policy_id(&self) -> ProjectionPolicyId {
309 self.projection_policy_id
310 }
311
312 pub(super) fn is_ignored(&self, path: &[String]) -> bool {
313 self.ignored_paths
314 .iter()
315 .any(|pattern| pattern.matches_prefix_of(path))
316 }
317
318 pub(super) fn is_relevant(&self, path: &[String]) -> bool {
319 self.selected_paths.is_empty()
320 || self
321 .selected_paths
322 .iter()
323 .any(|pattern| pattern.matches_prefix_of(path) || pattern.path_is_prefix(path))
324 }
325
326 pub(super) fn is_unordered_array(&self, path: &[String]) -> bool {
327 self.unordered_arrays
328 .iter()
329 .any(|pattern| pattern.matches_exactly(path))
330 }
331
332 pub(super) fn is_selected_root(&self, path: &[String]) -> bool {
333 if self.selected_paths.is_empty() {
334 path.is_empty()
335 } else {
336 self.selected_paths
337 .iter()
338 .any(|pattern| pattern.matches_exactly(path))
339 }
340 }
341}
342
343impl Default for ApiComparisonProfile {
344 fn default() -> Self {
345 Self::new(
346 Vec::new(),
347 Vec::new(),
348 Vec::new(),
349 DEFAULT_API_VISIBILITY_DIFF_PATHS,
350 )
351 .expect("compiled default API comparison profile is valid")
352 }
353}
354
355impl fmt::Debug for ApiComparisonProfile {
356 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
357 formatter
358 .debug_struct("ApiComparisonProfile")
359 .field("algorithm_version", &self.algorithm_version)
360 .field("selected_path_count", &self.selected_paths.len())
361 .field("ignored_path_count", &self.ignored_paths.len())
362 .field("unordered_array_count", &self.unordered_arrays.len())
363 .field("max_diff_paths", &self.max_diff_paths)
364 .field("projection_policy_id", &self.projection_policy_id)
365 .finish()
366 }
367}
368
369impl<'de> Deserialize<'de> for ApiComparisonProfile {
370 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
371 where
372 D: Deserializer<'de>,
373 {
374 #[derive(Deserialize)]
375 #[serde(deny_unknown_fields)]
376 struct WireProfile {
377 algorithm_version: ComparisonAlgorithmVersion,
378 #[serde(deserialize_with = "deserialize_patterns")]
379 selected_paths: Vec<JsonPathPattern>,
380 #[serde(deserialize_with = "deserialize_patterns")]
381 ignored_paths: Vec<JsonPathPattern>,
382 #[serde(deserialize_with = "deserialize_patterns")]
383 unordered_arrays: Vec<JsonPathPattern>,
384 max_diff_paths: u16,
385 }
386
387 let wire = WireProfile::deserialize(deserializer)?;
388 if wire.algorithm_version != CURRENT_API_COMPARISON_ALGORITHM_VERSION {
389 return Err(serde::de::Error::custom(
390 "unsupported API comparison algorithm version",
391 ));
392 }
393 Self::new(
394 wire.selected_paths,
395 wire.ignored_paths,
396 wire.unordered_arrays,
397 wire.max_diff_paths,
398 )
399 .map_err(serde::de::Error::custom)
400 }
401}
402
403fn normalized_patterns(mut patterns: Vec<JsonPathPattern>) -> Vec<JsonPathPattern> {
404 patterns.sort_unstable();
405 patterns.dedup();
406 patterns
407}
408
409fn normalized_subtrees(patterns: Vec<JsonPathPattern>) -> Vec<JsonPathPattern> {
410 let patterns = normalized_patterns(patterns);
411 let mut normalized = Vec::<JsonPathPattern>::new();
412 for pattern in patterns {
413 if normalized
414 .iter()
415 .any(|ancestor| ancestor.matches_prefix_of(&pattern.tokens))
416 {
417 continue;
418 }
419 normalized.push(pattern);
420 }
421 normalized
422}
423
424fn projection_policy_id(
425 algorithm_version: ComparisonAlgorithmVersion,
426 selected_paths: &[JsonPathPattern],
427 ignored_paths: &[JsonPathPattern],
428 unordered_arrays: &[JsonPathPattern],
429 max_diff_paths: u16,
430) -> ProjectionPolicyId {
431 let mut hasher = Sha256::new();
432 hasher.update(PROFILE_ID_DOMAIN);
433 update_framed(&mut hasher, algorithm_version.as_str().as_bytes());
434 update_framed(
435 &mut hasher,
436 CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION
437 .as_str()
438 .as_bytes(),
439 );
440 update_pattern_list(&mut hasher, b"selected", selected_paths);
441 update_pattern_list(&mut hasher, b"ignored", ignored_paths);
442 update_pattern_list(&mut hasher, b"unordered", unordered_arrays);
443 hasher.update(max_diff_paths.to_be_bytes());
444 ProjectionPolicyId(hasher.finalize().into())
445}
446
447fn update_pattern_list(hasher: &mut Sha256, name: &[u8], patterns: &[JsonPathPattern]) {
448 update_framed(hasher, name);
449 hasher.update(
450 u64::try_from(patterns.len())
451 .unwrap_or(u64::MAX)
452 .to_be_bytes(),
453 );
454 for pattern in patterns {
455 update_framed(hasher, pattern.canonical.as_bytes());
456 }
457}
458
459pub(super) fn update_framed(hasher: &mut Sha256, value: &[u8]) {
460 hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
461 hasher.update(value);
462}
463
464fn tokens_match(pattern: &[String], path: &[String]) -> bool {
465 pattern
466 .iter()
467 .zip(path)
468 .all(|(expected, actual)| expected == "*" || expected == actual)
469}
470
471fn decode_pointer_token(token: &str) -> Result<String, ProfiledApiVisibilityError> {
472 let mut decoded = String::with_capacity(token.len());
473 let mut chars = token.chars();
474 while let Some(character) = chars.next() {
475 if character != '~' {
476 decoded.push(character);
477 continue;
478 }
479 match chars.next() {
480 Some('0') => decoded.push('~'),
481 Some('1') => decoded.push('/'),
482 _ => {
483 return Err(ProfiledApiVisibilityError::InvalidPathPattern {
484 reason: "'~' escapes must be ~0 or ~1",
485 });
486 },
487 }
488 }
489 Ok(decoded)
490}
491
492fn encode_pointer(tokens: &[String]) -> String {
493 let mut encoded = String::new();
494 for token in tokens {
495 encoded.push('/');
496 encoded.push_str(&token.replace('~', "~0").replace('/', "~1"));
497 }
498 encoded
499}
500
501pub(super) fn deserialize_digest<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
502where
503 D: Deserializer<'de>,
504{
505 let value = String::deserialize(deserializer)?;
506 if value.len() != 64 {
507 return Err(serde::de::Error::custom(
508 "API visibility digest must contain 64 hexadecimal characters",
509 ));
510 }
511 decode_digest(&value).map_err(serde::de::Error::custom)
512}
513
514pub(super) fn encode_digest(bytes: [u8; 32]) -> String {
515 const HEX: &[u8; 16] = b"0123456789abcdef";
516 let mut encoded = String::with_capacity(64);
517 for byte in bytes {
518 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
519 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
520 }
521 encoded
522}
523
524pub(super) fn decode_digest(value: &str) -> Result<[u8; 32], &'static str> {
525 let bytes = value.as_bytes();
526 if bytes.len() != 64 {
527 return Err("API visibility digest must contain exactly 32 bytes");
528 }
529 let (pairs, remainder) = bytes.as_chunks::<2>();
530 debug_assert!(remainder.is_empty());
531 let mut decoded = [0_u8; 32];
532 for (index, pair) in pairs.iter().enumerate() {
533 let high = decode_hex_nibble(pair[0])?;
534 let low = decode_hex_nibble(pair[1])?;
535 decoded[index] = (high << 4) | low;
536 }
537 Ok(decoded)
538}
539
540fn decode_hex_nibble(value: u8) -> Result<u8, &'static str> {
541 match value {
542 b'0'..=b'9' => Ok(value - b'0'),
543 b'a'..=b'f' => Ok(value - b'a' + 10),
544 _ => Err("API visibility digest must use lowercase hexadecimal"),
545 }
546}
547
548fn deserialize_patterns<'de, D>(deserializer: D) -> Result<Vec<JsonPathPattern>, D::Error>
549where
550 D: Deserializer<'de>,
551{
552 struct PatternVisitor;
553
554 impl<'de> Visitor<'de> for PatternVisitor {
555 type Value = Vec<JsonPathPattern>;
556
557 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
558 write!(
559 formatter,
560 "at most {HARD_MAX_API_COMPARISON_PROFILE_PATHS} JSON path patterns"
561 )
562 }
563
564 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
565 where
566 A: SeqAccess<'de>,
567 {
568 let mut patterns = Vec::new();
569 while patterns.len() < HARD_MAX_API_COMPARISON_PROFILE_PATHS {
570 match sequence.next_element()? {
571 Some(pattern) => patterns.push(pattern),
572 None => return Ok(patterns),
573 }
574 }
575 if sequence.next_element::<IgnoredAny>()?.is_some() {
576 return Err(serde::de::Error::custom(
577 "API comparison path-pattern list exceeds compiled limit",
578 ));
579 }
580 Ok(patterns)
581 }
582 }
583
584 deserializer.deserialize_seq(PatternVisitor)
585}