1use crate::authorities::{Category, CustomAuthority, Risk};
28use crate::detector::Finding;
29use serde::Deserialize;
30use std::path::Path;
31
32const CONFIG_FILE: &str = ".capsec.toml";
33
34#[derive(Debug, Deserialize, Default)]
39pub struct Config {
40 #[serde(default)]
41 pub analysis: AnalysisConfig,
42 #[serde(default)]
43 pub authority: Vec<AuthorityEntry>,
44 #[serde(default)]
45 pub allow: Vec<AllowEntry>,
46}
47
48#[derive(Debug, Deserialize, Default)]
50pub struct AnalysisConfig {
51 #[serde(default)]
52 pub exclude: Vec<String>,
53}
54
55#[derive(Debug, Deserialize)]
57pub struct AuthorityEntry {
58 pub path: Vec<String>,
59 pub category: String,
60 #[serde(default = "default_risk")]
61 pub risk: String,
62 #[serde(default)]
63 pub description: String,
64}
65
66fn default_risk() -> String {
67 "medium".to_string()
68}
69
70#[derive(Debug, Deserialize)]
75pub struct AllowEntry {
76 #[serde(default)]
77 pub crate_name: Option<String>,
78 #[serde(default, rename = "crate")]
80 pub crate_key: Option<String>,
81 #[serde(default)]
82 pub function: Option<String>,
83}
84
85impl AllowEntry {
86 pub fn effective_crate(&self) -> Option<&str> {
88 self.crate_name.as_deref().or(self.crate_key.as_deref())
89 }
90}
91
92pub fn load_config(
97 workspace_root: &Path,
98 cap: &impl capsec_core::has::Has<capsec_core::permission::FsRead>,
99) -> Result<Config, String> {
100 let config_path = workspace_root.join(CONFIG_FILE);
101
102 if !config_path.exists() {
103 return Ok(Config::default());
104 }
105
106 let content = capsec_std::fs::read_to_string(&config_path, cap)
107 .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
108
109 let config: Config = toml::from_str(&content)
110 .map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
111
112 Ok(config)
113}
114
115pub fn custom_authorities(config: &Config) -> Vec<CustomAuthority> {
118 config
119 .authority
120 .iter()
121 .map(|entry| CustomAuthority {
122 path: entry.path.clone(),
123 category: match entry.category.to_lowercase().as_str() {
124 "fs" => Category::Fs,
125 "net" => Category::Net,
126 "env" => Category::Env,
127 "process" | "proc" => Category::Process,
128 "ffi" => Category::Ffi,
129 _ => Category::Ffi,
130 },
131 risk: Risk::parse(&entry.risk),
132 description: entry.description.clone(),
133 })
134 .collect()
135}
136
137pub fn should_allow(finding: &Finding, config: &Config) -> bool {
139 config.allow.iter().any(|rule| {
140 let crate_match = rule
141 .effective_crate()
142 .is_none_or(|c| c == finding.crate_name);
143 let func_match = rule
144 .function
145 .as_ref()
146 .is_none_or(|f| f == &finding.function);
147 crate_match && func_match && (rule.effective_crate().is_some() || rule.function.is_some())
148 })
149}
150
151pub fn should_exclude(path: &Path, excludes: &[String]) -> bool {
156 let path_str = path.display().to_string();
157 excludes.iter().any(|pattern| {
158 match globset::Glob::new(pattern) {
159 Ok(glob) => match glob.compile_matcher().is_match(&path_str) {
160 true => true,
161 false => {
162 path.file_name()
164 .and_then(|n| n.to_str())
165 .is_some_and(|name| glob.compile_matcher().is_match(name))
166 }
167 },
168 Err(_) => path_str.contains(pattern),
169 }
170 })
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn parse_config() {
179 let toml = r#"
180 [analysis]
181 exclude = ["tests/**", "benches/**"]
182
183 [[authority]]
184 path = ["my_crate", "secrets", "fetch"]
185 category = "net"
186 risk = "critical"
187 description = "Fetches secrets"
188
189 [[allow]]
190 crate = "tracing"
191 reason = "Logging framework"
192 "#;
193
194 let config: Config = toml::from_str(toml).unwrap();
195 assert_eq!(config.analysis.exclude.len(), 2);
196 assert_eq!(config.authority.len(), 1);
197 assert_eq!(
198 config.authority[0].path,
199 vec!["my_crate", "secrets", "fetch"]
200 );
201 assert_eq!(config.allow.len(), 1);
202 assert_eq!(config.allow[0].effective_crate(), Some("tracing"));
203 }
204
205 #[test]
206 fn missing_config_returns_default() {
207 let root = capsec_core::root::test_root();
208 let cap = root.grant::<capsec_core::permission::FsRead>();
209 let config = load_config(Path::new("/nonexistent/path"), &cap).unwrap();
210 assert!(config.authority.is_empty());
211 assert!(config.allow.is_empty());
212 }
213
214 #[test]
215 fn exclude_pattern_matching() {
216 assert!(should_exclude(
217 Path::new("tests/integration.rs"),
218 &["tests/**".to_string()]
219 ));
220 assert!(!should_exclude(
221 Path::new("src/main.rs"),
222 &["tests/**".to_string()]
223 ));
224 }
225}