use std::path::{Path, PathBuf};
use padlock_core::findings::Severity;
const CONFIG_FILENAME: &str = ".padlock.toml";
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub min_severity: Severity,
pub fail_below: u8,
pub ignore: Vec<String>,
pub arch_override: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
min_severity: Severity::Low,
fail_below: 0,
ignore: Vec::new(),
arch_override: None,
}
}
}
impl Config {
pub fn load_from(start_dir: &Path) -> Self {
find_config_file(start_dir)
.and_then(|p| Self::load_file(&p))
.unwrap_or_default()
}
#[allow(dead_code)]
pub fn for_path(target: &Path) -> Self {
let dir = if target.is_dir() {
target.to_path_buf()
} else {
target.parent().unwrap_or(target).to_path_buf()
};
Self::load_from(&dir)
}
fn load_file(path: &Path) -> Option<Self> {
let text = std::fs::read_to_string(path).ok()?;
let doc: toml::Value = toml::from_str(&text)
.map_err(|e| eprintln!("padlock: warning: failed to parse {}: {e}", path.display()))
.ok()?;
let padlock = doc.get("padlock");
let arch = doc.get("arch");
let min_severity = padlock
.and_then(|p| p.get("min_severity"))
.and_then(|v| v.as_str())
.and_then(parse_severity)
.unwrap_or(Severity::Low);
let fail_below = padlock
.and_then(|p| p.get("fail_below"))
.and_then(|v| v.as_integer())
.map(|n| n.clamp(0, 100) as u8)
.unwrap_or(0);
let ignore = padlock
.and_then(|p| p.get("ignore"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
let arch_override = arch
.and_then(|a| a.get("override"))
.and_then(|v| v.as_str())
.map(str::to_owned);
Some(Self {
min_severity,
fail_below,
ignore,
arch_override,
})
}
pub fn is_ignored(&self, struct_name: &str) -> bool {
self.ignore.iter().any(|n| n == struct_name)
}
pub fn should_report(&self, severity: &Severity) -> bool {
severity_rank(severity) >= severity_rank(&self.min_severity)
}
}
fn find_config_file(start: &Path) -> Option<PathBuf> {
let mut dir = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
loop {
let candidate = dir.join(CONFIG_FILENAME);
if candidate.exists() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
fn parse_severity(s: &str) -> Option<Severity> {
match s.to_ascii_lowercase().as_str() {
"high" => Some(Severity::High),
"medium" | "med" => Some(Severity::Medium),
"low" => Some(Severity::Low),
_ => {
eprintln!("padlock: warning: unknown min_severity '{s}', using 'low'");
None
}
}
}
fn severity_rank(s: &Severity) -> u8 {
match s {
Severity::Low => 0,
Severity::Medium => 1,
Severity::High => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_config(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn default_config_is_permissive() {
let cfg = Config::default();
assert_eq!(cfg.min_severity, Severity::Low);
assert_eq!(cfg.fail_below, 0);
assert!(cfg.ignore.is_empty());
}
#[test]
fn parse_full_config() {
let content = r#"
[padlock]
min_severity = "medium"
fail_below = 60
ignore = ["GeneratedFoo", "FfiLayout"]
[arch]
override = "aarch64"
"#;
let f = write_config(content);
let cfg = Config::load_file(f.path()).unwrap();
assert_eq!(cfg.min_severity, Severity::Medium);
assert_eq!(cfg.fail_below, 60);
assert_eq!(cfg.ignore, vec!["GeneratedFoo", "FfiLayout"]);
assert_eq!(cfg.arch_override.as_deref(), Some("aarch64"));
}
#[test]
fn parse_high_severity() {
let content = "[padlock]\nmin_severity = \"high\"\n";
let f = write_config(content);
let cfg = Config::load_file(f.path()).unwrap();
assert_eq!(cfg.min_severity, Severity::High);
}
#[test]
fn parse_low_severity() {
let content = "[padlock]\nmin_severity = \"low\"\n";
let f = write_config(content);
let cfg = Config::load_file(f.path()).unwrap();
assert_eq!(cfg.min_severity, Severity::Low);
}
#[test]
fn missing_keys_use_defaults() {
let content = "[padlock]\n";
let f = write_config(content);
let cfg = Config::load_file(f.path()).unwrap();
assert_eq!(cfg.min_severity, Severity::Low);
assert_eq!(cfg.fail_below, 0);
assert!(cfg.ignore.is_empty());
}
#[test]
fn fail_below_clamped_to_100() {
let content = "[padlock]\nfail_below = 200\n";
let f = write_config(content);
let cfg = Config::load_file(f.path()).unwrap();
assert_eq!(cfg.fail_below, 100);
}
#[test]
fn is_ignored_matches_exact_name() {
let cfg = Config {
ignore: vec!["FfiLayout".into()],
..Config::default()
};
assert!(cfg.is_ignored("FfiLayout"));
assert!(!cfg.is_ignored("FfiLayoutExtra"));
}
#[test]
fn should_report_high_always_when_min_low() {
let cfg = Config::default(); assert!(cfg.should_report(&Severity::High));
assert!(cfg.should_report(&Severity::Medium));
assert!(cfg.should_report(&Severity::Low));
}
#[test]
fn should_report_suppresses_low_when_min_medium() {
let cfg = Config {
min_severity: Severity::Medium,
..Config::default()
};
assert!(cfg.should_report(&Severity::High));
assert!(cfg.should_report(&Severity::Medium));
assert!(!cfg.should_report(&Severity::Low));
}
#[test]
fn should_report_only_high_when_min_high() {
let cfg = Config {
min_severity: Severity::High,
..Config::default()
};
assert!(cfg.should_report(&Severity::High));
assert!(!cfg.should_report(&Severity::Medium));
assert!(!cfg.should_report(&Severity::Low));
}
#[test]
fn find_config_file_returns_none_for_nonexistent_dir() {
let result = find_config_file(Path::new("/tmp/__padlock_no_such_dir__"));
assert!(result.is_none());
}
#[test]
fn load_from_nonexistent_dir_returns_default() {
let cfg = Config::load_from(Path::new("/tmp/__padlock_no_such_dir__"));
assert_eq!(cfg, Config::default());
}
}