use std::path::Path;
use apcore::{match_pattern, ACLRule, ErrorCode, ModuleError, ACL};
use apcore_toolkit::ScannedModule;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct AclConfig {
rules: Vec<ACLRule>,
default_effect: String,
}
const READONLY_RULE_DESCRIPTION: &str = "Auto-allow readonly CLI commands";
const DESTRUCTIVE_RULE_DESCRIPTION: &str = "Block destructive CLI commands by default";
pub struct AclManager {
acl: ACL,
default_effect: String,
}
impl AclManager {
#[allow(clippy::result_large_err)] pub fn from_config(config_path: &Path) -> Result<Self, ModuleError> {
let acl = ACL::load(&config_path.to_string_lossy()).map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("Failed to load ACL: {e}"),
)
})?;
let default_effect = Self::read_default_effect(config_path);
Ok(Self {
acl,
default_effect,
})
}
pub fn generate_default(modules: &[ScannedModule]) -> Self {
let readonly_ids = Self::readonly_ids(modules);
let destructive_ids = Self::destructive_ids(modules);
let mut rules = Vec::new();
if !readonly_ids.is_empty() {
rules.push(Self::readonly_rule(readonly_ids));
}
if !destructive_ids.is_empty() {
rules.push(Self::destructive_rule(destructive_ids));
}
let acl = ACL::new(rules, "deny", None);
Self {
acl,
default_effect: "deny".to_string(),
}
}
#[allow(clippy::result_large_err)] pub fn merge_default(
existing_path: &Path,
modules: &[ScannedModule],
) -> Result<Self, ModuleError> {
let existing = Self::from_config(existing_path)?;
let batch_ids: std::collections::HashSet<&str> =
modules.iter().map(|m| m.module_id.as_str()).collect();
let mut fresh_readonly = Self::readonly_ids(modules);
let mut fresh_destructive = Self::destructive_ids(modules);
let mut rules = Vec::new();
for mut rule in existing.acl.rules().to_vec() {
match rule.description.as_deref() {
Some(READONLY_RULE_DESCRIPTION) => {
rule.targets.retain(|id| !batch_ids.contains(id.as_str()));
rule.targets.append(&mut fresh_readonly);
if rule.targets.is_empty() {
continue;
}
}
Some(DESTRUCTIVE_RULE_DESCRIPTION) => {
rule.targets.retain(|id| !batch_ids.contains(id.as_str()));
rule.targets.append(&mut fresh_destructive);
if rule.targets.is_empty() {
continue;
}
}
_ => {}
}
rules.push(rule);
}
if !fresh_readonly.is_empty() {
rules.push(Self::readonly_rule(fresh_readonly));
}
if !fresh_destructive.is_empty() {
rules.push(Self::destructive_rule(fresh_destructive));
}
let default_effect = existing.default_effect.clone();
let acl = ACL::new(rules, &default_effect, None);
Ok(Self {
acl,
default_effect,
})
}
fn readonly_ids(modules: &[ScannedModule]) -> Vec<String> {
modules
.iter()
.filter(|m| m.annotations.as_ref().is_some_and(|a| a.readonly))
.map(|m| m.module_id.clone())
.collect()
}
fn readonly_rule(targets: Vec<String>) -> ACLRule {
ACLRule {
callers: vec!["*".to_string()],
targets,
effect: "allow".to_string(),
description: Some(READONLY_RULE_DESCRIPTION.to_string()),
conditions: None,
}
}
fn destructive_ids(modules: &[ScannedModule]) -> Vec<String> {
modules
.iter()
.filter(|m| m.annotations.as_ref().is_some_and(|a| a.destructive))
.map(|m| m.module_id.clone())
.collect()
}
fn destructive_rule(targets: Vec<String>) -> ACLRule {
ACLRule {
callers: vec!["*".to_string()],
targets,
effect: "deny".to_string(),
description: Some(DESTRUCTIVE_RULE_DESCRIPTION.to_string()),
conditions: None,
}
}
#[allow(clippy::result_large_err)] pub fn write_config(&self, path: &Path) -> Result<(), ModuleError> {
let config = AclConfig {
rules: self.acl.rules().to_vec(),
default_effect: self.default_effect.clone(),
};
let yaml = serde_yaml::to_string(&config).map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("Failed to serialize ACL: {e}"),
)
})?;
std::fs::write(path, yaml).map_err(|e| {
ModuleError::new(
ErrorCode::GeneralInternalError,
format!("Failed to write ACL file: {e}"),
)
})?;
Ok(())
}
pub fn into_inner(self) -> ACL {
self.acl
}
fn read_default_effect(path: &Path) -> String {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_yaml::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("default_effect")?.as_str().map(String::from))
.unwrap_or_else(|| "deny".to_string())
}
}
const OR_SENTINEL: &str = "$or";
const NOT_SENTINEL: &str = "$not";
const MAX_SUGGESTIONS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InertRule {
pub rule_index: usize,
pub field: String,
pub effect: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnmatchedTarget {
pub rule_index: usize,
pub effect: String,
pub pattern: String,
pub negated: bool,
pub suggestions: Vec<String>,
}
impl UnmatchedTarget {
pub fn is_near_miss(&self) -> bool {
!self.suggestions.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AclValidationReport {
pub inert_rules: Vec<InertRule>,
pub unmatched_targets: Vec<UnmatchedTarget>,
}
impl AclValidationReport {
pub fn is_fatal(&self) -> bool {
!self.inert_rules.is_empty()
|| self
.unmatched_targets
.iter()
.any(UnmatchedTarget::is_near_miss)
}
pub fn emit_warnings(&self) {
for target in self.unmatched_targets.iter().filter(|t| !t.is_near_miss()) {
if target.negated {
tracing::warn!(
rule_index = target.rule_index,
effect = %target.effect,
pattern = %target.pattern,
"ACL `$not` operand matches no registered module, so apcore matches \
this rule against EVERY module rather than none. Under \
first-match-wins an `allow` here nullifies every rule below it. \
Harmless if the operand anticipates a module this server does not \
serve (a glob, or an ACL shared with a differently filtered \
server); a typo otherwise."
);
} else {
tracing::warn!(
rule_index = target.rule_index,
effect = %target.effect,
pattern = %target.pattern,
"ACL target matches no registered module — this rule currently \
protects nothing. Harmless if the pattern anticipates a module \
this server does not serve (a glob, or an ACL shared with a \
differently filtered server); a typo otherwise."
);
}
}
}
pub fn fatal_error(&self, acl_path: &Path) -> Option<ModuleError> {
if !self.is_fatal() {
return None;
}
let mut lines: Vec<String> = self.inert_rules.iter().map(describe_inert).collect();
lines.extend(
self.unmatched_targets
.iter()
.filter(|t| t.is_near_miss())
.map(describe_near_miss),
);
Some(
ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"ACL '{}' contains {} rule(s) that protect nothing:\n{}\n\
Refusing to start: an operator who asked for access control must not \
silently get less of it than they wrote.",
acl_path.display(),
lines.len(),
lines.join("\n")
),
)
.with_retryable(false),
)
}
}
fn describe_inert(rule: &InertRule) -> String {
format!(
" - rule {} ({}): `{}` is {}",
rule.rule_index, rule.effect, rule.field, rule.reason
)
}
fn describe_near_miss(target: &UnmatchedTarget) -> String {
if target.negated {
return format!(
" - rule {} ({}): `$not` operand '{}' matches no registered module, so this rule \
applies to every module instead of excluding one; did you mean {}?",
target.rule_index,
target.effect,
target.pattern,
target.suggestions.join(" or ")
);
}
format!(
" - rule {} ({}): target '{}' matches no registered module; did you mean {}?",
target.rule_index,
target.effect,
target.pattern,
target.suggestions.join(" or ")
)
}
pub fn validate_acl_rules(rules: &[ACLRule], registered_ids: &[String]) -> AclValidationReport {
let mut report = AclValidationReport::default();
for (rule_index, rule) in rules.iter().enumerate() {
collect_inert(rule_index, rule, &mut report);
if registered_ids.is_empty() {
continue;
}
collect_unmatched_targets(rule_index, rule, registered_ids, &mut report);
}
report
}
fn collect_inert(rule_index: usize, rule: &ACLRule, report: &mut AclValidationReport) {
for (field, patterns) in [("callers", &rule.callers), ("targets", &rule.targets)] {
if let Some(reason) = never_matches(patterns) {
report.inert_rules.push(InertRule {
rule_index,
field: field.to_string(),
effect: rule.effect.clone(),
reason,
});
}
}
}
fn collect_unmatched_targets(
rule_index: usize,
rule: &ACLRule,
registered_ids: &[String],
report: &mut AclValidationReport,
) {
for target in target_patterns(&rule.targets) {
if registered_ids
.iter()
.any(|id| match_pattern(target.pattern, id))
{
continue;
}
report.unmatched_targets.push(UnmatchedTarget {
rule_index,
effect: rule.effect.clone(),
pattern: target.pattern.to_string(),
negated: target.negated,
suggestions: suggest_similar(target.pattern, registered_ids),
});
}
}
fn never_matches(patterns: &[String]) -> Option<String> {
if patterns.is_empty() {
return Some(
"an empty list — apcore's matcher returns `false` for an empty pattern list, so \
this rule can never fire"
.to_string(),
);
}
match patterns[0].as_str() {
NOT_SENTINEL if patterns.len() < 2 => {
Some("`$not` with no operand, which apcore's matcher rejects outright".to_string())
}
OR_SENTINEL if patterns.len() < 2 => {
Some("`$or` with no operands, so there is nothing to match".to_string())
}
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TargetPattern<'a> {
pattern: &'a str,
negated: bool,
}
fn target_patterns(targets: &[String]) -> Vec<TargetPattern<'_>> {
fn positive(pattern: &str) -> TargetPattern<'_> {
TargetPattern {
pattern,
negated: false,
}
}
match targets.first().map(String::as_str) {
Some(OR_SENTINEL) => targets[1..]
.iter()
.map(String::as_str)
.map(positive)
.collect(),
Some(NOT_SENTINEL) => targets
.get(1)
.map(|pattern| TargetPattern {
pattern: pattern.as_str(),
negated: true,
})
.into_iter()
.collect(),
_ => targets.iter().map(String::as_str).map(positive).collect(),
}
}
fn suggest_similar(pattern: &str, registered_ids: &[String]) -> Vec<String> {
let needle = normalize_id(pattern);
if needle.is_empty() || needle.contains('*') {
return vec![];
}
let suffix = format!(".{needle}");
let mut hits: Vec<String> = registered_ids
.iter()
.filter(|id| {
let normalized = normalize_id(id);
normalized == needle || normalized.ends_with(&suffix)
})
.cloned()
.collect();
hits.truncate(MAX_SUGGESTIONS);
hits
}
fn normalize_id(id: &str) -> String {
id.trim()
.to_ascii_lowercase()
.chars()
.map(|c| {
if c == '-' || c == '_' || c == '/' || c.is_whitespace() {
'.'
} else {
c
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_module_with_annotations(id: &str, readonly: bool, destructive: bool) -> ScannedModule {
let mut module = ScannedModule::new(
id.to_string(),
format!("Test {id}"),
json!({"type": "object"}),
json!({"type": "object"}),
vec!["cli".to_string()],
format!("exec:///usr/bin/test {id}"),
);
module.annotations = Some(apcore::module::ModuleAnnotations {
readonly,
destructive,
requires_approval: destructive,
..Default::default()
});
module
}
#[test]
fn test_acl_generate_default_readonly() {
let modules = vec![
make_module_with_annotations("cli.git.status", true, false),
make_module_with_annotations("cli.git.log", true, false),
];
let mgr = AclManager::generate_default(&modules);
let rules = mgr.acl.rules();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].effect, "allow");
assert_eq!(rules[0].targets.len(), 2);
}
#[test]
fn test_acl_generate_default_destructive() {
let modules = vec![make_module_with_annotations("cli.git.clean", false, true)];
let mgr = AclManager::generate_default(&modules);
let rules = mgr.acl.rules();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].effect, "deny");
assert!(rules[0].conditions.is_none());
}
#[test]
fn test_acl_generate_default_destructive_rule_denies_on_its_own() {
let modules = vec![make_module_with_annotations("cli.git.clean", false, true)];
let mgr = AclManager::generate_default(&modules);
let mut rules = mgr.acl.rules().to_vec();
rules.push(ACLRule {
callers: vec!["*".to_string()],
targets: vec!["cli.git.clean".to_string()],
effect: "allow".to_string(),
description: None,
conditions: None,
});
let acl = ACL::new(rules, "allow", None);
assert!(
!acl.check(None, "cli.git.clean", None),
"destructive command must be denied by its own ACL rule, not merely \
by a coincidental default_effect"
);
}
#[test]
fn test_acl_generate_default_mixed() {
let modules = vec![
make_module_with_annotations("cli.git.status", true, false),
make_module_with_annotations("cli.git.clean", false, true),
];
let mgr = AclManager::generate_default(&modules);
let rules = mgr.acl.rules();
assert_eq!(rules.len(), 2);
}
#[test]
fn test_acl_generate_default_empty() {
let mgr = AclManager::generate_default(&[]);
let rules = mgr.acl.rules();
assert!(rules.is_empty());
assert_eq!(mgr.default_effect, "deny");
}
#[test]
fn test_acl_merge_default_preserves_targets_from_an_earlier_scan() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("acl.yaml");
let first_batch = vec![make_module_with_annotations("cli.ls", true, false)];
AclManager::generate_default(&first_batch)
.write_config(&path)
.unwrap();
let second_batch = vec![make_module_with_annotations("cli.echo", false, false)];
let merged = AclManager::merge_default(&path, &second_batch).unwrap();
let readonly_rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some(READONLY_RULE_DESCRIPTION))
.expect("the readonly-allow rule from the first scan must survive");
assert!(
readonly_rule.targets.contains(&"cli.ls".to_string()),
"{:?}",
readonly_rule.targets
);
}
#[test]
fn test_acl_merge_default_moves_a_module_between_rules_when_its_annotations_change() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("acl.yaml");
let first_batch = vec![make_module_with_annotations("cli.tool", true, false)];
AclManager::generate_default(&first_batch)
.write_config(&path)
.unwrap();
let second_batch = vec![make_module_with_annotations("cli.tool", false, true)];
let merged = AclManager::merge_default(&path, &second_batch).unwrap();
let readonly_rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some(READONLY_RULE_DESCRIPTION));
if let Some(rule) = readonly_rule {
assert!(
!rule.targets.contains(&"cli.tool".to_string()),
"{:?}",
rule.targets
);
}
let destructive_rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some(DESTRUCTIVE_RULE_DESCRIPTION))
.expect("cli.tool must now be in the destructive-deny rule");
assert!(destructive_rule.targets.contains(&"cli.tool".to_string()));
}
#[test]
fn test_acl_merge_default_keeps_hand_authored_rules_untouched() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("acl.yaml");
std::fs::write(
&path,
"rules:\n - callers: [\"*\"]\n targets: [\"cli.special\"]\n effect: deny\n description: Hand-authored exception\ndefault_effect: allow\n",
)
.unwrap();
let batch = vec![make_module_with_annotations("cli.ls", true, false)];
let merged = AclManager::merge_default(&path, &batch).unwrap();
assert_eq!(
merged.default_effect, "allow",
"existing default_effect must be preserved"
);
let hand_rule = merged
.acl
.rules()
.iter()
.find(|r| r.description.as_deref() == Some("Hand-authored exception"))
.expect("hand-authored rule must survive a merge");
assert_eq!(hand_rule.targets, vec!["cli.special".to_string()]);
}
#[test]
fn test_acl_merge_default_falls_back_to_generate_default_when_no_file_exists() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("does-not-exist.yaml");
let batch = vec![make_module_with_annotations("cli.ls", true, false)];
let result = AclManager::merge_default(&path, &batch);
assert!(
result.is_err(),
"merging against a missing file should error, not silently start from empty"
);
}
#[test]
fn test_acl_write_and_load() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("acl_manager.yaml");
let modules = vec![
make_module_with_annotations("cli.git.status", true, false),
make_module_with_annotations("cli.git.clean", false, true),
];
let mgr = AclManager::generate_default(&modules);
mgr.write_config(&path).unwrap();
let loaded = AclManager::from_config(&path).unwrap();
assert_eq!(loaded.acl.rules().len(), 2);
assert_eq!(loaded.default_effect, "deny");
}
fn rule(targets: &[&str], effect: &str) -> ACLRule {
ACLRule {
callers: vec!["*".to_string()],
targets: targets.iter().map(|t| (*t).to_string()).collect(),
effect: effect.to_string(),
description: None,
conditions: None,
}
}
fn registered(ids: &[&str]) -> Vec<String> {
ids.iter().map(|id| (*id).to_string()).collect()
}
#[test]
fn test_validate_acl_rules_accepts_exact_registered_target() {
let report = validate_acl_rules(
&[rule(&["cli.cp"], "deny")],
®istered(&["cli.cp", "cli.ls"]),
);
assert_eq!(report, AclValidationReport::default());
assert!(!report.is_fatal());
}
#[test]
fn test_validate_acl_rules_flags_empty_target_list_as_inert() {
let report = validate_acl_rules(&[rule(&[], "deny")], ®istered(&["cli.cp"]));
assert_eq!(report.inert_rules.len(), 1);
assert_eq!(report.inert_rules[0].field, "targets");
assert_eq!(report.inert_rules[0].effect, "deny");
assert!(report.is_fatal());
}
#[test]
fn test_validate_acl_rules_flags_empty_caller_list_as_inert() {
let mut inert = rule(&["cli.cp"], "deny");
inert.callers = vec![];
let report = validate_acl_rules(&[inert], ®istered(&["cli.cp"]));
assert_eq!(report.inert_rules.len(), 1);
assert_eq!(report.inert_rules[0].field, "callers");
}
#[test]
fn test_validate_acl_rules_flags_bare_compound_sentinels_as_inert() {
let report = validate_acl_rules(
&[rule(&["$not"], "deny"), rule(&["$or"], "allow")],
®istered(&["cli.cp"]),
);
assert_eq!(report.inert_rules.len(), 2);
assert!(report.inert_rules[0].reason.contains("$not"));
assert!(report.inert_rules[1].reason.contains("$or"));
}
#[test]
fn test_validate_acl_rules_flags_hyphenated_id_as_near_miss() {
let report = validate_acl_rules(
&[rule(&["cli.git.cat-file"], "deny")],
®istered(&["cli.git.cat_file"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
assert_eq!(
report.unmatched_targets[0].suggestions,
vec!["cli.git.cat_file".to_string()]
);
assert!(report.unmatched_targets[0].is_near_miss());
assert!(report.is_fatal());
}
#[test]
fn test_validate_acl_rules_flags_bare_command_names_as_near_miss() {
for (pattern, expected) in [
("cp", "cli.cp"),
("ls", "cli.ls"),
("git log", "cli.git.log"),
] {
let report = validate_acl_rules(
&[rule(&[pattern], "deny")],
®istered(&["cli.cp", "cli.ls", "cli.git.log"]),
);
assert_eq!(
report.unmatched_targets[0].suggestions,
vec![expected.to_string()],
"'{pattern}' should point at '{expected}'"
);
assert!(report.is_fatal(), "'{pattern}' should refuse to start");
}
}
#[test]
fn test_validate_acl_rules_warns_but_does_not_refuse_on_unknown_target() {
let report = validate_acl_rules(
&[rule(&["cli.kubectl.apply"], "deny")],
®istered(&["cli.cp", "cli.ls"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
assert!(report.unmatched_targets[0].suggestions.is_empty());
assert!(!report.is_fatal());
}
#[test]
fn test_validate_acl_rules_accepts_working_globs() {
let report = validate_acl_rules(
&[
rule(&["cli.c*"], "deny"),
rule(&["*.log"], "allow"),
rule(&["cli.*.status"], "allow"),
rule(&["*"], "deny"),
],
®istered(&["cli.cp", "cli.git.log", "cli.git.status"]),
);
assert_eq!(report, AclValidationReport::default());
}
#[test]
fn test_validate_acl_rules_warns_on_glob_matching_nothing() {
let report = validate_acl_rules(&[rule(&["cli.z*"], "deny")], ®istered(&["cli.cp"]));
assert_eq!(report.unmatched_targets.len(), 1);
assert!(!report.is_fatal());
}
#[test]
fn test_validate_acl_rules_understands_compound_targets() {
let report = validate_acl_rules(
&[
rule(&["$or", "cli.cp", "cli.ls"], "deny"),
rule(&["$not", "cli.cp", "never.consulted"], "allow"),
],
®istered(&["cli.cp", "cli.ls"]),
);
assert_eq!(report, AclValidationReport::default());
}
#[test]
fn test_validate_acl_rules_flags_unmatched_operand_inside_compound() {
let report = validate_acl_rules(
&[rule(&["$or", "cli.cp", "cli.git.cat-file"], "deny")],
®istered(&["cli.cp", "cli.git.cat_file"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
assert_eq!(report.unmatched_targets[0].pattern, "cli.git.cat-file");
assert!(report.is_fatal());
}
#[test]
fn test_validate_acl_rules_reports_a_not_operand_as_covering_everything() {
let report = validate_acl_rules(
&[rule(&["$not", "cli.kubectl.apply"], "allow")],
®istered(&["cli.cp", "cli.ls"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
let finding = &report.unmatched_targets[0];
assert!(finding.negated, "the $not polarity must be carried through");
assert_eq!(finding.pattern, "cli.kubectl.apply");
assert!(!finding.is_near_miss());
assert!(!report.is_fatal());
}
#[test]
fn test_validate_acl_rules_refuses_a_near_miss_not_operand() {
let report = validate_acl_rules(
&[rule(&["$not", "cli.git.cat-file"], "allow")],
®istered(&["cli.git.cat_file", "cli.cp"]),
);
assert_eq!(report.unmatched_targets.len(), 1);
assert!(report.unmatched_targets[0].negated);
assert!(report.is_fatal());
let message = report
.fatal_error(Path::new("/etc/apexe/acl.yaml"))
.expect("a near-miss $not operand must refuse to start")
.message;
assert!(message.contains("$not"), "{message}");
assert!(
message.contains("applies to every module"),
"the diagnostic must not claim the rule protects nothing: {message}"
);
assert!(message.contains("cli.git.cat_file"), "{message}");
}
#[test]
fn test_validate_acl_rules_skips_target_check_on_empty_registry() {
let report = validate_acl_rules(&[rule(&["cli.cp"], "deny"), rule(&[], "deny")], &[]);
assert!(report.unmatched_targets.is_empty());
assert_eq!(report.inert_rules.len(), 1);
}
#[test]
fn test_acl_validation_report_fatal_error_names_the_findings() {
let report = validate_acl_rules(
&[rule(&["cli.git.cat-file"], "deny"), rule(&[], "deny")],
®istered(&["cli.git.cat_file"]),
);
let err = report
.fatal_error(Path::new("/etc/apexe/acl.yaml"))
.expect("report should be fatal");
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
assert!(err.message.contains("/etc/apexe/acl.yaml"));
assert!(err.message.contains("cli.git.cat_file"));
assert!(err.message.contains("empty list"));
}
#[test]
fn test_acl_validation_report_fatal_error_none_when_clean() {
let report = validate_acl_rules(&[rule(&["cli.cp"], "deny")], ®istered(&["cli.cp"]));
assert!(report.fatal_error(Path::new("/tmp/acl.yaml")).is_none());
}
#[test]
fn test_normalize_id_folds_separators_and_case() {
assert_eq!(normalize_id("cli.git.cat-file"), "cli.git.cat.file");
assert_eq!(normalize_id("CLI_GIT_LOG"), "cli.git.log");
assert_eq!(normalize_id(" git log "), "git.log");
}
#[test]
fn test_suggest_similar_does_not_guess_across_unrelated_modules() {
assert!(suggest_similar("clip", ®istered(&["cli.cp"])).is_empty());
assert!(suggest_similar("cli.c*", ®istered(&["cli.cp"])).is_empty());
}
#[tokio::test]
async fn test_acl_decision_recorded_via_audit_logger() {
use std::sync::Arc;
let tmp = tempfile::TempDir::new().unwrap();
let audit_path = tmp.path().join("audit.jsonl");
let audit = Arc::new(crate::governance::AuditManager::new(&audit_path));
let modules = vec![make_module_with_annotations("cli.rm", false, true)];
let mut acl = AclManager::generate_default(&modules).into_inner();
{
let audit = audit.clone();
acl.set_audit_logger(move |entry| audit.log_acl_decision(entry));
}
let allowed = acl.check(Some("@external"), "cli.rm", None);
assert!(!allowed);
let mut content = String::new();
for _ in 0..200 {
content = std::fs::read_to_string(&audit_path).unwrap_or_default();
if !content.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
assert!(
content.contains("\"decision\""),
"ACL decision not recorded: {content}"
);
assert!(content.contains("cli.rm"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&audit_path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "audit log should be owner-only");
}
}
}