Skip to main content

vellaveto_engine/
lint.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//! Policy linting and best-practices engine.
9//!
10//! Provides static analysis of policy sets to detect misconfigurations,
11//! overlapping rules, and security anti-patterns before policies are loaded
12//! into the evaluation engine.
13//!
14//! # Lint Rules
15//!
16//! | ID   | Severity | Description |
17//! |------|----------|-------------|
18//! | L001 | Error    | Empty policy ID |
19//! | L002 | Error    | Empty policy name |
20//! | L003 | Warning  | Wildcard-only policy (overly broad) |
21//! | L004 | Warning  | Allow without path or network rules (matches everything) |
22//! | L005 | Warning  | Overlapping path rules between policies |
23//! | L006 | Info     | Deny policy with unused path/network rules |
24//! | L007 | Warning  | Blocked path is prefix of allowed path (dead rule) |
25//! | L008 | Warning  | Empty allowed_domains with non-empty blocked_domains |
26//! | L009 | Error    | Duplicate policy IDs |
27//! | L010 | Warning  | Priority collision (non-deterministic ordering) |
28//! | L011 | Info     | Large policy set (>500 may impact latency) |
29//! | L012 | Warning  | Conditional with no conditions |
30
31use std::collections::{HashMap, HashSet};
32use vellaveto_types::{Policy, PolicyType};
33
34/// Maximum number of policies before emitting L011.
35const LARGE_POLICY_SET_THRESHOLD: usize = 500;
36
37/// Severity level for lint findings.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39pub enum LintSeverity {
40    Info,
41    Warning,
42    Error,
43}
44
45/// A single lint finding.
46#[derive(Debug, Clone)]
47pub struct LintFinding {
48    pub rule_id: String,
49    pub severity: LintSeverity,
50    pub policy_id: String,
51    pub message: String,
52    pub suggestion: Option<String>,
53}
54
55/// Result of linting a policy set.
56#[derive(Debug, Clone)]
57pub struct LintReport {
58    pub findings: Vec<LintFinding>,
59    pub policies_checked: usize,
60    pub error_count: usize,
61    pub warning_count: usize,
62    pub info_count: usize,
63}
64
65impl LintReport {
66    /// Returns true if no Error-level findings were emitted.
67    pub fn is_ok(&self) -> bool {
68        self.error_count == 0
69    }
70}
71
72/// Policy linting engine.
73///
74/// Performs static analysis of policy sets to detect misconfigurations,
75/// shadowed rules, and security anti-patterns.
76pub struct PolicyLinter {
77    // No state needed — pure analysis.
78}
79
80impl PolicyLinter {
81    /// Create a new policy linter.
82    pub fn new() -> Self {
83        Self {}
84    }
85
86    /// Lint a set of policies and return a report.
87    pub fn lint(&self, policies: &[Policy]) -> LintReport {
88        let mut findings = Vec::new();
89
90        // Per-policy rules
91        for policy in policies {
92            findings.extend(self.lint_single(policy));
93        }
94
95        // Cross-policy rules
96        self.check_duplicate_ids(policies, &mut findings);
97        self.check_priority_collisions(policies, &mut findings);
98        self.check_overlapping_paths(policies, &mut findings);
99        self.check_large_policy_set(policies, &mut findings);
100
101        let error_count = findings
102            .iter()
103            .filter(|f| f.severity == LintSeverity::Error)
104            .count();
105        let warning_count = findings
106            .iter()
107            .filter(|f| f.severity == LintSeverity::Warning)
108            .count();
109        let info_count = findings
110            .iter()
111            .filter(|f| f.severity == LintSeverity::Info)
112            .count();
113
114        LintReport {
115            findings,
116            policies_checked: policies.len(),
117            error_count,
118            warning_count,
119            info_count,
120        }
121    }
122
123    /// Lint a single policy (subset of rules that apply to one policy).
124    pub fn lint_single(&self, policy: &Policy) -> Vec<LintFinding> {
125        let mut findings = Vec::new();
126
127        self.check_empty_id(policy, &mut findings);
128        self.check_empty_name(policy, &mut findings);
129        self.check_wildcard_only(policy, &mut findings);
130        self.check_allow_without_rules(policy, &mut findings);
131        self.check_deny_unused_rules(policy, &mut findings);
132        self.check_blocked_prefix_of_allowed(policy, &mut findings);
133        self.check_empty_allowed_with_blocked_domains(policy, &mut findings);
134        self.check_conditional_no_conditions(policy, &mut findings);
135
136        findings
137    }
138
139    // ───────────────────────────────────────────────
140    // Single-policy rules
141    // ───────────────────────────────────────────────
142
143    /// L001: Policy ID must not be empty.
144    fn check_empty_id(&self, policy: &Policy, findings: &mut Vec<LintFinding>) {
145        if policy.id.trim().is_empty() {
146            findings.push(LintFinding {
147                rule_id: "L001".to_string(),
148                severity: LintSeverity::Error,
149                policy_id: policy.id.clone(),
150                message: "Policy ID is empty".to_string(),
151                suggestion: Some(
152                    "Set a unique policy ID in the format 'tool:function' or 'tool:*'".to_string(),
153                ),
154            });
155        }
156    }
157
158    /// L002: Policy name must not be empty.
159    fn check_empty_name(&self, policy: &Policy, findings: &mut Vec<LintFinding>) {
160        if policy.name.trim().is_empty() {
161            findings.push(LintFinding {
162                rule_id: "L002".to_string(),
163                severity: LintSeverity::Error,
164                policy_id: policy.id.clone(),
165                message: "Policy name is empty".to_string(),
166                suggestion: Some(
167                    "Set a descriptive name for the policy (e.g. 'Allow file reads')".to_string(),
168                ),
169            });
170        }
171    }
172
173    /// L003: Wildcard-only policy ID is overly broad.
174    fn check_wildcard_only(&self, policy: &Policy, findings: &mut Vec<LintFinding>) {
175        let trimmed = policy.id.trim();
176        if trimmed == "*" || trimmed == "*:*" {
177            findings.push(LintFinding {
178                rule_id: "L003".to_string(),
179                severity: LintSeverity::Warning,
180                policy_id: policy.id.clone(),
181                message: format!(
182                    "Policy '{}' uses a wildcard-only ID that matches all tools",
183                    policy.id
184                ),
185                suggestion: Some(
186                    "Use a more specific tool pattern (e.g. 'file_system:*') to limit scope"
187                        .to_string(),
188                ),
189            });
190        }
191    }
192
193    /// L004: Allow policy with no path_rules and no network_rules matches everything.
194    fn check_allow_without_rules(&self, policy: &Policy, findings: &mut Vec<LintFinding>) {
195        if !matches!(policy.policy_type, PolicyType::Allow) {
196            return;
197        }
198        if policy.path_rules.is_none() && policy.network_rules.is_none() {
199            findings.push(LintFinding {
200                rule_id: "L004".to_string(),
201                severity: LintSeverity::Warning,
202                policy_id: policy.id.clone(),
203                message: format!(
204                    "Allow policy '{}' has no path_rules or network_rules — matches all paths and domains",
205                    policy.id
206                ),
207                suggestion: Some(
208                    "Add path_rules or network_rules to restrict what this policy allows".to_string(),
209                ),
210            });
211        }
212    }
213
214    /// L006: Deny policy with path_rules or network_rules (those rules are unused).
215    fn check_deny_unused_rules(&self, policy: &Policy, findings: &mut Vec<LintFinding>) {
216        if !matches!(policy.policy_type, PolicyType::Deny) {
217            return;
218        }
219        let has_path_rules = policy
220            .path_rules
221            .as_ref()
222            .is_some_and(|pr| !pr.allowed.is_empty() || !pr.blocked.is_empty());
223        let has_network_rules = policy
224            .network_rules
225            .as_ref()
226            .is_some_and(|nr| !nr.allowed_domains.is_empty() || !nr.blocked_domains.is_empty());
227        if has_path_rules || has_network_rules {
228            findings.push(LintFinding {
229                rule_id: "L006".to_string(),
230                severity: LintSeverity::Info,
231                policy_id: policy.id.clone(),
232                message: format!(
233                    "Deny policy '{}' has path_rules or network_rules that will not be evaluated (Deny blocks unconditionally)",
234                    policy.id
235                ),
236                suggestion: Some(
237                    "Remove path_rules/network_rules from Deny policies, or change policy_type to Conditional".to_string(),
238                ),
239            });
240        }
241    }
242
243    /// L007: In PathRules, a blocked pattern is a prefix of an allowed pattern (allowed is dead).
244    fn check_blocked_prefix_of_allowed(&self, policy: &Policy, findings: &mut Vec<LintFinding>) {
245        let path_rules = match &policy.path_rules {
246            Some(pr) => pr,
247            None => return,
248        };
249        for blocked in &path_rules.blocked {
250            for allowed in &path_rules.allowed {
251                if is_prefix_pattern(blocked, allowed) {
252                    findings.push(LintFinding {
253                        rule_id: "L007".to_string(),
254                        severity: LintSeverity::Warning,
255                        policy_id: policy.id.clone(),
256                        message: format!(
257                            "Blocked pattern '{blocked}' is a prefix of allowed pattern '{allowed}' — the allowed pattern is unreachable"
258                        ),
259                        suggestion: Some(
260                            "Remove the unreachable allowed pattern or restructure the rules".to_string(),
261                        ),
262                    });
263                }
264            }
265        }
266    }
267
268    /// L008: Empty allowed_domains with non-empty blocked_domains.
269    fn check_empty_allowed_with_blocked_domains(
270        &self,
271        policy: &Policy,
272        findings: &mut Vec<LintFinding>,
273    ) {
274        let network_rules = match &policy.network_rules {
275            Some(nr) => nr,
276            None => return,
277        };
278        if network_rules.allowed_domains.is_empty() && !network_rules.blocked_domains.is_empty() {
279            findings.push(LintFinding {
280                rule_id: "L008".to_string(),
281                severity: LintSeverity::Warning,
282                policy_id: policy.id.clone(),
283                message: format!(
284                    "Policy '{}' has blocked_domains but no allowed_domains — blocked_domains has no effect when the allowlist is empty",
285                    policy.id
286                ),
287                suggestion: Some(
288                    "Add allowed_domains to define which domains are permitted, or remove blocked_domains".to_string(),
289                ),
290            });
291        }
292    }
293
294    /// L012: Conditional policy type with empty or missing conditions.
295    fn check_conditional_no_conditions(&self, policy: &Policy, findings: &mut Vec<LintFinding>) {
296        let conditions = match &policy.policy_type {
297            PolicyType::Conditional { conditions } => conditions,
298            _ => return,
299        };
300        let is_empty = conditions.is_null()
301            || (conditions.is_object() && conditions.as_object().is_none_or(|m| m.is_empty()))
302            || (conditions.is_array() && conditions.as_array().is_none_or(|a| a.is_empty()));
303        if is_empty {
304            findings.push(LintFinding {
305                rule_id: "L012".to_string(),
306                severity: LintSeverity::Warning,
307                policy_id: policy.id.clone(),
308                message: format!(
309                    "Conditional policy '{}' has empty or null conditions — it will not match any context",
310                    policy.id
311                ),
312                suggestion: Some(
313                    "Add conditions (e.g. parameter_constraints, time_window) or change policy_type to Allow/Deny".to_string(),
314                ),
315            });
316        }
317    }
318
319    // ───────────────────────────────────────────────
320    // Cross-policy rules
321    // ───────────────────────────────────────────────
322
323    /// L009: Duplicate policy IDs.
324    fn check_duplicate_ids(&self, policies: &[Policy], findings: &mut Vec<LintFinding>) {
325        let mut seen: HashMap<&str, usize> = HashMap::new();
326        for (i, policy) in policies.iter().enumerate() {
327            if let Some(&first_idx) = seen.get(policy.id.as_str()) {
328                findings.push(LintFinding {
329                    rule_id: "L009".to_string(),
330                    severity: LintSeverity::Error,
331                    policy_id: policy.id.clone(),
332                    message: format!(
333                        "Duplicate policy ID '{}' (first seen at index {}, duplicate at index {})",
334                        policy.id, first_idx, i
335                    ),
336                    suggestion: Some("Each policy must have a unique ID".to_string()),
337                });
338            } else {
339                seen.insert(&policy.id, i);
340            }
341        }
342    }
343
344    /// L010: Priority collision — two or more policies with the same priority.
345    fn check_priority_collisions(&self, policies: &[Policy], findings: &mut Vec<LintFinding>) {
346        let mut priority_groups: HashMap<i32, Vec<&str>> = HashMap::new();
347        for policy in policies {
348            priority_groups
349                .entry(policy.priority)
350                .or_default()
351                .push(&policy.id);
352        }
353        for (priority, ids) in &priority_groups {
354            if ids.len() > 1 {
355                // Report once per collision group, referencing the first policy ID.
356                findings.push(LintFinding {
357                    rule_id: "L010".to_string(),
358                    severity: LintSeverity::Warning,
359                    policy_id: ids[0].to_string(),
360                    message: format!(
361                        "Priority {} is shared by {} policies: {} — evaluation order may be non-deterministic",
362                        priority,
363                        ids.len(),
364                        ids.join(", "),
365                    ),
366                    suggestion: Some(
367                        "Assign unique priorities to each policy for deterministic evaluation".to_string(),
368                    ),
369                });
370            }
371        }
372    }
373
374    /// L005: Overlapping path rules between policies on the same tool pattern.
375    ///
376    /// Detects cases where two Allow policies on the same (or overlapping) tool
377    /// pattern have path_rules whose allowed patterns overlap, which may cause
378    /// one policy to shadow the other.
379    fn check_overlapping_paths(&self, policies: &[Policy], findings: &mut Vec<LintFinding>) {
380        // Group policies by their tool pattern (the part before ':')
381        let mut tool_groups: HashMap<&str, Vec<&Policy>> = HashMap::new();
382        for policy in policies {
383            let tool_part = policy.id.split(':').next().unwrap_or(&policy.id);
384            tool_groups.entry(tool_part).or_default().push(policy);
385        }
386
387        for group in tool_groups.values() {
388            if group.len() < 2 {
389                continue;
390            }
391            // Check all pairs
392            let mut reported: HashSet<(usize, usize)> = HashSet::new();
393            for (i, p1) in group.iter().enumerate() {
394                let pr1 = match &p1.path_rules {
395                    Some(pr) if !pr.allowed.is_empty() => pr,
396                    _ => continue,
397                };
398                for (j, p2) in group.iter().enumerate().skip(i + 1) {
399                    if reported.contains(&(i, j)) {
400                        continue;
401                    }
402                    let pr2 = match &p2.path_rules {
403                        Some(pr) if !pr.allowed.is_empty() => pr,
404                        _ => continue,
405                    };
406                    // Check if any allowed pattern from p1 overlaps with p2
407                    for a1 in &pr1.allowed {
408                        for a2 in &pr2.allowed {
409                            if patterns_overlap(a1, a2) {
410                                reported.insert((i, j));
411                                findings.push(LintFinding {
412                                    rule_id: "L005".to_string(),
413                                    severity: LintSeverity::Warning,
414                                    policy_id: p1.id.clone(),
415                                    message: format!(
416                                        "Policy '{}' path '{}' overlaps with policy '{}' path '{}' — higher-priority policy shadows the other",
417                                        p1.id, a1, p2.id, a2
418                                    ),
419                                    suggestion: Some(
420                                        "Ensure overlapping policies have distinct priorities or non-overlapping paths".to_string(),
421                                    ),
422                                });
423                            }
424                        }
425                    }
426                }
427            }
428        }
429    }
430
431    /// L011: Large policy set (>500 policies).
432    fn check_large_policy_set(&self, policies: &[Policy], findings: &mut Vec<LintFinding>) {
433        if policies.len() > LARGE_POLICY_SET_THRESHOLD {
434            findings.push(LintFinding {
435                rule_id: "L011".to_string(),
436                severity: LintSeverity::Info,
437                policy_id: String::new(),
438                message: format!(
439                    "Policy set contains {} policies (threshold: {}) — evaluation latency may be impacted",
440                    policies.len(),
441                    LARGE_POLICY_SET_THRESHOLD,
442                ),
443                suggestion: Some(
444                    "Consider consolidating policies or using more specific tool patterns for faster matching".to_string(),
445                ),
446            });
447        }
448    }
449}
450
451impl Default for PolicyLinter {
452    fn default() -> Self {
453        Self::new()
454    }
455}
456
457// ───────────────────────────────────────────────────
458// Helper functions
459// ───────────────────────────────────────────────────
460
461/// Check if `prefix` is a glob/path prefix of `candidate`.
462///
463/// A pattern is considered a prefix if:
464/// - `candidate` starts with `prefix` (exact prefix match), or
465/// - `prefix` ends with `/**` and the base of `prefix` is a prefix of `candidate`, or
466/// - `prefix` ends with `/*` and the base of `prefix` is a directory prefix of `candidate`
467fn is_prefix_pattern(prefix: &str, candidate: &str) -> bool {
468    // Exact prefix (e.g. "/home" blocks "/home/user")
469    if candidate.starts_with(prefix) && candidate.len() > prefix.len() {
470        return true;
471    }
472
473    // /foo/** blocks /foo/bar/baz
474    if let Some(base) = prefix.strip_suffix("/**") {
475        if candidate.starts_with(base) {
476            return true;
477        }
478    }
479
480    // /foo/* blocks /foo/bar
481    if let Some(base) = prefix.strip_suffix("/*") {
482        if candidate.starts_with(base) {
483            return true;
484        }
485    }
486
487    false
488}
489
490/// Check if two glob patterns potentially overlap in the paths they match.
491///
492/// This is a conservative approximation — it may report false positives
493/// but should not miss true overlaps. Two patterns overlap if:
494/// - They are identical
495/// - One is a prefix of the other
496/// - Both match the same directory tree (e.g. `/home/*` and `/home/user/*`)
497fn patterns_overlap(a: &str, b: &str) -> bool {
498    if a == b {
499        return true;
500    }
501    // One is a prefix of the other
502    if is_prefix_pattern(a, b) || is_prefix_pattern(b, a) {
503        return true;
504    }
505    // Both patterns start with the same concrete prefix up to the first wildcard
506    let a_concrete = concrete_prefix(a);
507    let b_concrete = concrete_prefix(b);
508    if !a_concrete.is_empty() && !b_concrete.is_empty() {
509        // If one concrete prefix starts with the other, they may overlap
510        if a_concrete.starts_with(b_concrete) || b_concrete.starts_with(a_concrete) {
511            return true;
512        }
513    }
514    false
515}
516
517/// Extract the concrete (non-wildcard) prefix of a glob pattern.
518///
519/// Returns the portion of the pattern before the first `*`, `?`, or `[`.
520fn concrete_prefix(pattern: &str) -> &str {
521    let end = pattern.find(['*', '?', '[']).unwrap_or(pattern.len());
522    &pattern[..end]
523}
524
525// ═══════════════════════════════════════════════════
526// Tests
527// ═══════════════════════════════════════════════════
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use serde_json::json;
533    use vellaveto_types::{NetworkRules, PathRules};
534
535    /// Helper to create an Allow policy with optional path/network rules.
536    fn make_allow_policy(
537        id: &str,
538        name: &str,
539        priority: i32,
540        path_rules: Option<PathRules>,
541        network_rules: Option<NetworkRules>,
542    ) -> Policy {
543        Policy {
544            id: id.to_string(),
545            name: name.to_string(),
546            policy_type: PolicyType::Allow,
547            priority,
548            path_rules,
549            network_rules,
550        }
551    }
552
553    /// Helper to create a Deny policy.
554    fn make_deny_policy(id: &str, name: &str, priority: i32) -> Policy {
555        Policy {
556            id: id.to_string(),
557            name: name.to_string(),
558            policy_type: PolicyType::Deny,
559            priority,
560            path_rules: None,
561            network_rules: None,
562        }
563    }
564
565    /// Helper to create a Conditional policy.
566    fn make_conditional_policy(
567        id: &str,
568        name: &str,
569        priority: i32,
570        conditions: serde_json::Value,
571    ) -> Policy {
572        Policy {
573            id: id.to_string(),
574            name: name.to_string(),
575            policy_type: PolicyType::Conditional { conditions },
576            priority,
577            path_rules: None,
578            network_rules: None,
579        }
580    }
581
582    // ───────────────────────────────────────────────
583    // L001: Empty policy ID
584    // ───────────────────────────────────────────────
585
586    #[test]
587    fn test_lint_l001_empty_id() {
588        let linter = PolicyLinter::new();
589        let policy = make_allow_policy("", "Test", 10, None, None);
590        let findings = linter.lint_single(&policy);
591        assert!(findings.iter().any(|f| f.rule_id == "L001"));
592    }
593
594    #[test]
595    fn test_lint_l001_whitespace_only_id() {
596        let linter = PolicyLinter::new();
597        let policy = make_allow_policy("   ", "Test", 10, None, None);
598        let findings = linter.lint_single(&policy);
599        assert!(findings.iter().any(|f| f.rule_id == "L001"));
600    }
601
602    #[test]
603    fn test_lint_l001_valid_id_no_finding() {
604        let linter = PolicyLinter::new();
605        let policy = make_allow_policy("file:read", "Test", 10, None, None);
606        let findings = linter.lint_single(&policy);
607        assert!(!findings.iter().any(|f| f.rule_id == "L001"));
608    }
609
610    // ───────────────────────────────────────────────
611    // L002: Empty policy name
612    // ───────────────────────────────────────────────
613
614    #[test]
615    fn test_lint_l002_empty_name() {
616        let linter = PolicyLinter::new();
617        let policy = make_allow_policy("test:read", "", 10, None, None);
618        let findings = linter.lint_single(&policy);
619        assert!(findings.iter().any(|f| f.rule_id == "L002"));
620    }
621
622    #[test]
623    fn test_lint_l002_whitespace_only_name() {
624        let linter = PolicyLinter::new();
625        let policy = make_allow_policy("test:read", "  \t ", 10, None, None);
626        let findings = linter.lint_single(&policy);
627        assert!(findings.iter().any(|f| f.rule_id == "L002"));
628    }
629
630    // ───────────────────────────────────────────────
631    // L003: Wildcard-only policy
632    // ───────────────────────────────────────────────
633
634    #[test]
635    fn test_lint_l003_star_only() {
636        let linter = PolicyLinter::new();
637        let policy = make_allow_policy("*", "All tools", 10, None, None);
638        let findings = linter.lint_single(&policy);
639        assert!(findings.iter().any(|f| f.rule_id == "L003"));
640    }
641
642    #[test]
643    fn test_lint_l003_star_colon_star() {
644        let linter = PolicyLinter::new();
645        let policy = make_allow_policy("*:*", "All tools", 10, None, None);
646        let findings = linter.lint_single(&policy);
647        assert!(findings.iter().any(|f| f.rule_id == "L003"));
648    }
649
650    #[test]
651    fn test_lint_l003_partial_wildcard_no_finding() {
652        let linter = PolicyLinter::new();
653        let policy = make_allow_policy("file:*", "File tools", 10, None, None);
654        let findings = linter.lint_single(&policy);
655        assert!(!findings.iter().any(|f| f.rule_id == "L003"));
656    }
657
658    // ───────────────────────────────────────────────
659    // L004: Allow without path or network rules
660    // ───────────────────────────────────────────────
661
662    #[test]
663    fn test_lint_l004_allow_no_rules() {
664        let linter = PolicyLinter::new();
665        let policy = make_allow_policy("file:read", "Allow reads", 10, None, None);
666        let findings = linter.lint_single(&policy);
667        assert!(findings.iter().any(|f| f.rule_id == "L004"));
668    }
669
670    #[test]
671    fn test_lint_l004_allow_with_path_rules_no_finding() {
672        let linter = PolicyLinter::new();
673        let pr = PathRules {
674            allowed: vec!["/home/**".to_string()],
675            blocked: vec![],
676        };
677        let policy = make_allow_policy("file:read", "Allow reads", 10, Some(pr), None);
678        let findings = linter.lint_single(&policy);
679        assert!(!findings.iter().any(|f| f.rule_id == "L004"));
680    }
681
682    #[test]
683    fn test_lint_l004_deny_no_rules_no_finding() {
684        let linter = PolicyLinter::new();
685        let policy = make_deny_policy("bash:*", "Block bash", 100);
686        let findings = linter.lint_single(&policy);
687        assert!(!findings.iter().any(|f| f.rule_id == "L004"));
688    }
689
690    // ───────────────────────────────────────────────
691    // L005: Overlapping path rules
692    // ───────────────────────────────────────────────
693
694    #[test]
695    fn test_lint_l005_overlapping_paths() {
696        let linter = PolicyLinter::new();
697        let p1 = make_allow_policy(
698            "file:read",
699            "P1",
700            10,
701            Some(PathRules {
702                allowed: vec!["/home/**".to_string()],
703                blocked: vec![],
704            }),
705            None,
706        );
707        let p2 = make_allow_policy(
708            "file:write",
709            "P2",
710            20,
711            Some(PathRules {
712                allowed: vec!["/home/user/**".to_string()],
713                blocked: vec![],
714            }),
715            None,
716        );
717        let report = linter.lint(&[p1, p2]);
718        assert!(report.findings.iter().any(|f| f.rule_id == "L005"));
719    }
720
721    #[test]
722    fn test_lint_l005_non_overlapping_paths_no_finding() {
723        let linter = PolicyLinter::new();
724        let p1 = make_allow_policy(
725            "file:read",
726            "P1",
727            10,
728            Some(PathRules {
729                allowed: vec!["/home/**".to_string()],
730                blocked: vec![],
731            }),
732            None,
733        );
734        let p2 = make_allow_policy(
735            "network:fetch",
736            "P2",
737            20,
738            Some(PathRules {
739                allowed: vec!["/var/log/**".to_string()],
740                blocked: vec![],
741            }),
742            None,
743        );
744        let report = linter.lint(&[p1, p2]);
745        assert!(!report.findings.iter().any(|f| f.rule_id == "L005"));
746    }
747
748    // ───────────────────────────────────────────────
749    // L006: Deny with unused rules
750    // ───────────────────────────────────────────────
751
752    #[test]
753    fn test_lint_l006_deny_with_path_rules() {
754        let linter = PolicyLinter::new();
755        let policy = Policy {
756            id: "bash:*".to_string(),
757            name: "Block bash".to_string(),
758            policy_type: PolicyType::Deny,
759            priority: 100,
760            path_rules: Some(PathRules {
761                allowed: vec!["/tmp/**".to_string()],
762                blocked: vec![],
763            }),
764            network_rules: None,
765        };
766        let findings = linter.lint_single(&policy);
767        assert!(findings.iter().any(|f| f.rule_id == "L006"));
768    }
769
770    #[test]
771    fn test_lint_l006_deny_without_rules_no_finding() {
772        let linter = PolicyLinter::new();
773        let policy = make_deny_policy("bash:*", "Block bash", 100);
774        let findings = linter.lint_single(&policy);
775        assert!(!findings.iter().any(|f| f.rule_id == "L006"));
776    }
777
778    #[test]
779    fn test_lint_l006_deny_with_empty_rules_no_finding() {
780        let linter = PolicyLinter::new();
781        let policy = Policy {
782            id: "bash:*".to_string(),
783            name: "Block bash".to_string(),
784            policy_type: PolicyType::Deny,
785            priority: 100,
786            path_rules: Some(PathRules {
787                allowed: vec![],
788                blocked: vec![],
789            }),
790            network_rules: None,
791        };
792        let findings = linter.lint_single(&policy);
793        assert!(!findings.iter().any(|f| f.rule_id == "L006"));
794    }
795
796    // ───────────────────────────────────────────────
797    // L007: Blocked path prefix of allowed path
798    // ───────────────────────────────────────────────
799
800    #[test]
801    fn test_lint_l007_blocked_prefix_of_allowed() {
802        let linter = PolicyLinter::new();
803        let policy = make_allow_policy(
804            "file:read",
805            "Read files",
806            10,
807            Some(PathRules {
808                allowed: vec!["/etc/config/**".to_string()],
809                blocked: vec!["/etc/**".to_string()],
810            }),
811            None,
812        );
813        let findings = linter.lint_single(&policy);
814        assert!(findings.iter().any(|f| f.rule_id == "L007"));
815    }
816
817    #[test]
818    fn test_lint_l007_no_prefix_no_finding() {
819        let linter = PolicyLinter::new();
820        let policy = make_allow_policy(
821            "file:read",
822            "Read files",
823            10,
824            Some(PathRules {
825                allowed: vec!["/home/**".to_string()],
826                blocked: vec!["/etc/**".to_string()],
827            }),
828            None,
829        );
830        let findings = linter.lint_single(&policy);
831        assert!(!findings.iter().any(|f| f.rule_id == "L007"));
832    }
833
834    // ───────────────────────────────────────────────
835    // L008: Empty allowed_domains with blocked_domains
836    // ───────────────────────────────────────────────
837
838    #[test]
839    fn test_lint_l008_empty_allowed_with_blocked() {
840        let linter = PolicyLinter::new();
841        let policy = make_allow_policy(
842            "http:fetch",
843            "Fetch",
844            10,
845            None,
846            Some(NetworkRules {
847                allowed_domains: vec![],
848                blocked_domains: vec!["evil.com".to_string()],
849                ip_rules: None,
850            }),
851        );
852        let findings = linter.lint_single(&policy);
853        assert!(findings.iter().any(|f| f.rule_id == "L008"));
854    }
855
856    #[test]
857    fn test_lint_l008_both_populated_no_finding() {
858        let linter = PolicyLinter::new();
859        let policy = make_allow_policy(
860            "http:fetch",
861            "Fetch",
862            10,
863            None,
864            Some(NetworkRules {
865                allowed_domains: vec!["example.com".to_string()],
866                blocked_domains: vec!["evil.com".to_string()],
867                ip_rules: None,
868            }),
869        );
870        let findings = linter.lint_single(&policy);
871        assert!(!findings.iter().any(|f| f.rule_id == "L008"));
872    }
873
874    // ───────────────────────────────────────────────
875    // L009: Duplicate policy IDs
876    // ───────────────────────────────────────────────
877
878    #[test]
879    fn test_lint_l009_duplicate_ids() {
880        let linter = PolicyLinter::new();
881        let p1 = make_allow_policy("file:read", "P1", 10, None, None);
882        let p2 = make_deny_policy("file:read", "P2", 20);
883        let report = linter.lint(&[p1, p2]);
884        assert!(report.findings.iter().any(|f| f.rule_id == "L009"));
885        assert!(report.error_count >= 1);
886    }
887
888    #[test]
889    fn test_lint_l009_unique_ids_no_finding() {
890        let linter = PolicyLinter::new();
891        let p1 = make_allow_policy("file:read", "P1", 10, None, None);
892        let p2 = make_deny_policy("file:write", "P2", 20);
893        let report = linter.lint(&[p1, p2]);
894        assert!(!report.findings.iter().any(|f| f.rule_id == "L009"));
895    }
896
897    // ───────────────────────────────────────────────
898    // L010: Priority collision
899    // ───────────────────────────────────────────────
900
901    #[test]
902    fn test_lint_l010_priority_collision() {
903        let linter = PolicyLinter::new();
904        let p1 = make_allow_policy("file:read", "P1", 50, None, None);
905        let p2 = make_deny_policy("bash:exec", "P2", 50);
906        let report = linter.lint(&[p1, p2]);
907        assert!(report.findings.iter().any(|f| f.rule_id == "L010"));
908    }
909
910    #[test]
911    fn test_lint_l010_unique_priorities_no_finding() {
912        let linter = PolicyLinter::new();
913        let p1 = make_allow_policy("file:read", "P1", 10, None, None);
914        let p2 = make_deny_policy("bash:exec", "P2", 20);
915        let report = linter.lint(&[p1, p2]);
916        assert!(!report.findings.iter().any(|f| f.rule_id == "L010"));
917    }
918
919    // ───────────────────────────────────────────────
920    // L011: Large policy set
921    // ───────────────────────────────────────────────
922
923    #[test]
924    fn test_lint_l011_large_policy_set() {
925        let linter = PolicyLinter::new();
926        let policies: Vec<Policy> = (0..501)
927            .map(|i| make_deny_policy(&format!("tool{i}:fn"), &format!("Policy {i}"), i))
928            .collect();
929        let report = linter.lint(&policies);
930        assert!(report.findings.iter().any(|f| f.rule_id == "L011"));
931    }
932
933    #[test]
934    fn test_lint_l011_small_policy_set_no_finding() {
935        let linter = PolicyLinter::new();
936        let policies: Vec<Policy> = (0..10)
937            .map(|i| make_deny_policy(&format!("tool{i}:fn"), &format!("Policy {i}"), i))
938            .collect();
939        let report = linter.lint(&policies);
940        assert!(!report.findings.iter().any(|f| f.rule_id == "L011"));
941    }
942
943    // ───────────────────────────────────────────────
944    // L012: Conditional with no conditions
945    // ───────────────────────────────────────────────
946
947    #[test]
948    fn test_lint_l012_conditional_null_conditions() {
949        let linter = PolicyLinter::new();
950        let policy = make_conditional_policy("test:fn", "Test", 10, json!(null));
951        let findings = linter.lint_single(&policy);
952        assert!(findings.iter().any(|f| f.rule_id == "L012"));
953    }
954
955    #[test]
956    fn test_lint_l012_conditional_empty_object() {
957        let linter = PolicyLinter::new();
958        let policy = make_conditional_policy("test:fn", "Test", 10, json!({}));
959        let findings = linter.lint_single(&policy);
960        assert!(findings.iter().any(|f| f.rule_id == "L012"));
961    }
962
963    #[test]
964    fn test_lint_l012_conditional_empty_array() {
965        let linter = PolicyLinter::new();
966        let policy = make_conditional_policy("test:fn", "Test", 10, json!([]));
967        let findings = linter.lint_single(&policy);
968        assert!(findings.iter().any(|f| f.rule_id == "L012"));
969    }
970
971    #[test]
972    fn test_lint_l012_conditional_with_conditions_no_finding() {
973        let linter = PolicyLinter::new();
974        let policy = make_conditional_policy(
975            "test:fn",
976            "Test",
977            10,
978            json!({ "parameter_constraints": [{ "param": "mode", "op": "eq", "value": "safe" }] }),
979        );
980        let findings = linter.lint_single(&policy);
981        assert!(!findings.iter().any(|f| f.rule_id == "L012"));
982    }
983
984    // ───────────────────────────────────────────────
985    // Report summary counts
986    // ───────────────────────────────────────────────
987
988    #[test]
989    fn test_lint_report_counts() {
990        let linter = PolicyLinter::new();
991        // L001 (Error) + L003 (Warning) + L004 (Warning) from one policy
992        let policy = make_allow_policy("*", "", 10, None, None);
993        let report = linter.lint(&[policy]);
994        // L001 (empty name -> Error), L002 actually since name is empty
995        // L003 (wildcard -> Warning), L004 (allow no rules -> Warning)
996        assert!(report.error_count >= 1); // L002
997        assert!(report.warning_count >= 1); // L003, L004
998        assert_eq!(report.policies_checked, 1, "should check exactly 1 policy");
999    }
1000
1001    #[test]
1002    fn test_lint_report_is_ok_with_errors() {
1003        let linter = PolicyLinter::new();
1004        let policy = make_allow_policy("", "Test", 10, None, None);
1005        let report = linter.lint(&[policy]);
1006        assert!(!report.is_ok(), "report with errors should not be ok");
1007    }
1008
1009    #[test]
1010    fn test_lint_report_is_ok_without_errors() {
1011        let linter = PolicyLinter::new();
1012        let policy = make_deny_policy("bash:exec", "Block bash", 100);
1013        let report = linter.lint(&[policy]);
1014        assert!(report.is_ok(), "report without errors should be ok");
1015    }
1016
1017    #[test]
1018    fn test_lint_empty_policy_set() {
1019        let linter = PolicyLinter::new();
1020        let report = linter.lint(&[]);
1021        assert_eq!(report.policies_checked, 0);
1022        assert_eq!(report.error_count, 0);
1023        assert_eq!(report.warning_count, 0);
1024        assert_eq!(report.info_count, 0);
1025        assert!(report.findings.is_empty());
1026    }
1027
1028    #[test]
1029    fn test_lint_default_constructor() {
1030        let linter = PolicyLinter::default();
1031        let report = linter.lint(&[]);
1032        assert!(report.is_ok());
1033    }
1034
1035    // ───────────────────────────────────────────────
1036    // Helper function tests
1037    // ───────────────────────────────────────────────
1038
1039    #[test]
1040    fn test_is_prefix_pattern_exact() {
1041        assert!(is_prefix_pattern("/home", "/home/user"));
1042        assert!(!is_prefix_pattern("/home/user", "/home"));
1043    }
1044
1045    #[test]
1046    fn test_is_prefix_pattern_glob_double_star() {
1047        assert!(is_prefix_pattern("/etc/**", "/etc/config/file.toml"));
1048    }
1049
1050    #[test]
1051    fn test_is_prefix_pattern_glob_single_star() {
1052        assert!(is_prefix_pattern("/var/*", "/var/log/syslog"));
1053    }
1054
1055    #[test]
1056    fn test_patterns_overlap_identical() {
1057        assert!(patterns_overlap("/home/**", "/home/**"));
1058    }
1059
1060    #[test]
1061    fn test_patterns_overlap_prefix() {
1062        assert!(patterns_overlap("/home/**", "/home/user/**"));
1063    }
1064
1065    #[test]
1066    fn test_patterns_overlap_disjoint() {
1067        assert!(!patterns_overlap("/home/**", "/var/**"));
1068    }
1069
1070    #[test]
1071    fn test_concrete_prefix_extraction() {
1072        assert_eq!(concrete_prefix("/home/user/*"), "/home/user/");
1073        assert_eq!(concrete_prefix("**"), "");
1074        assert_eq!(concrete_prefix("/exact/path"), "/exact/path");
1075    }
1076
1077    // ───────────────────────────────────────────────
1078    // Edge cases
1079    // ───────────────────────────────────────────────
1080
1081    #[test]
1082    fn test_lint_l009_triple_duplicate() {
1083        let linter = PolicyLinter::new();
1084        let p1 = make_deny_policy("dup:id", "P1", 10);
1085        let p2 = make_deny_policy("dup:id", "P2", 20);
1086        let p3 = make_deny_policy("dup:id", "P3", 30);
1087        let report = linter.lint(&[p1, p2, p3]);
1088        let dup_findings: Vec<_> = report
1089            .findings
1090            .iter()
1091            .filter(|f| f.rule_id == "L009")
1092            .collect();
1093        assert_eq!(
1094            dup_findings.len(),
1095            2,
1096            "should report 2 duplicates for 3 identical IDs"
1097        );
1098    }
1099
1100    #[test]
1101    fn test_lint_l010_three_way_collision() {
1102        let linter = PolicyLinter::new();
1103        let p1 = make_deny_policy("a:fn", "A", 50);
1104        let p2 = make_deny_policy("b:fn", "B", 50);
1105        let p3 = make_deny_policy("c:fn", "C", 50);
1106        let report = linter.lint(&[p1, p2, p3]);
1107        let collision_findings: Vec<_> = report
1108            .findings
1109            .iter()
1110            .filter(|f| f.rule_id == "L010")
1111            .collect();
1112        assert_eq!(
1113            collision_findings.len(),
1114            1,
1115            "should report one collision group"
1116        );
1117        assert!(
1118            collision_findings[0].message.contains("3 policies"),
1119            "message should mention 3 policies"
1120        );
1121    }
1122
1123    #[test]
1124    fn test_lint_finding_has_suggestion() {
1125        let linter = PolicyLinter::new();
1126        let policy = make_allow_policy("", "Empty ID", 10, None, None);
1127        let findings = linter.lint_single(&policy);
1128        let l001 = findings.iter().find(|f| f.rule_id == "L001");
1129        assert!(l001.is_some());
1130        assert!(l001.is_some_and(|f| f.suggestion.is_some()));
1131    }
1132}