use super::tiers::{SecurityConfig, SecurityTier};
use crate::error::Error;
pub const ACTION_NAMES: &[&str] = &["ignore", "log", "warn", "warn_with_challenge", "block"];
pub const SEVERITY_NAMES: &[&str] = &["info", "warn", "degraded", "hostile", "critical"];
pub const TIER_NAMES: &[&str] = &["standard", "hardened", "lockdown"];
impl SecurityConfig {
#[must_use]
pub fn build_policy(&self) -> crate::guard::Policy {
let mut policy = crate::guard::Policy::new();
for (sig_id, action_str) in &self.signal_overrides {
if let Some(action) = parse_action(action_str) {
policy.override_signal(crate::guard::SignalId::from_runtime_string(sig_id), action);
}
}
for (key, action_str) in &self.tier_overrides {
let Some((sev, tier)) = key.split_once(':') else {
continue;
};
let Some(severity) = parse_severity(sev) else {
continue;
};
let Some(tier_v) = parse_tier(tier) else {
continue;
};
let Some(action) = parse_action(action_str) else {
continue;
};
policy.override_action(severity, tier_v, action);
}
policy
}
pub fn set_signal_override(&mut self, signal_id: &str, action: &str) -> Result<(), Error> {
let id = validate_signal_id(signal_id)?;
let canon = canonical_action(action)?;
self.signal_overrides.insert(id, canon);
Ok(())
}
pub fn clear_signal_override(&mut self, signal_id: &str) -> bool {
self.signal_overrides.remove(signal_id.trim()).is_some()
}
pub fn set_tier_override(
&mut self,
severity: &str,
tier: &str,
action: &str,
) -> Result<(), Error> {
let sev = canonical_severity(severity)?;
let tier_canon = canonical_tier(tier)?;
let action_canon = canonical_action(action)?;
self.tier_overrides
.insert(format!("{sev}:{tier_canon}"), action_canon);
Ok(())
}
pub fn clear_tier_override(&mut self, severity: &str, tier: &str) -> Result<bool, Error> {
let sev = canonical_severity(severity)?;
let tier_canon = canonical_tier(tier)?;
Ok(self
.tier_overrides
.remove(&format!("{sev}:{tier_canon}"))
.is_some())
}
}
fn validate_signal_id(s: &str) -> Result<String, Error> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(Error::CryptoFailure(
"signal id must be non-empty".to_string(),
));
}
if trimmed.chars().any(char::is_control) {
return Err(Error::CryptoFailure(format!(
"signal id contains control characters: {trimmed:?}"
)));
}
Ok(trimmed.to_string())
}
fn canonical_action(s: &str) -> Result<String, Error> {
let lc = s.trim().to_ascii_lowercase();
if parse_action(&lc).is_some() {
Ok(lc)
} else {
Err(Error::CryptoFailure(format!(
"unknown action '{s}'. valid: {}",
ACTION_NAMES.join(", ")
)))
}
}
fn canonical_severity(s: &str) -> Result<String, Error> {
let lc = s.trim().to_ascii_lowercase();
if parse_severity(&lc).is_some() {
Ok(lc)
} else {
Err(Error::CryptoFailure(format!(
"unknown severity '{s}'. valid: {}",
SEVERITY_NAMES.join(", ")
)))
}
}
fn canonical_tier(s: &str) -> Result<String, Error> {
let lc = s.trim().to_ascii_lowercase();
if parse_tier(&lc).is_some() {
Ok(lc)
} else {
Err(Error::CryptoFailure(format!(
"unknown tier '{s}'. valid: {}",
TIER_NAMES.join(", ")
)))
}
}
fn parse_action(s: &str) -> Option<crate::guard::Action> {
match s.trim().to_ascii_lowercase().as_str() {
"ignore" => Some(crate::guard::Action::Ignore),
"log" => Some(crate::guard::Action::Log),
"warn" => Some(crate::guard::Action::Warn { friction: false }),
"warn_with_challenge" | "challenge" => Some(crate::guard::Action::Warn { friction: true }),
"block" => Some(crate::guard::Action::Block),
_ => None,
}
}
fn parse_severity(s: &str) -> Option<crate::guard::Severity> {
match s.trim().to_ascii_lowercase().as_str() {
"info" => Some(crate::guard::Severity::Info),
"warn" => Some(crate::guard::Severity::Warn),
"degraded" => Some(crate::guard::Severity::Degraded),
"hostile" => Some(crate::guard::Severity::Hostile),
"critical" => Some(crate::guard::Severity::Critical),
_ => None,
}
}
fn parse_tier(s: &str) -> Option<SecurityTier> {
match s.trim().to_ascii_lowercase().as_str() {
"standard" => Some(SecurityTier::Standard),
"hardened" => Some(SecurityTier::Hardened),
"lockdown" => Some(SecurityTier::Lockdown),
_ => None,
}
}
#[cfg(test)]
mod policy_override_tests {
use super::*;
use crate::guard::{Action, Severity};
#[test]
fn empty_overrides_yield_default_policy() {
let config = SecurityConfig::default();
let policy = config.build_policy();
let id = crate::guard::SignalId::from_runtime_string("any.signal");
let sig = crate::guard::Signal::new(
id,
crate::guard::Category::Other,
Severity::Hostile,
"x",
"",
"",
);
assert_eq!(policy.decide(&sig, SecurityTier::Lockdown), Action::Block);
}
#[test]
fn signal_override_loaded_from_config_takes_effect() {
let mut config = SecurityConfig::default();
config.signal_overrides.insert(
"gui.input_injector.xdotool".to_string(),
"ignore".to_string(),
);
let policy = config.build_policy();
let xdotool = crate::guard::Signal::new(
crate::guard::SignalId::from_runtime_string("gui.input_injector.xdotool"),
crate::guard::Category::InputInjection,
Severity::Hostile,
"xdotool",
"running",
"",
);
assert_eq!(
policy.decide(&xdotool, SecurityTier::Lockdown),
Action::Ignore
);
let ydotool = crate::guard::Signal::new(
crate::guard::SignalId::from_runtime_string("gui.input_injector.ydotool"),
crate::guard::Category::InputInjection,
Severity::Hostile,
"ydotool",
"running",
"",
);
assert_eq!(
policy.decide(&ydotool, SecurityTier::Lockdown),
Action::Block
);
}
#[test]
fn tier_override_loaded_from_config_takes_effect() {
let mut config = SecurityConfig::default();
config
.tier_overrides
.insert("warn:hardened".to_string(), "block".to_string());
let policy = config.build_policy();
let warn_sig = crate::guard::Signal::new(
crate::guard::SignalId::from_runtime_string("test.warn.x"),
crate::guard::Category::Other,
Severity::Warn,
"x",
"",
"",
);
assert_eq!(
policy.decide(&warn_sig, SecurityTier::Hardened),
Action::Block
);
assert_eq!(
policy.decide(&warn_sig, SecurityTier::Standard),
Action::Warn { friction: false }
);
}
#[test]
fn typo_in_action_string_is_silently_ignored_not_panic() {
let mut config = SecurityConfig::default();
config.signal_overrides.insert(
"gui.input_injector.xdotool".to_string(),
"BLOK_PLZ".to_string(),
);
let policy = config.build_policy();
let sig = crate::guard::Signal::new(
crate::guard::SignalId::from_runtime_string("gui.input_injector.xdotool"),
crate::guard::Category::InputInjection,
Severity::Hostile,
"xdotool",
"",
"",
);
assert_eq!(
policy.decide(&sig, SecurityTier::Lockdown),
Action::Block,
"garbage action string falls back to default, which blocks Hostile under Lockdown"
);
}
#[test]
fn config_roundtrips_through_toml_with_overrides() {
let mut config = SecurityConfig::default();
config.signal_overrides.insert(
"env.suspicious_loader_var.ld_library_path".to_string(),
"warn".to_string(),
);
config
.tier_overrides
.insert("warn:lockdown".to_string(), "block".to_string());
let serialized = toml::to_string(&config).expect("serialize");
let parsed: SecurityConfig = toml::from_str(&serialized).expect("deserialize");
assert_eq!(
parsed
.signal_overrides
.get("env.suspicious_loader_var.ld_library_path"),
Some(&"warn".to_string())
);
assert_eq!(
parsed.tier_overrides.get("warn:lockdown"),
Some(&"block".to_string())
);
}
#[test]
fn apply_preset_preserves_user_authored_overrides() {
let mut config = SecurityConfig::default();
config
.signal_overrides
.insert("gui.screen_recorder.obs".to_string(), "ignore".to_string());
config.apply_preset(SecurityTier::Lockdown);
assert_eq!(
config.signal_overrides.get("gui.screen_recorder.obs"),
Some(&"ignore".to_string()),
"tier preset must not wipe user-authored signal overrides"
);
}
#[test]
fn set_signal_override_canonicalizes_action() {
let mut config = SecurityConfig::default();
config
.set_signal_override("gui.input_injector.xdotool", "BLOCK")
.expect("valid action");
assert_eq!(
config.signal_overrides.get("gui.input_injector.xdotool"),
Some(&"block".to_string()),
"action must be normalized to lowercase on write"
);
}
#[test]
fn set_signal_override_rejects_unknown_action() {
let mut config = SecurityConfig::default();
let err = config
.set_signal_override("gui.x", "deny")
.expect_err("must reject");
assert!(
err.to_string().contains("unknown action"),
"error message: {err}"
);
}
#[test]
fn set_signal_override_rejects_empty_id() {
let mut config = SecurityConfig::default();
let err = config
.set_signal_override(" ", "block")
.expect_err("must reject");
assert!(err.to_string().contains("non-empty"));
}
#[test]
fn clear_signal_override_returns_presence() {
let mut config = SecurityConfig::default();
config.set_signal_override("a.b.c", "ignore").unwrap();
assert!(config.clear_signal_override("a.b.c"));
assert!(!config.clear_signal_override("a.b.c"));
}
#[test]
fn set_tier_override_validates_all_three_fields() {
let mut config = SecurityConfig::default();
config
.set_tier_override("warn", "hardened", "block")
.expect("valid");
assert_eq!(
config.tier_overrides.get("warn:hardened"),
Some(&"block".to_string())
);
assert!(config
.set_tier_override("scary", "hardened", "block")
.is_err());
assert!(config
.set_tier_override("warn", "paranoid", "block")
.is_err());
assert!(config
.set_tier_override("warn", "hardened", "deny")
.is_err());
}
#[test]
fn clear_tier_override_validates_inputs() {
let mut config = SecurityConfig::default();
config
.set_tier_override("warn", "hardened", "block")
.unwrap();
assert!(config.clear_tier_override("warn", "hardened").unwrap());
assert!(!config.clear_tier_override("warn", "hardened").unwrap());
assert!(config.clear_tier_override("scary", "hardened").is_err());
}
#[test]
fn override_then_build_policy_applies_canonical_value() {
let mut config = SecurityConfig::default();
config
.set_signal_override("env.preload.ld_preload", "ignore")
.unwrap();
let policy = config.build_policy();
let sig = crate::guard::Signal::new(
crate::guard::SignalId::from_runtime_string("env.preload.ld_preload"),
crate::guard::Category::EnvironmentInjection,
Severity::Hostile,
"ld_preload",
"",
"",
);
assert_eq!(
policy.decide(&sig, SecurityTier::Lockdown),
Action::Ignore,
"API-set override must round-trip through build_policy"
);
}
}