Skip to main content

vellaveto_engine/
traced.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! Traced policy evaluation methods.
9//!
10//! This module provides evaluation methods that record detailed per-policy
11//! match information for OPA-style decision explanations. Use these methods
12//! when `?trace=true` is requested - they have ~20% allocation overhead
13//! compared to the non-traced hot path.
14
15use crate::compiled::{CompiledConstraint, CompiledPolicy};
16use crate::error::EngineError;
17use crate::PolicyEngine;
18use std::time::Instant;
19use vellaveto_types::{
20    Action, ActionSummary, ConstraintResult, EvaluationContext, EvaluationTrace, Policy,
21    PolicyMatch, PolicyType, Verdict,
22};
23
24impl PolicyEngine {
25    /// Evaluate an action with full decision trace.
26    ///
27    /// Opt-in alternative to [`Self::evaluate_action`] that records per-policy match
28    /// details for OPA-style decision explanations. Has ~20% allocation overhead
29    /// compared to the non-traced hot path, so use only when `?trace=true`.
30    #[must_use = "security verdicts must not be discarded"]
31    pub fn evaluate_action_traced(
32        &self,
33        action: &Action,
34    ) -> Result<(Verdict, EvaluationTrace), EngineError> {
35        let start = Instant::now();
36        let mut policies_checked: usize = 0;
37        let mut final_verdict: Option<Verdict> = None;
38
39        let action_summary = ActionSummary {
40            tool: action.tool.clone(),
41            function: action.function.clone(),
42            param_count: action.parameters.as_object().map(|o| o.len()).unwrap_or(0),
43            param_keys: action
44                .parameters
45                .as_object()
46                .map(|o| o.keys().cloned().collect())
47                .unwrap_or_default(),
48        };
49
50        // Topology pre-filter: check if the tool exists in the topology graph.
51        #[cfg(feature = "discovery")]
52        if let Some(deny) = self.check_topology(action) {
53            let trace = EvaluationTrace {
54                action_summary,
55                policies_checked: 0,
56                policies_matched: 0,
57                matches: vec![],
58                verdict: deny.clone(),
59                duration_us: start.elapsed().as_micros() as u64,
60            };
61            return Ok((deny, trace));
62        }
63
64        if self.compiled_policies.is_empty() {
65            let verdict = Verdict::Deny {
66                reason: "No policies defined".to_string(),
67            };
68            let trace = EvaluationTrace {
69                action_summary,
70                policies_checked: 0,
71                policies_matched: 0,
72                matches: Vec::new(),
73                verdict: verdict.clone(),
74                duration_us: start.elapsed().as_micros() as u64,
75            };
76            return Ok((verdict, trace));
77        }
78
79        // SECURITY (FIND-R206-001): Normalize tool/function names through homoglyph
80        // normalization before policy matching. This prevents fullwidth/Cyrillic/Greek
81        // characters from bypassing exact-match Deny policies. Patterns are normalized
82        // at compile time; input must be normalized at evaluation time for consistency.
83        // Matches the normalization in evaluate_with_compiled().
84        let norm_tool = crate::normalize::normalize_full(&action.tool);
85        let norm_func = crate::normalize::normalize_full(&action.function);
86
87        // Walk compiled policies using the tool index (same order as evaluate_with_compiled)
88        let indices = self.collect_candidate_indices_normalized(&norm_tool);
89        let mut policy_matches: Vec<PolicyMatch> = Vec::with_capacity(indices.len());
90
91        for idx in &indices {
92            let cp = &self.compiled_policies[*idx];
93            policies_checked += 1;
94
95            let tool_matched = cp.tool_matcher.matches_normalized(&norm_tool, &norm_func);
96            if !tool_matched {
97                policy_matches.push(PolicyMatch {
98                    policy_id: cp.policy.id.clone(),
99                    policy_name: cp.policy.name.clone(),
100                    policy_type: Self::policy_type_str(&cp.policy.policy_type),
101                    priority: cp.policy.priority,
102                    tool_matched: false,
103                    constraint_results: Vec::new(),
104                    verdict_contribution: None,
105                });
106                continue;
107            }
108
109            // Tool matched — evaluate the policy and record constraint details
110            let (verdict, constraint_results) = self.apply_compiled_policy_traced(action, cp)?;
111
112            let pm = PolicyMatch {
113                policy_id: cp.policy.id.clone(),
114                policy_name: cp.policy.name.clone(),
115                policy_type: Self::policy_type_str(&cp.policy.policy_type),
116                priority: cp.policy.priority,
117                tool_matched: true,
118                constraint_results,
119                verdict_contribution: verdict.clone(),
120            };
121            policy_matches.push(pm);
122
123            if let Some(v) = verdict {
124                if final_verdict.is_none() {
125                    final_verdict = Some(v);
126                }
127                // First match wins — stop checking further policies
128                break;
129            }
130            // None: on_no_match="continue", try next policy
131        }
132
133        let verdict = final_verdict.unwrap_or(Verdict::Deny {
134            reason: "No matching policy".to_string(),
135        });
136
137        let policies_matched = policy_matches.iter().filter(|m| m.tool_matched).count();
138
139        let trace = EvaluationTrace {
140            action_summary,
141            policies_checked,
142            policies_matched,
143            matches: policy_matches,
144            verdict: verdict.clone(),
145            duration_us: start.elapsed().as_micros() as u64,
146        };
147
148        Ok((verdict, trace))
149    }
150
151    /// Traced evaluation with optional session context.
152    pub(crate) fn evaluate_action_traced_ctx(
153        &self,
154        action: &Action,
155        context: Option<&EvaluationContext>,
156    ) -> Result<(Verdict, EvaluationTrace), EngineError> {
157        let start = Instant::now();
158        let mut policies_checked: usize = 0;
159        let mut final_verdict: Option<Verdict> = None;
160
161        let action_summary = ActionSummary {
162            tool: action.tool.clone(),
163            function: action.function.clone(),
164            param_count: action.parameters.as_object().map(|o| o.len()).unwrap_or(0),
165            param_keys: action
166                .parameters
167                .as_object()
168                .map(|o| o.keys().cloned().collect())
169                .unwrap_or_default(),
170        };
171
172        // SECURITY (R230-ENG-5): Topology pre-filter must run on ALL traced paths.
173        #[cfg(feature = "discovery")]
174        if let Some(deny) = self.check_topology(action) {
175            let trace = EvaluationTrace {
176                action_summary,
177                policies_checked: 0,
178                policies_matched: 0,
179                matches: vec![],
180                verdict: deny.clone(),
181                duration_us: start.elapsed().as_micros() as u64,
182            };
183            return Ok((deny, trace));
184        }
185
186        if self.compiled_policies.is_empty() {
187            let verdict = Verdict::Deny {
188                reason: "No policies defined".to_string(),
189            };
190            let trace = EvaluationTrace {
191                action_summary,
192                policies_checked: 0,
193                policies_matched: 0,
194                matches: Vec::new(),
195                verdict: verdict.clone(),
196                duration_us: start.elapsed().as_micros() as u64,
197            };
198            return Ok((verdict, trace));
199        }
200
201        // SECURITY (FIND-R206-001): Normalize for homoglyph-safe matching.
202        let norm_tool = crate::normalize::normalize_full(&action.tool);
203        let norm_func = crate::normalize::normalize_full(&action.function);
204
205        let indices = self.collect_candidate_indices_normalized(&norm_tool);
206        let mut policy_matches: Vec<PolicyMatch> = Vec::with_capacity(indices.len());
207
208        for idx in &indices {
209            let cp = &self.compiled_policies[*idx];
210            policies_checked += 1;
211
212            let tool_matched = cp.tool_matcher.matches_normalized(&norm_tool, &norm_func);
213            if !tool_matched {
214                policy_matches.push(PolicyMatch {
215                    policy_id: cp.policy.id.clone(),
216                    policy_name: cp.policy.name.clone(),
217                    policy_type: Self::policy_type_str(&cp.policy.policy_type),
218                    priority: cp.policy.priority,
219                    tool_matched: false,
220                    constraint_results: Vec::new(),
221                    verdict_contribution: None,
222                });
223                continue;
224            }
225
226            let (verdict, constraint_results) =
227                self.apply_compiled_policy_traced_ctx(action, cp, context)?;
228
229            let pm = PolicyMatch {
230                policy_id: cp.policy.id.clone(),
231                policy_name: cp.policy.name.clone(),
232                policy_type: Self::policy_type_str(&cp.policy.policy_type),
233                priority: cp.policy.priority,
234                tool_matched: true,
235                constraint_results,
236                verdict_contribution: verdict.clone(),
237            };
238            policy_matches.push(pm);
239
240            if let Some(v) = verdict {
241                if final_verdict.is_none() {
242                    final_verdict = Some(v);
243                }
244                break;
245            }
246        }
247
248        let verdict = final_verdict.unwrap_or(Verdict::Deny {
249            reason: "No matching policy".to_string(),
250        });
251
252        let policies_matched = policy_matches.iter().filter(|m| m.tool_matched).count();
253
254        let trace = EvaluationTrace {
255            action_summary,
256            policies_checked,
257            policies_matched,
258            matches: policy_matches,
259            verdict: verdict.clone(),
260            duration_us: start.elapsed().as_micros() as u64,
261        };
262
263        Ok((verdict, trace))
264    }
265
266    /// Collect candidate policy indices using a pre-normalized tool name.
267    ///
268    /// SECURITY (FIND-R206-001): Uses the normalized tool name for index lookup,
269    /// ensuring homoglyph-normalized tool names match the normalized index keys.
270    fn collect_candidate_indices_normalized(&self, norm_tool: &str) -> Vec<usize> {
271        if self.tool_index.is_empty() && self.always_check.is_empty() {
272            // No index: return all indices in order
273            return (0..self.compiled_policies.len()).collect();
274        }
275
276        let tool_specific = self.tool_index.get(norm_tool);
277        let tool_slice = tool_specific.map(|v| v.as_slice()).unwrap_or(&[]);
278        let always_slice = &self.always_check;
279
280        // Merge two sorted index slices.
281        // SECURITY (R26-ENG-1): Deduplicate when same index in both slices.
282        let mut result = Vec::with_capacity(tool_slice.len() + always_slice.len());
283        let mut ti = 0;
284        let mut ai = 0;
285        loop {
286            let next_idx = match (tool_slice.get(ti), always_slice.get(ai)) {
287                (Some(&t), Some(&a)) => {
288                    if t < a {
289                        ti += 1;
290                        t
291                    } else if t > a {
292                        ai += 1;
293                        a
294                    } else {
295                        ti += 1;
296                        ai += 1;
297                        t
298                    }
299                }
300                (Some(&t), None) => {
301                    ti += 1;
302                    t
303                }
304                (None, Some(&a)) => {
305                    ai += 1;
306                    a
307                }
308                (None, None) => break,
309            };
310            result.push(next_idx);
311        }
312        result
313    }
314
315    /// Apply a compiled policy and return both the verdict and constraint trace.
316    /// Returns `None` as the verdict when `on_no_match: "continue"` and no constraints fired.
317    fn apply_compiled_policy_traced(
318        &self,
319        action: &Action,
320        cp: &CompiledPolicy,
321    ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
322        self.apply_compiled_policy_traced_ctx(action, cp, None)
323    }
324
325    fn apply_compiled_policy_traced_ctx(
326        &self,
327        action: &Action,
328        cp: &CompiledPolicy,
329        context: Option<&EvaluationContext>,
330    ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
331        // Check path rules BEFORE policy type dispatch (mirrors apply_compiled_policy).
332        // Without this, ?trace=true would bypass all path/domain blocking.
333        if let Some(denial) = self.check_path_rules(action, cp) {
334            return Ok((Some(denial), Vec::new()));
335        }
336        // Check network rules before policy type dispatch.
337        if let Some(denial) = self.check_network_rules(action, cp) {
338            return Ok((Some(denial), Vec::new()));
339        }
340        // Check IP rules (DNS rebinding protection) after network rules.
341        // SECURITY: Without this, ?trace=true would bypass IP-based blocking.
342        if let Some(denial) = self.check_ip_rules(action, cp) {
343            return Ok((Some(denial), Vec::new()));
344        }
345        // Check context conditions.
346        // SECURITY: Fail-closed when context conditions exist but no context provided.
347        if !cp.context_conditions.is_empty() {
348            match context {
349                Some(ctx) => {
350                    // SECURITY (R231-ENG-3): Normalize tool name before context
351                    // conditions (mirrors lib.rs).
352                    let norm_tool = crate::normalize::normalize_full(&action.tool);
353                    if let Some(denial) = self.check_context_conditions(ctx, cp, &norm_tool) {
354                        return Ok((Some(denial), Vec::new()));
355                    }
356                }
357                None => {
358                    return Ok((Some(Verdict::Deny {
359                        reason: format!(
360                            "Policy '{}' requires evaluation context (has {} context condition(s)) but none was provided",
361                            cp.policy.name,
362                            cp.context_conditions.len()
363                        ),
364                    }), Vec::new()));
365                }
366            }
367        }
368
369        match &cp.policy.policy_type {
370            PolicyType::Allow => Ok((Some(Verdict::Allow), Vec::new())),
371            PolicyType::Deny => Ok((
372                Some(Verdict::Deny {
373                    reason: cp.deny_reason.clone(),
374                }),
375                Vec::new(),
376            )),
377            PolicyType::Conditional { .. } => self.evaluate_compiled_conditions_traced(action, cp),
378            // Handle future variants - fail closed (deny)
379            _ => Ok((
380                Some(Verdict::Deny {
381                    reason: format!("Unknown policy type for '{}'", cp.policy.name),
382                }),
383                Vec::new(),
384            )),
385        }
386    }
387
388    /// Evaluate compiled conditions with full constraint tracing.
389    /// Delegates to `evaluate_compiled_conditions_core` with trace collection enabled.
390    /// Returns `None` as the verdict when `on_no_match: "continue"` and no constraints fired.
391    fn evaluate_compiled_conditions_traced(
392        &self,
393        action: &Action,
394        cp: &CompiledPolicy,
395    ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
396        let mut results = Some(Vec::with_capacity(cp.constraints.len()));
397        let verdict = self.evaluate_compiled_conditions_core(action, cp, &mut results)?;
398        Ok((verdict, results.unwrap_or_default()))
399    }
400
401    /// Evaluate a single compiled constraint with tracing.
402    pub(crate) fn evaluate_compiled_constraint_traced(
403        &self,
404        action: &Action,
405        policy: &Policy,
406        constraint: &CompiledConstraint,
407    ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
408        let param_name = constraint.param();
409        let on_match = constraint.on_match();
410        let on_missing = constraint.on_missing();
411
412        // Wildcard param: scan all string values
413        if param_name == "*" {
414            let all_values = Self::collect_all_string_values(&action.parameters);
415            let mut results = Vec::with_capacity(all_values.len());
416            if all_values.is_empty() {
417                if on_missing == "skip" {
418                    return Ok((None, Vec::new()));
419                }
420                results.push(ConstraintResult {
421                    constraint_type: Self::constraint_type_str(constraint),
422                    param: "*".to_string(),
423                    expected: "any string values".to_string(),
424                    actual: "none found".to_string(),
425                    passed: false,
426                });
427                let verdict = Self::make_constraint_verdict(
428                    "deny",
429                    &format!(
430                        "No string values found in parameters (fail-closed) in policy '{}'",
431                        policy.name
432                    ),
433                )?;
434                return Ok((Some(verdict), results));
435            }
436            for (value_path, value_str) in &all_values {
437                let json_val = serde_json::Value::String((*value_str).to_string());
438                let matched = self.constraint_matches_value(&json_val, constraint);
439                results.push(ConstraintResult {
440                    constraint_type: Self::constraint_type_str(constraint),
441                    param: value_path.clone(),
442                    expected: Self::constraint_expected_str(constraint),
443                    actual: value_str.to_string(),
444                    passed: !matched,
445                });
446                if matched {
447                    let verdict = Self::make_constraint_verdict(
448                        on_match,
449                        &format!(
450                            "Parameter '{}' value triggered constraint (policy '{}')",
451                            value_path, policy.name
452                        ),
453                    )?;
454                    return Ok((Some(verdict), results));
455                }
456            }
457            return Ok((None, results));
458        }
459
460        // Get specific parameter
461        let param_value = match Self::get_param_by_path(&action.parameters, param_name) {
462            Some(v) => v,
463            None => {
464                if on_missing == "skip" {
465                    return Ok((
466                        None,
467                        vec![ConstraintResult {
468                            constraint_type: Self::constraint_type_str(constraint),
469                            param: param_name.to_string(),
470                            expected: Self::constraint_expected_str(constraint),
471                            actual: "missing".to_string(),
472                            passed: true,
473                        }],
474                    ));
475                }
476                let verdict = Self::make_constraint_verdict(
477                    "deny",
478                    &format!(
479                        "Parameter '{}' missing (fail-closed) in policy '{}'",
480                        param_name, policy.name
481                    ),
482                )?;
483                return Ok((
484                    Some(verdict),
485                    vec![ConstraintResult {
486                        constraint_type: Self::constraint_type_str(constraint),
487                        param: param_name.to_string(),
488                        expected: Self::constraint_expected_str(constraint),
489                        actual: "missing".to_string(),
490                        passed: false,
491                    }],
492                ));
493            }
494        };
495
496        let matched = self.constraint_matches_value(param_value, constraint);
497        let actual_str = param_value
498            .as_str()
499            .unwrap_or(&param_value.to_string())
500            .to_string();
501        let result = ConstraintResult {
502            constraint_type: Self::constraint_type_str(constraint),
503            param: param_name.to_string(),
504            expected: Self::constraint_expected_str(constraint),
505            actual: actual_str,
506            passed: !matched,
507        };
508
509        if matched {
510            let verdict = self.evaluate_compiled_constraint_value(
511                policy,
512                param_name,
513                on_match,
514                param_value,
515                constraint,
516            )?;
517            Ok((verdict, vec![result]))
518        } else {
519            Ok((None, vec![result]))
520        }
521    }
522
523    /// Check if a constraint matches a given value (without producing a verdict).
524    fn constraint_matches_value(
525        &self,
526        value: &serde_json::Value,
527        constraint: &CompiledConstraint,
528    ) -> bool {
529        match constraint {
530            CompiledConstraint::Glob { matcher, .. } => {
531                if let Some(s) = value.as_str() {
532                    match Self::normalize_path_bounded(s, self.max_path_decode_iterations) {
533                        Ok(ref normalized) => matcher.is_match(normalized),
534                        Err(_) => true,
535                    }
536                } else {
537                    true // non-string → treated as match (fail-closed)
538                }
539            }
540            CompiledConstraint::NotGlob { matchers, .. } => {
541                if let Some(s) = value.as_str() {
542                    match Self::normalize_path_bounded(s, self.max_path_decode_iterations) {
543                        Ok(ref normalized) => !matchers.iter().any(|(_, m)| m.is_match(normalized)),
544                        Err(_) => true,
545                    }
546                } else {
547                    true
548                }
549            }
550            CompiledConstraint::Regex { regex, .. } => {
551                if let Some(s) = value.as_str() {
552                    regex.is_match(s)
553                } else {
554                    true
555                }
556            }
557            CompiledConstraint::DomainMatch { pattern, .. } => {
558                if let Some(s) = value.as_str() {
559                    let domain = Self::extract_domain(s);
560                    // SECURITY (R34-ENG-1): IDNA fail-closed guard matching compiled path.
561                    // If domain contains non-ASCII and cannot be normalized, treat as match
562                    // (fail-closed: deny) to prevent bypass via unnormalizable domains.
563                    if !domain.is_ascii() && Self::normalize_domain_for_match(&domain).is_none() {
564                        return true;
565                    }
566                    Self::match_domain_pattern(&domain, pattern)
567                } else {
568                    true // non-string → fail-closed
569                }
570            }
571            CompiledConstraint::DomainNotIn { patterns, .. } => {
572                if let Some(s) = value.as_str() {
573                    let domain = Self::extract_domain(s);
574                    // SECURITY (R34-ENG-1): IDNA fail-closed for DomainNotIn.
575                    // If domain contains non-ASCII and cannot be normalized, it cannot
576                    // be in the allowlist — constraint fires (fail-closed).
577                    if !domain.is_ascii() && Self::normalize_domain_for_match(&domain).is_none() {
578                        return true;
579                    }
580                    !patterns
581                        .iter()
582                        .any(|p| Self::match_domain_pattern(&domain, p))
583                } else {
584                    true
585                }
586            }
587            CompiledConstraint::Eq {
588                value: expected, ..
589            } => value == expected,
590            CompiledConstraint::Ne {
591                value: expected, ..
592            } => value != expected,
593            CompiledConstraint::OneOf { values, .. } => values.contains(value),
594            CompiledConstraint::NoneOf { values, .. } => !values.contains(value),
595        }
596    }
597
598    fn policy_type_str(pt: &PolicyType) -> String {
599        match pt {
600            PolicyType::Allow => "allow".to_string(),
601            PolicyType::Deny => "deny".to_string(),
602            PolicyType::Conditional { .. } => "conditional".to_string(),
603            // Handle future variants
604            _ => "unknown".to_string(),
605        }
606    }
607
608    fn constraint_type_str(c: &CompiledConstraint) -> String {
609        match c {
610            CompiledConstraint::Glob { .. } => "glob".to_string(),
611            CompiledConstraint::NotGlob { .. } => "not_glob".to_string(),
612            CompiledConstraint::Regex { .. } => "regex".to_string(),
613            CompiledConstraint::DomainMatch { .. } => "domain_match".to_string(),
614            CompiledConstraint::DomainNotIn { .. } => "domain_not_in".to_string(),
615            CompiledConstraint::Eq { .. } => "eq".to_string(),
616            CompiledConstraint::Ne { .. } => "ne".to_string(),
617            CompiledConstraint::OneOf { .. } => "one_of".to_string(),
618            CompiledConstraint::NoneOf { .. } => "none_of".to_string(),
619        }
620    }
621
622    fn constraint_expected_str(c: &CompiledConstraint) -> String {
623        match c {
624            CompiledConstraint::Glob { pattern_str, .. } => {
625                format!("matches glob '{}'", pattern_str)
626            }
627            CompiledConstraint::NotGlob { matchers, .. } => {
628                let pats: Vec<&str> = matchers.iter().map(|(s, _)| s.as_str()).collect();
629                format!("not in [{}]", pats.join(", "))
630            }
631            CompiledConstraint::Regex { pattern_str, .. } => {
632                format!("matches regex '{}'", pattern_str)
633            }
634            CompiledConstraint::DomainMatch { pattern, .. } => {
635                format!("domain matches '{}'", pattern)
636            }
637            CompiledConstraint::DomainNotIn { patterns, .. } => {
638                format!("domain not in [{}]", patterns.join(", "))
639            }
640            CompiledConstraint::Eq { value, .. } => format!("equals {}", value),
641            CompiledConstraint::Ne { value, .. } => format!("not equal {}", value),
642            CompiledConstraint::OneOf { values, .. } => format!("one of {:?}", values),
643            CompiledConstraint::NoneOf { values, .. } => format!("none of {:?}", values),
644        }
645    }
646
647    /// Describe a JSON value's type and size without exposing raw content.
648    /// Used in trace output to give useful debugging info without leaking secrets.
649    pub(crate) fn describe_value(value: &serde_json::Value) -> String {
650        match value {
651            serde_json::Value::Null => "null".to_string(),
652            serde_json::Value::Bool(b) => format!("bool({})", b),
653            serde_json::Value::Number(n) => format!("number({})", n),
654            serde_json::Value::String(s) => format!("string({} chars)", s.len()),
655            serde_json::Value::Array(arr) => format!("array({} items)", arr.len()),
656            serde_json::Value::Object(obj) => format!("object({} keys)", obj.len()),
657        }
658    }
659
660    /// Maximum nesting depth limit for JSON depth calculation.
661    /// Returns this limit when exceeded rather than continuing traversal.
662    const MAX_JSON_DEPTH_LIMIT: usize = 128;
663
664    /// Maximum number of nodes to visit during JSON depth calculation.
665    /// Prevents OOM from extremely wide JSON (e.g., objects with 100K keys).
666    const MAX_JSON_DEPTH_NODES: usize = 10_000;
667
668    /// Calculate the nesting depth of a JSON value using an iterative approach.
669    /// Avoids stack overflow on adversarially deep JSON (e.g., 10,000+ levels).
670    ///
671    /// SECURITY (FIND-R46-005): Bounded by [`MAX_JSON_DEPTH_LIMIT`] (128) for
672    /// depth and [`MAX_JSON_DEPTH_NODES`] (10,000) for total nodes visited.
673    /// Returns the depth limit when either bound is exceeded, ensuring the
674    /// caller's depth check triggers a reject.
675    pub(crate) fn json_depth(value: &serde_json::Value) -> usize {
676        let mut max_depth: usize = 0;
677        let mut nodes_visited: usize = 0;
678        // Stack of (value, current_depth) to process iteratively
679        let mut stack: Vec<(&serde_json::Value, usize)> = vec![(value, 0)];
680
681        while let Some((val, depth)) = stack.pop() {
682            nodes_visited = nodes_visited.saturating_add(1); // FIND-R58-ENG-007: Trap 9
683            if depth > max_depth {
684                max_depth = depth;
685            }
686            // Early termination: if we exceed depth limit or node budget, stop
687            if max_depth > Self::MAX_JSON_DEPTH_LIMIT || nodes_visited > Self::MAX_JSON_DEPTH_NODES
688            {
689                return max_depth;
690            }
691            match val {
692                serde_json::Value::Array(arr) => {
693                    for item in arr {
694                        stack.push((item, depth + 1));
695                    }
696                }
697                serde_json::Value::Object(obj) => {
698                    for item in obj.values() {
699                        stack.push((item, depth + 1));
700                    }
701                }
702                _ => {}
703            }
704        }
705
706        max_depth
707    }
708}