use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::AppError;
use crate::audit::AuditConfig;
use crate::context::ContextConfig;
use crate::detector::DetectorConfig;
use crate::rules::{ActionKind, RuleConfig};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_detectors")]
pub detectors: Vec<DetectorConfig>,
#[serde(default = "default_rules")]
pub rules: Vec<RuleConfig>,
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub context: Option<ContextConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
detectors: default_detectors(),
rules: default_rules(),
audit: AuditConfig::default(),
context: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigLoadResult {
pub config: Config,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct UserConfig {
detectors: Option<Vec<DetectorConfig>>,
#[serde(default)]
rules: Vec<UserRule>,
#[serde(default)]
audit: AuditConfig,
#[serde(default)]
context: Option<ContextConfig>,
#[serde(default)]
overrides: HashMap<String, bool>,
}
#[derive(Debug, Clone, Deserialize)]
struct UserRule {
name: String,
command: Option<String>,
action: Option<ActionKind>,
enabled: Option<bool>,
destination: Option<String>,
match_all: Option<Vec<String>>,
match_any: Option<Vec<String>>,
message: Option<String>,
}
pub const BLOCKED_DESTINATION_PREFIXES: &[&str] = &[
"/usr", "/etc", "/System", "/Library", "/bin", "/sbin", "/var", "/private",
];
pub fn load_config(path: Option<&Path>) -> Result<ConfigLoadResult, AppError> {
let path = path.map(Path::to_path_buf).or_else(default_config_path);
let mut warnings = Vec::new();
let config = match path {
Some(path) => {
if !path.exists() {
warnings.push(format!(
"config not found at {}\n \
Built-in default rules are active (safe to use as-is).\n \
To create a config for customization, run: omamori init",
path.display()
));
Config::default()
} else if !permissions_are_safe(&path)? {
warnings.push(format!(
"config permissions are too open at {}\n \
Built-in default rules are active for security.\n \
To fix, run: chmod 600 {}",
path.display(),
path.display()
));
Config::default()
} else {
let content = fs::read_to_string(&path)?;
match toml::from_str::<UserConfig>(&content) {
Ok(user_config) => build_merged_config(user_config, &mut warnings),
Err(error) => {
warnings.push(format!(
"failed to parse config at {} ({error})\n \
Built-in default rules are active for safety.\n \
Fix the syntax error or run: omamori init --force",
path.display()
));
Config::default()
}
}
}
}
None => Config::default(),
};
Ok(ConfigLoadResult { config, warnings })
}
fn build_merged_config(user: UserConfig, warnings: &mut Vec<String>) -> Config {
let detectors = user.detectors.unwrap_or_else(default_detectors);
let mut rules = merge_rules(default_rules(), &user.rules, &user.overrides, warnings);
validate_rules(&mut rules, warnings);
if let Some(ref ctx) = user.context {
let ctx_warnings = crate::context::validate_regenerable_paths(&ctx.regenerable_paths);
warnings.extend(ctx_warnings);
}
Config {
detectors,
rules,
audit: user.audit,
context: user.context,
}
}
fn merge_rules(
defaults: Vec<RuleConfig>,
user_rules: &[UserRule],
overrides: &HashMap<String, bool>,
warnings: &mut Vec<String>,
) -> Vec<RuleConfig> {
let mut seen_names = HashSet::new();
for ur in user_rules {
if !seen_names.insert(&ur.name) {
warnings.push(format!(
"duplicate rule name `{}` in config; only the first occurrence is used",
ur.name
));
}
}
let mut merged = defaults;
let mut applied_names = HashSet::new();
for ur in user_rules {
if applied_names.contains(&ur.name) {
continue; }
applied_names.insert(ur.name.clone());
if let Some(existing) = merged.iter_mut().find(|r| r.name == ur.name) {
apply_user_overrides(existing, ur, overrides, warnings);
} else {
match (&ur.command, &ur.action) {
(Some(command), Some(action)) => {
let mut rule = RuleConfig::new(
&ur.name,
command,
action.clone(),
ur.match_all.clone().unwrap_or_default(),
ur.match_any.clone().unwrap_or_default(),
ur.message.clone(),
);
if let Some(enabled) = ur.enabled {
rule.enabled = enabled;
}
if let Some(dest) = &ur.destination {
rule.destination = Some(dest.clone());
}
merged.push(rule);
}
_ => {
warnings.push(format!(
"rule `{}` is not a built-in rule and is missing `command` or `action`; skipped",
ur.name
));
}
}
}
}
for (rule_name, &enabled) in overrides {
if !enabled
&& let Some(rule) = merged.iter_mut().find(|r| r.name == *rule_name)
&& rule.is_builtin
{
rule.enabled = false;
}
}
merged
}
fn apply_user_overrides(
rule: &mut RuleConfig,
ur: &UserRule,
overrides: &HashMap<String, bool>,
warnings: &mut Vec<String>,
) {
if rule.is_builtin {
let has_non_message = ur.command.is_some()
|| ur.action.is_some()
|| ur.match_all.is_some()
|| ur.match_any.is_some()
|| ur.destination.is_some();
let has_enabled_override = ur.enabled.is_some();
let has_overrides_entry = overrides.contains_key(&rule.name);
if has_non_message {
if let Some(action) = &ur.action {
if action.defense_level() < rule.action.defense_level() {
warnings.push(format!(
"rule `{}` is a core safety rule — action downgrade from `{}` to `{}` \
is not allowed. Override ignored.",
rule.name,
rule.action.as_str(),
action.as_str()
));
} else if action.defense_level() >= rule.action.defense_level()
&& action != &rule.action
{
rule.action = action.clone();
}
}
if ur.command.is_some()
|| ur.match_all.is_some()
|| ur.match_any.is_some()
|| ur.destination.is_some()
{
warnings.push(format!(
"rule `{}` is a core safety rule. Only `message` can be customized. \
Other overrides (`command`, `match_all`, `match_any`, `destination`) are ignored.",
rule.name
));
}
}
if has_enabled_override && !has_overrides_entry && ur.enabled == Some(false) {
warnings.push(format!(
"rule `{}` is a core safety rule and cannot be disabled via config. \
Ignored. To override: omamori override disable {}",
rule.name, rule.name
));
}
if let Some(message) = &ur.message {
rule.message = Some(message.clone());
}
return;
}
if let Some(command) = &ur.command {
rule.command = command.clone();
}
if let Some(action) = &ur.action {
rule.action = action.clone();
}
if let Some(enabled) = ur.enabled {
rule.enabled = enabled;
}
if let Some(dest) = &ur.destination {
rule.destination = Some(dest.clone());
}
if let Some(match_all) = &ur.match_all {
rule.match_all = match_all.clone();
}
if let Some(match_any) = &ur.match_any {
rule.match_any = match_any.clone();
}
if let Some(message) = &ur.message {
rule.message = Some(message.clone());
}
}
fn validate_rules(rules: &mut [RuleConfig], warnings: &mut Vec<String>) {
for rule in rules.iter_mut() {
if rule.action == ActionKind::MoveTo && rule.destination.is_none() {
warnings.push(format!(
"rule `{}` uses action `move-to` but has no `destination`; rule disabled",
rule.name
));
rule.enabled = false;
}
if rule.destination.is_some() && rule.action != ActionKind::MoveTo {
warnings.push(format!(
"rule `{}` has a `destination` but action is `{}`; destination is ignored",
rule.name,
rule.action.as_str()
));
}
if let Some(dest) = &rule.destination.clone()
&& !validate_destination(dest, &rule.name, warnings)
{
rule.enabled = false;
}
}
}
fn validate_destination(dest: &str, rule_name: &str, warnings: &mut Vec<String>) -> bool {
let path = Path::new(dest);
if !path.is_absolute() {
warnings.push(format!(
"rule `{rule_name}`: destination `{dest}` is not an absolute path; rule disabled"
));
return false;
}
if let Ok(canonical) = path.canonicalize() {
let canonical_str = canonical.to_string_lossy();
for prefix in BLOCKED_DESTINATION_PREFIXES {
if canonical_str.starts_with(prefix) {
warnings.push(format!(
"rule `{rule_name}`: destination `{dest}` resolves to system directory \
`{canonical_str}`; rule disabled for security"
));
return false;
}
}
if let Ok(meta) = fs::symlink_metadata(&canonical)
&& meta.file_type().is_symlink()
{
warnings.push(format!(
"rule `{rule_name}`: destination `{dest}` is a symlink; rule disabled for security"
));
return false;
}
}
true
}
pub fn default_config_path() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
let xdg_path = PathBuf::from(&xdg);
if xdg_path.is_absolute() {
return Some(xdg_path.join("omamori").join("config.toml"));
}
}
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".config").join("omamori").join("config.toml"))
}
pub fn default_detectors() -> Vec<DetectorConfig> {
vec![
DetectorConfig::env_var("claude-code", "CLAUDECODE", "1"),
DetectorConfig::env_var("codex-cli", "CODEX_CI", "1"),
DetectorConfig::env_var("cursor", "CURSOR_AGENT", "1"),
DetectorConfig::env_var("gemini-cli", "GEMINI_CLI", "1"),
DetectorConfig::env_var("cline", "CLINE_ACTIVE", "true"),
DetectorConfig::env_var("ai-guard-fallback", "AI_GUARD", "1"),
]
}
pub fn default_rules() -> Vec<RuleConfig> {
vec![
RuleConfig::new(
"rm-recursive-to-trash",
"rm",
ActionKind::Trash,
Vec::new(),
vec![
"-r".to_string(),
"-rf".to_string(),
"-fr".to_string(),
"--recursive".to_string(),
],
Some(
"omamori moved the recursive rm targets to Trash instead of deleting them"
.to_string(),
),
)
.with_builtin(true),
RuleConfig::new(
"git-reset-hard-stash",
"git",
ActionKind::StashThenExec,
vec!["reset".to_string(), "--hard".to_string()],
Vec::new(),
Some("omamori stashed changes before running git reset --hard".to_string()),
)
.with_builtin(true),
RuleConfig::new(
"git-push-force-block",
"git",
ActionKind::Block,
vec!["push".to_string()],
vec!["--force".to_string(), "-f".to_string()],
Some("omamori blocked a force push".to_string()),
)
.with_builtin(true),
RuleConfig::new(
"git-clean-force-block",
"git",
ActionKind::Block,
vec!["clean".to_string()],
vec!["-f".to_string(), "--force".to_string()],
Some("omamori blocked git clean because it would remove untracked files".to_string()),
)
.with_builtin(true),
RuleConfig::new(
"chmod-777-block",
"chmod",
ActionKind::Block,
Vec::new(),
vec!["777".to_string()],
Some("omamori blocked chmod 777".to_string()),
)
.with_builtin(true),
RuleConfig::new(
"find-delete-block",
"find",
ActionKind::Block,
Vec::new(),
vec!["-delete".to_string(), "--delete".to_string()],
Some("omamori blocked find with -delete flag".to_string()),
)
.with_builtin(true),
RuleConfig::new(
"rsync-delete-block",
"rsync",
ActionKind::Block,
Vec::new(),
vec![
"--delete".to_string(),
"--del".to_string(),
"--delete-before".to_string(),
"--delete-during".to_string(),
"--delete-after".to_string(),
"--delete-excluded".to_string(),
"--delete-delay".to_string(),
"--remove-source-files".to_string(),
],
Some("omamori blocked rsync with destructive flags".to_string()),
)
.with_builtin(true),
]
}
pub fn core_rule_names() -> Vec<&'static str> {
vec![
"rm-recursive-to-trash",
"git-reset-hard-stash",
"git-push-force-block",
"git-clean-force-block",
"chmod-777-block",
"find-delete-block",
"rsync-delete-block",
]
}
#[derive(Debug)]
pub struct WriteConfigResult {
pub path: PathBuf,
pub created: bool,
}
pub fn config_template() -> String {
let defaults = default_rules();
let mut out = String::new();
out.push_str(
"# omamori config — only write the rules you want to change.\n\
# Built-in rules are inherited automatically.\n\
# To disable a rule: set enabled = false\n\
# To change an action: override the action field\n\
#\n\
# Docs: https://github.com/yottayoshida/omamori\n\
#\n",
);
for rule in &defaults {
out.push_str("\n# [[rules]]\n");
out.push_str(&format!("# name = \"{}\"\n", rule.name));
out.push_str(&format!("# command = \"{}\"\n", rule.command));
out.push_str(&format!("# action = \"{}\"\n", rule.action.as_str()));
if !rule.match_all.is_empty() {
out.push_str(&format!("# match_all = {:?}\n", rule.match_all));
}
if !rule.match_any.is_empty() {
out.push_str(&format!("# match_any = {:?}\n", rule.match_any));
}
out.push_str("# # enabled = false # uncomment to disable this rule\n");
}
out.push_str(
"\n# --- Custom rule example ---\n\
# [[rules]]\n\
# name = \"rm-to-backup\"\n\
# command = \"rm\"\n\
# action = \"move-to\"\n\
# destination = \"/tmp/omamori-quarantine/\"\n\
# match_any = [\"-r\", \"-rf\", \"-fr\", \"--recursive\"]\n\
# message = \"omamori moved targets to backup instead of deleting\"\n",
);
out
}
pub fn write_default_config(path: &Path, force: bool) -> Result<WriteConfigResult, AppError> {
let dir = path
.parent()
.ok_or_else(|| AppError::Config(format!("invalid config path: {}", path.display())))?;
if !dir.exists() {
fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
}
} else {
reject_symlink(dir, "config directory")?;
}
if path.exists() || path.symlink_metadata().is_ok() {
reject_symlink(path, "config path")?;
if !force {
return Err(AppError::Config(format!(
"config already exists at {}\n Use `omamori init --force` to overwrite.",
path.display()
)));
}
}
let content = config_template();
if force && path.exists() {
let temp_path = path.with_extension("toml.tmp");
if temp_path.symlink_metadata().is_ok() {
reject_symlink(&temp_path, "temp config path")?;
let _ = fs::remove_file(&temp_path);
}
write_new_config(&temp_path, &content)?;
let file = fs::File::open(&temp_path)?;
file.sync_all()?;
drop(file);
fs::rename(&temp_path, path)?;
if let Ok(dir_file) = fs::File::open(dir) {
let _ = dir_file.sync_all();
}
} else {
write_new_config(path, &content)?;
}
Ok(WriteConfigResult {
path: path.to_path_buf(),
created: true,
})
}
pub fn reject_symlink_public(path: &Path, label: &str) -> Result<(), AppError> {
reject_symlink(path, label)
}
fn reject_symlink(path: &Path, label: &str) -> Result<(), AppError> {
if let Ok(meta) = fs::symlink_metadata(path)
&& meta.file_type().is_symlink()
{
return Err(AppError::Config(format!(
"{label} `{}` is a symlink; refusing to write for security",
path.display()
)));
}
Ok(())
}
#[cfg(unix)]
fn write_new_config(path: &Path, content: &str) -> Result<(), AppError> {
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
Ok(())
}
#[cfg(not(unix))]
fn write_new_config(path: &Path, content: &str) -> Result<(), AppError> {
fs::write(path, content)?;
Ok(())
}
#[cfg(unix)]
fn permissions_are_safe(path: &Path) -> Result<bool, AppError> {
use std::os::unix::fs::MetadataExt;
let metadata = fs::metadata(path)?;
Ok(metadata.mode() & 0o777 == 0o600)
}
#[cfg(not(unix))]
fn permissions_are_safe(_path: &Path) -> Result<bool, AppError> {
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn no_overrides() -> HashMap<String, bool> {
HashMap::new()
}
#[test]
fn merge_core_rule_ignores_disable_without_override() {
let user_rules = vec![UserRule {
name: "git-push-force-block".to_string(),
command: None,
action: None,
enabled: Some(false),
destination: None,
match_all: None,
match_any: None,
message: None,
}];
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &user_rules, &no_overrides(), &mut warnings);
let rule = merged
.iter()
.find(|r| r.name == "git-push-force-block")
.unwrap();
assert!(rule.enabled); assert_eq!(rule.action, ActionKind::Block);
assert!(
warnings.iter().any(
|w: &String| w.contains("core safety rule") && w.contains("cannot be disabled")
),
"expected immutability warning, got: {warnings:?}"
);
}
#[test]
fn merge_core_rule_disabled_via_overrides_section() {
let user_rules = vec![UserRule {
name: "git-push-force-block".to_string(),
command: None,
action: None,
enabled: Some(false),
destination: None,
match_all: None,
match_any: None,
message: None,
}];
let mut overrides = HashMap::new();
overrides.insert("git-push-force-block".to_string(), false);
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &user_rules, &overrides, &mut warnings);
let rule = merged
.iter()
.find(|r| r.name == "git-push-force-block")
.unwrap();
assert!(!rule.enabled); }
#[test]
fn merge_adds_new_rule() {
let user_rules = vec![UserRule {
name: "custom-rm".to_string(),
command: Some("rm".to_string()),
action: Some(ActionKind::MoveTo),
enabled: None,
destination: Some("/tmp/backup".to_string()),
match_all: None,
match_any: Some(vec!["-rf".to_string()]),
message: Some("custom".to_string()),
}];
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &user_rules, &no_overrides(), &mut warnings);
let rule = merged.iter().find(|r| r.name == "custom-rm").unwrap();
assert_eq!(rule.action, ActionKind::MoveTo);
assert_eq!(rule.destination.as_deref(), Some("/tmp/backup"));
assert!(rule.enabled);
}
#[test]
fn merge_new_rule_without_command_warns() {
let user_rules = vec![UserRule {
name: "bad-rule".to_string(),
command: None,
action: None,
enabled: Some(false),
destination: None,
match_all: None,
match_any: None,
message: None,
}];
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &user_rules, &no_overrides(), &mut warnings);
assert!(merged.iter().all(|r| r.name != "bad-rule"));
assert!(
warnings
.iter()
.any(|w: &String| w.contains("missing `command` or `action`"))
);
}
#[test]
fn merge_duplicate_name_warns() {
let user_rules = vec![
UserRule {
name: "git-push-force-block".to_string(),
command: None,
action: None,
enabled: Some(false),
destination: None,
match_all: None,
match_any: None,
message: None,
},
UserRule {
name: "git-push-force-block".to_string(),
command: None,
action: None,
enabled: Some(true),
destination: None,
match_all: None,
match_any: None,
message: None,
},
];
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &user_rules, &no_overrides(), &mut warnings);
let rule = merged
.iter()
.find(|r| r.name == "git-push-force-block")
.unwrap();
assert!(rule.enabled);
assert!(
warnings
.iter()
.any(|w: &String| w.contains("duplicate rule name"))
);
}
#[test]
fn merge_preserves_all_defaults_when_no_user_rules() {
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &[], &no_overrides(), &mut warnings);
assert_eq!(merged.len(), default_rules().len());
assert!(warnings.is_empty());
}
#[test]
fn merge_core_rule_action_downgrade_rejected() {
let user_rules = vec![UserRule {
name: "rm-recursive-to-trash".to_string(),
command: None,
action: Some(ActionKind::LogOnly),
enabled: None,
destination: None,
match_all: None,
match_any: None,
message: None,
}];
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &user_rules, &no_overrides(), &mut warnings);
let rule = merged
.iter()
.find(|r| r.name == "rm-recursive-to-trash")
.unwrap();
assert_eq!(rule.action, ActionKind::Trash); assert!(
warnings
.iter()
.any(|w: &String| w.contains("action downgrade")),
"expected downgrade warning, got: {warnings:?}"
);
}
#[test]
fn merge_core_rule_action_upgrade_allowed() {
let user_rules = vec![UserRule {
name: "rm-recursive-to-trash".to_string(),
command: None,
action: Some(ActionKind::Block),
enabled: None,
destination: None,
match_all: None,
match_any: None,
message: None,
}];
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &user_rules, &no_overrides(), &mut warnings);
let rule = merged
.iter()
.find(|r| r.name == "rm-recursive-to-trash")
.unwrap();
assert_eq!(rule.action, ActionKind::Block); }
#[test]
fn merge_core_rule_message_override_allowed() {
let user_rules = vec![UserRule {
name: "git-push-force-block".to_string(),
command: None,
action: None,
enabled: None,
destination: None,
match_all: None,
match_any: None,
message: Some("my custom message".to_string()),
}];
let mut warnings = Vec::new();
let merged = merge_rules(default_rules(), &user_rules, &no_overrides(), &mut warnings);
let rule = merged
.iter()
.find(|r| r.name == "git-push-force-block")
.unwrap();
assert_eq!(rule.message.as_deref(), Some("my custom message"));
assert!(
warnings.is_empty(),
"no warnings for message override: {warnings:?}"
);
}
#[test]
fn validate_move_to_without_destination_disables_rule() {
let mut rules = vec![RuleConfig::new(
"bad",
"rm",
ActionKind::MoveTo,
Vec::new(),
Vec::new(),
None,
)];
let mut warnings = Vec::new();
validate_rules(&mut rules, &mut warnings);
assert!(warnings.iter().any(|w| w.contains("no `destination`")));
assert!(!rules[0].enabled); }
#[test]
fn validate_destination_on_non_move_to_warns() {
let mut rules = vec![
RuleConfig::new(
"weird",
"rm",
ActionKind::Trash,
Vec::new(),
Vec::new(),
None,
)
.with_destination("/tmp/x".to_string()),
];
let mut warnings = Vec::new();
validate_rules(&mut rules, &mut warnings);
assert!(
warnings
.iter()
.any(|w| w.contains("destination is ignored"))
);
assert!(rules[0].enabled); }
#[test]
fn validate_relative_destination_disables_rule() {
let mut rules = vec![
RuleConfig::new(
"rel",
"rm",
ActionKind::MoveTo,
Vec::new(),
Vec::new(),
None,
)
.with_destination("relative/path".to_string()),
];
let mut warnings = Vec::new();
validate_rules(&mut rules, &mut warnings);
assert!(warnings.iter().any(|w| w.contains("not an absolute path")));
assert!(!rules[0].enabled); }
#[test]
fn default_rules_all_enabled() {
for rule in default_rules() {
assert!(
rule.enabled,
"rule {} should be enabled by default",
rule.name
);
}
}
#[test]
fn user_config_without_detectors_uses_defaults() {
let toml_str = r#"
[[rules]]
name = "git-push-force-block"
enabled = false
"#;
let user: UserConfig = toml::from_str(toml_str).unwrap();
assert!(user.detectors.is_none());
let mut warnings = Vec::new();
let config = build_merged_config(user, &mut warnings);
assert_eq!(config.detectors.len(), 6); }
#[test]
fn user_config_with_custom_detectors_replaces() {
let toml_str = r#"
[[detectors]]
name = "my-tool"
type = "env_var"
env_key = "MY_TOOL"
env_value = "1"
"#;
let user: UserConfig = toml::from_str(toml_str).unwrap();
assert!(user.detectors.is_some());
let mut warnings = Vec::new();
let config = build_merged_config(user, &mut warnings);
assert_eq!(config.detectors.len(), 1);
assert_eq!(config.detectors[0].name, "my-tool");
}
#[test]
fn enabled_field_defaults_to_true_in_toml() {
let toml_str = r#"
[[rules]]
name = "test-rule"
command = "rm"
action = "block"
"#;
#[derive(Deserialize)]
struct Wrapper {
rules: Vec<RuleConfig>,
}
let parsed: Wrapper = toml::from_str(toml_str).unwrap();
assert!(parsed.rules[0].enabled);
}
#[test]
fn config_default_toml_rules_match_default_rules() {
let toml_str = include_str!("../config.default.toml");
let parsed: Config = toml::from_str(toml_str).unwrap();
let toml_names: HashSet<&str> = parsed.rules.iter().map(|r| r.name.as_str()).collect();
let code_rules = default_rules();
let code_names: HashSet<&str> = code_rules.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
toml_names,
code_names,
"config.default.toml rules and default_rules() are out of sync.\n\
In TOML only: {:?}\n\
In code only: {:?}",
toml_names.difference(&code_names).collect::<Vec<_>>(),
code_names.difference(&toml_names).collect::<Vec<_>>(),
);
}
#[test]
fn config_default_toml_detectors_match_default_detectors() {
let toml_str = include_str!("../config.default.toml");
let parsed: Config = toml::from_str(toml_str).unwrap();
let toml_names: HashSet<&str> = parsed.detectors.iter().map(|d| d.name.as_str()).collect();
let code_detectors = default_detectors();
let code_names: HashSet<&str> = code_detectors.iter().map(|d| d.name.as_str()).collect();
assert_eq!(
toml_names,
code_names,
"config.default.toml detectors and default_detectors() are out of sync.\n\
In TOML only: {:?}\n\
In code only: {:?}",
toml_names.difference(&code_names).collect::<Vec<_>>(),
code_names.difference(&toml_names).collect::<Vec<_>>(),
);
}
#[test]
fn write_default_config_creates_with_correct_permissions() {
let dir = std::env::temp_dir().join(format!("omamori-cfg-g05-1-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let path = dir.join("config.toml");
let result = write_default_config(&path, false);
assert!(result.is_ok());
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let meta = fs::metadata(&path).unwrap();
assert_eq!(meta.mode() & 0o777, 0o600, "file should be mode 600");
let dir_meta = fs::metadata(&dir).unwrap();
assert_eq!(dir_meta.mode() & 0o777, 0o700, "dir should be mode 700");
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_default_config_rejects_symlink_target() {
let dir = std::env::temp_dir().join(format!("omamori-cfg-g05-2-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
#[cfg(unix)]
{
let real_file = dir.join("real.toml");
fs::write(&real_file, "real").unwrap();
let link_path = dir.join("config.toml");
std::os::unix::fs::symlink(&real_file, &link_path).unwrap();
let result = write_default_config(&link_path, false);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("symlink"));
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_default_config_force_atomic_write() {
let dir = std::env::temp_dir().join(format!("omamori-cfg-g05-3-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let path = dir.join("config.toml");
write_default_config(&path, false).unwrap();
let content1 = fs::read_to_string(&path).unwrap();
let result = write_default_config(&path, true);
assert!(result.is_ok());
let content2 = fs::read_to_string(&path).unwrap();
assert_eq!(content1, content2, "content should be the same template");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_default_config_no_force_errors_on_existing() {
let dir = std::env::temp_dir().join(format!("omamori-cfg-g05-4-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let path = dir.join("config.toml");
write_default_config(&path, false).unwrap();
let result = write_default_config(&path, false);
assert!(result.is_err());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_config_rejects_insecure_permissions() {
let dir = std::env::temp_dir().join(format!("omamori-cfg-g06-1-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
fs::write(&path, "# test config\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
let result = load_config(Some(&path)).unwrap();
assert!(
result.warnings.iter().any(|w| w.contains("permissions")),
"should warn about insecure permissions"
);
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn load_config_accepts_secure_permissions() {
let dir = std::env::temp_dir().join(format!("omamori-cfg-g06-2-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
fs::write(&path, "# valid config\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
let result = load_config(Some(&path)).unwrap();
assert!(
!result.warnings.iter().any(|w| w.contains("permissions")),
"should not warn about secure permissions"
);
}
let _ = fs::remove_dir_all(&dir);
}
}