use crate::lint::{LintIssue, LintSeverity};
use crate::rules::{Rule, RuleEngine};
use std::collections::{HashMap, HashSet};
pub fn validate(rules: &[Rule]) -> Vec<LintIssue> {
let mut issues = Vec::new();
validate_blank_ids(rules, &mut issues);
validate_unique_ids(rules, &mut issues);
validate_base_references_exist(rules, &mut issues);
validate_no_base_cycles(rules, &mut issues);
if !issues.is_empty() {
return issues;
}
if let Err(e) = RuleEngine::compile(rules) {
issues.push(LintIssue {
severity: LintSeverity::Error,
message: format!("policy rule failed validation: {e}"),
});
}
issues
}
fn validate_blank_ids(rules: &[Rule], issues: &mut Vec<LintIssue>) {
for (i, rule) in rules.iter().enumerate() {
if rule.id.trim().is_empty() {
issues.push(LintIssue {
severity: LintSeverity::Error,
message: format!("policy rule at index {i} has a blank or whitespace-only id"),
});
}
}
}
fn validate_unique_ids(rules: &[Rule], issues: &mut Vec<LintIssue>) {
let mut indexes_by_id: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, rule) in rules.iter().enumerate() {
indexes_by_id.entry(rule.id.as_str()).or_default().push(i);
}
let mut dupes: Vec<(&str, &Vec<usize>)> = indexes_by_id
.iter()
.filter(|(_, idxs)| idxs.len() > 1)
.map(|(&id, idxs)| (id, idxs))
.collect();
dupes.sort_unstable_by_key(|(id, _)| *id);
for (id, idxs) in dupes {
let positions = idxs
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", ");
issues.push(LintIssue {
severity: LintSeverity::Error,
message: format!(
"policy rule id '{id}' is declared {} times (at indexes {positions}); \
rule ids must be unique because `base` inheritance and violation \
reports both key on id",
idxs.len()
),
});
}
}
fn validate_base_references_exist(rules: &[Rule], issues: &mut Vec<LintIssue>) {
let ids: HashSet<&str> = rules.iter().map(|r| r.id.as_str()).collect();
for rule in rules {
if let Some(base_id) = &rule.base {
if !ids.contains(base_id.as_str()) {
issues.push(LintIssue {
severity: LintSeverity::Error,
message: format!(
"policy rule '{}' has base '{base_id}', which does not match any rule id",
rule.id
),
});
}
}
}
}
fn validate_no_base_cycles(rules: &[Rule], issues: &mut Vec<LintIssue>) {
let by_id: HashMap<&str, &Rule> = rules.iter().map(|r| (r.id.as_str(), r)).collect();
let mut already_reported: HashSet<&str> = HashSet::new();
for rule in rules {
if already_reported.contains(rule.id.as_str()) {
continue;
}
if let Some(cycle) = find_base_cycle(rule, &by_id) {
for id in &cycle {
already_reported.insert(id);
}
issues.push(LintIssue {
severity: LintSeverity::Error,
message: format!(
"policy rule base chain forms a cycle: {}",
cycle.join(" -> ")
),
});
}
}
}
fn find_base_cycle<'a>(rule: &'a Rule, by_id: &HashMap<&'a str, &'a Rule>) -> Option<Vec<&'a str>> {
let mut path = vec![rule.id.as_str()];
let mut current = rule;
while let Some(base_id) = current.base.as_deref() {
if path.contains(&base_id) {
path.push(base_id);
return Some(path);
}
let base_rule = by_id.get(base_id)?;
path.push(base_id);
current = base_rule;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(id: &str, severity: &str, expression: &str, base: Option<&str>) -> Rule {
Rule {
id: id.to_string(),
description: format!("{id} description"),
expression: expression.to_string(),
severity: severity.to_string(),
base: base.map(str::to_string),
}
}
#[test]
fn well_formed_rules_produce_no_issues() {
let rules = vec![
rule("required_team", "error", "team exists", None),
rule(
"critical_team",
"error",
"team exists",
Some("required_team"),
),
];
assert!(validate(&rules).is_empty());
}
#[test]
fn blank_id_is_flagged() {
let rules = vec![rule("", "error", "team exists", None)];
let issues = validate(&rules);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, LintSeverity::Error);
assert!(issues[0].message.contains("blank"));
}
#[test]
fn duplicate_ids_are_flagged_by_name_and_index() {
let rules = vec![
rule("required_team", "error", "team exists", None),
rule("required_team", "warning", "docs exists", None),
];
let issues = validate(&rules);
assert_eq!(
issues.len(),
1,
"expected exactly one duplicate-id issue: {issues:?}"
);
assert!(issues[0].message.contains("required_team"));
assert!(issues[0].message.contains("indexes 0, 1"));
}
#[test]
fn dangling_base_reference_is_flagged() {
let rules = vec![rule(
"orphan",
"error",
"team exists",
Some("does_not_exist"),
)];
let issues = validate(&rules);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("orphan"));
assert!(issues[0].message.contains("does_not_exist"));
}
#[test]
fn self_referencing_base_is_a_cycle() {
let rules = vec![rule("self_ref", "error", "team exists", Some("self_ref"))];
let issues = validate(&rules);
assert_eq!(
issues.len(),
1,
"expected exactly one cycle issue: {issues:?}"
);
assert!(issues[0].message.contains("cycle"));
assert!(issues[0].message.contains("self_ref"));
}
#[test]
fn mutual_two_rule_cycle_is_reported_once() {
let rules = vec![
rule("a", "error", "team exists", Some("b")),
rule("b", "error", "team exists", Some("a")),
];
let issues = validate(&rules);
assert_eq!(
issues.len(),
1,
"a two-node cycle should be reported once, not once per node: {issues:?}"
);
assert!(issues[0].message.contains("cycle"));
}
#[test]
fn invalid_severity_is_surfaced_with_the_rule_id() {
let rules = vec![rule("bad_severity", "critical", "team exists", None)];
let issues = validate(&rules);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("bad_severity"));
assert!(issues[0].message.contains("critical"));
}
#[test]
fn unparsable_expression_is_surfaced_with_the_rule_id() {
let rules = vec![rule(
"bad_expression",
"error",
"this is not valid syntax",
None,
)];
let issues = validate(&rules);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("bad_expression"));
}
#[test]
fn structural_errors_short_circuit_before_reaching_compile() {
let rules = vec![rule(
"self_ref",
"not-a-real-severity",
"team exists",
Some("self_ref"),
)];
let issues = validate(&rules);
assert_eq!(issues.len(), 1, "expected only the cycle issue: {issues:?}");
assert!(issues[0].message.contains("cycle"));
}
}