1use std::collections::{HashMap, HashSet};
32use vellaveto_types::{Policy, PolicyType};
33
34const LARGE_POLICY_SET_THRESHOLD: usize = 500;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39pub enum LintSeverity {
40 Info,
41 Warning,
42 Error,
43}
44
45#[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#[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 pub fn is_ok(&self) -> bool {
68 self.error_count == 0
69 }
70}
71
72pub struct PolicyLinter {
77 }
79
80impl PolicyLinter {
81 pub fn new() -> Self {
83 Self {}
84 }
85
86 pub fn lint(&self, policies: &[Policy]) -> LintReport {
88 let mut findings = Vec::new();
89
90 for policy in policies {
92 findings.extend(self.lint_single(policy));
93 }
94
95 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 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 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 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 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 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 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 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 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 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 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 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 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 fn check_overlapping_paths(&self, policies: &[Policy], findings: &mut Vec<LintFinding>) {
380 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 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 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 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
457fn is_prefix_pattern(prefix: &str, candidate: &str) -> bool {
468 if candidate.starts_with(prefix) && candidate.len() > prefix.len() {
470 return true;
471 }
472
473 if let Some(base) = prefix.strip_suffix("/**") {
475 if candidate.starts_with(base) {
476 return true;
477 }
478 }
479
480 if let Some(base) = prefix.strip_suffix("/*") {
482 if candidate.starts_with(base) {
483 return true;
484 }
485 }
486
487 false
488}
489
490fn patterns_overlap(a: &str, b: &str) -> bool {
498 if a == b {
499 return true;
500 }
501 if is_prefix_pattern(a, b) || is_prefix_pattern(b, a) {
503 return true;
504 }
505 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 a_concrete.starts_with(b_concrete) || b_concrete.starts_with(a_concrete) {
511 return true;
512 }
513 }
514 false
515}
516
517fn concrete_prefix(pattern: &str) -> &str {
521 let end = pattern.find(['*', '?', '[']).unwrap_or(pattern.len());
522 &pattern[..end]
523}
524
525#[cfg(test)]
530mod tests {
531 use super::*;
532 use serde_json::json;
533 use vellaveto_types::{NetworkRules, PathRules};
534
535 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
989 fn test_lint_report_counts() {
990 let linter = PolicyLinter::new();
991 let policy = make_allow_policy("*", "", 10, None, None);
993 let report = linter.lint(&[policy]);
994 assert!(report.error_count >= 1); assert!(report.warning_count >= 1); 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 #[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 #[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}