Skip to main content

termivar_scanner/api_evidence/profiled/
policy.rs

1//! Projection-policy validation, versioning, and stable identifiers.
2
3use 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
15/// Hard ceiling for the number of path patterns in one projection profile.
16pub const HARD_MAX_API_COMPARISON_PROFILE_PATHS: usize = 256;
17/// Hard ceiling for one canonical RFC 6901 path-pattern string.
18pub const HARD_MAX_API_COMPARISON_PATH_BYTES: usize = 512;
19/// Hard ceiling for decoded path-pattern segments.
20pub const HARD_MAX_API_COMPARISON_PATH_DEPTH: usize = 128;
21/// Hard ceiling for path digests retained by one comparison explanation.
22pub const HARD_MAX_API_VISIBILITY_DIFF_PATHS: u16 = 1_024;
23/// Default number of path digests retained by one comparison explanation.
24pub const DEFAULT_API_VISIBILITY_DIFF_PATHS: u16 = 64;
25
26/// Current deterministic comparator algorithm.
27pub const CURRENT_API_COMPARISON_ALGORITHM_VERSION: ComparisonAlgorithmVersion =
28    ComparisonAlgorithmVersion::V3;
29/// Current deterministic canonicalization format.
30pub const CURRENT_API_VISIBILITY_CANONICALIZATION_VERSION: CanonicalizationVersion =
31    CanonicalizationVersion::V2;
32
33/// Version of the high-level projection and comparison algorithm.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36#[non_exhaustive]
37pub enum ComparisonAlgorithmVersion {
38    /// Original profiled comparator with deterministic path projection.
39    V2,
40    /// Dimension-aware explanations that never attach body paths to status differences.
41    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/// Version of the canonical tree-hash encoding used by profiled views.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56#[non_exhaustive]
57pub enum CanonicalizationVersion {
58    /// Domain-separated Merkle-style JSON tree hashing with typed leaves.
59    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/// Validated RFC 6901 JSON Pointer pattern.
71///
72/// An empty string addresses the document root. A segment equal to `*` is a
73/// deterministic wildcard extension used primarily for array members. Other
74/// segments use standard `~0` and `~1` escaping. A selected pattern names a
75/// subtree; an ignored pattern removes a subtree; and an unordered-array
76/// pattern must match the array path itself.
77#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub struct JsonPathPattern {
79    canonical: String,
80    pub(super) tokens: Vec<String>,
81}
82
83impl JsonPathPattern {
84    /// Parses and canonicalizes one bounded pattern.
85    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    /// Returns the canonical RFC 6901 representation.
123    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/// Stable digest of a complete projection policy.
166#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
167pub struct ProjectionPolicyId([u8; 32]);
168
169impl ProjectionPolicyId {
170    /// Returns the raw SHA-256 digest bytes.
171    pub const fn as_bytes(&self) -> &[u8; 32] {
172        &self.0
173    }
174
175    /// Returns a lowercase hexadecimal representation.
176    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/// Deterministic projection and explanation policy for the current comparator.
215#[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    /// Creates a validated current-version profile.
228    ///
229    /// Empty `selected_paths` means the complete document. Patterns are sorted
230    /// and deduplicated before the policy ID is calculated. Ignore rules take
231    /// precedence over selection rules.
232    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    /// Returns the deterministic comparison algorithm version.
283    pub const fn algorithm_version(&self) -> ComparisonAlgorithmVersion {
284        self.algorithm_version
285    }
286
287    /// Returns the selected subtree patterns; an empty slice selects all.
288    pub fn selected_paths(&self) -> &[JsonPathPattern] {
289        &self.selected_paths
290    }
291
292    /// Returns the ignored subtree patterns.
293    pub fn ignored_paths(&self) -> &[JsonPathPattern] {
294        &self.ignored_paths
295    }
296
297    /// Returns array paths whose element order is semantically irrelevant.
298    pub fn unordered_arrays(&self) -> &[JsonPathPattern] {
299        &self.unordered_arrays
300    }
301
302    /// Returns the combined explanation-path limit.
303    pub const fn max_diff_paths(&self) -> u16 {
304        self.max_diff_paths
305    }
306
307    /// Returns the stable digest of this complete projection policy.
308    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}