use super::rules::DetectionRule;
use serde::{Deserialize, Serialize};
use std::path::{Component, Path};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DiscoverConfig {
#[serde(default)]
pub rules: Option<String>,
#[serde(default)]
pub ignore_dirs: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CustomRulesFile {
#[serde(default)]
pub rules: Vec<DetectionRule>,
}
pub fn load_effective_rules(
project_dir: &Path,
cfg: Option<&DiscoverConfig>,
) -> (Vec<DetectionRule>, Vec<String>) {
let mut combined: Vec<DetectionRule> = super::default_rules().to_vec();
let mut advisories: Vec<String> = Vec::new();
let Some(cfg) = cfg else {
return (combined, advisories);
};
let Some(rules_path) = cfg.rules.as_ref() else {
return (combined, advisories);
};
let candidate = Path::new(rules_path);
if candidate.is_absolute()
|| candidate
.components()
.any(|c| matches!(c, Component::ParentDir))
{
advisories.push(format!(
"[discover] rules file `{rules_path}` refused: absolute paths and `..` traversal \
are not allowed (built-in rules only)"
));
return (combined, advisories);
}
let path = project_dir.join(rules_path);
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
advisories.push(format!(
"[discover] rules file `{}` could not be read: {e} (built-in rules only)",
path.display()
));
return (combined, advisories);
}
};
match toml::from_str::<CustomRulesFile>(&content) {
Ok(parsed) => {
combined.extend(parsed.rules);
}
Err(_) => {
advisories.push(format!(
"[discover] rules file `{}` failed to parse (built-in rules only)",
path.display()
));
}
}
(combined, advisories)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn no_config_yields_only_default_rules() {
let tmp = tempdir().unwrap();
let (rules, adv) = load_effective_rules(tmp.path(), None);
assert!(!rules.is_empty(), "default_rules must be non-empty");
assert_eq!(adv, Vec::<String>::new());
}
#[test]
fn custom_rules_file_appends_entries() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("custom.toml"),
r#"
[[rules]]
name = "custom-tool"
category = "dev"
[[rules.detect]]
file = ".custom-marker"
"#,
)
.unwrap();
let cfg = DiscoverConfig {
rules: Some("custom.toml".into()),
ignore_dirs: vec![],
};
let (rules, adv) = load_effective_rules(tmp.path(), Some(&cfg));
assert_eq!(adv, Vec::<String>::new());
assert!(rules.iter().any(|r| r.name == "custom-tool"));
assert!(rules.iter().any(|r| r.name == "rust"));
}
#[test]
fn missing_file_emits_advisory_not_error() {
let tmp = tempdir().unwrap();
let cfg = DiscoverConfig {
rules: Some("nope.toml".into()),
ignore_dirs: vec![],
};
let (rules, adv) = load_effective_rules(tmp.path(), Some(&cfg));
assert!(!rules.is_empty());
assert_eq!(adv.len(), 1);
assert!(adv[0].contains("could not be read"));
}
#[test]
fn malformed_file_emits_advisory_not_error() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("bad.toml"), "not [ valid toml @").unwrap();
let cfg = DiscoverConfig {
rules: Some("bad.toml".into()),
ignore_dirs: vec![],
};
let (_rules, adv) = load_effective_rules(tmp.path(), Some(&cfg));
assert_eq!(adv.len(), 1);
assert!(adv[0].contains("failed to parse"));
}
#[test]
fn absolute_rules_path_is_refused() {
let tmp = tempdir().unwrap();
let abs_path = if cfg!(windows) {
r"C:\Windows\System32\drivers\etc\hosts"
} else {
"/etc/hostname"
};
let cfg = DiscoverConfig {
rules: Some(abs_path.into()),
ignore_dirs: vec![],
};
let (rules, adv) = load_effective_rules(tmp.path(), Some(&cfg));
assert!(!rules.is_empty(), "built-ins still loaded");
assert_eq!(adv.len(), 1);
assert!(adv[0].contains("refused"), "got: {}", adv[0]);
}
#[test]
fn parent_traversal_rules_path_is_refused() {
let tmp = tempdir().unwrap();
let cfg = DiscoverConfig {
rules: Some("../../etc/passwd".into()),
ignore_dirs: vec![],
};
let (rules, adv) = load_effective_rules(tmp.path(), Some(&cfg));
assert!(!rules.is_empty());
assert_eq!(adv.len(), 1);
assert!(adv[0].contains("refused"));
}
#[test]
fn malformed_file_advisory_redacts_target_bytes() {
let tmp = tempdir().unwrap();
let secret = "ROOT_PASSWORD_HASH=$6$secret\nhostile content\n";
fs::write(tmp.path().join("bad.toml"), secret).unwrap();
let cfg = DiscoverConfig {
rules: Some("bad.toml".into()),
ignore_dirs: vec![],
};
let (_rules, adv) = load_effective_rules(tmp.path(), Some(&cfg));
assert_eq!(adv.len(), 1);
assert!(
!adv[0].contains("ROOT_PASSWORD_HASH") && !adv[0].contains("hostile"),
"advisory must not echo target file bytes; got: {}",
adv[0]
);
}
}