1use crate::authorities::{Category, CustomAuthority, Risk};
32use crate::detector::Finding;
33use serde::Deserialize;
34use std::path::Path;
35
36const CONFIG_FILE: &str = ".capsec.toml";
37
38#[derive(Debug, Deserialize, Default)]
43pub struct Config {
44 #[serde(default)]
45 pub deny: DenyConfig,
46 #[serde(default)]
47 pub analysis: AnalysisConfig,
48 #[serde(default)]
49 pub authority: Vec<AuthorityEntry>,
50 #[serde(default)]
51 pub allow: Vec<AllowEntry>,
52}
53
54#[derive(Debug, Deserialize, Default)]
62pub struct DenyConfig {
63 #[serde(default)]
64 pub categories: Vec<String>,
65}
66
67#[derive(Debug, Deserialize, Default)]
69pub struct AnalysisConfig {
70 #[serde(default)]
71 pub exclude: Vec<String>,
72}
73
74#[derive(Debug, Deserialize)]
76pub struct AuthorityEntry {
77 pub path: Vec<String>,
78 pub category: String,
79 #[serde(default = "default_risk")]
80 pub risk: String,
81 #[serde(default)]
82 pub description: String,
83}
84
85fn default_risk() -> String {
86 "medium".to_string()
87}
88
89#[derive(Debug, Deserialize)]
94pub struct AllowEntry {
95 #[serde(default)]
96 pub crate_name: Option<String>,
97 #[serde(default, rename = "crate")]
99 pub crate_key: Option<String>,
100 #[serde(default)]
101 pub function: Option<String>,
102}
103
104impl AllowEntry {
105 pub fn effective_crate(&self) -> Option<&str> {
107 self.crate_name.as_deref().or(self.crate_key.as_deref())
108 }
109}
110
111const VALID_DENY_CATEGORIES: &[&str] = &["all", "fs", "net", "env", "process", "ffi"];
112
113impl DenyConfig {
114 pub fn normalized_categories(&self) -> Vec<String> {
116 self.categories
117 .iter()
118 .filter_map(|cat| {
119 let lower = cat.to_lowercase();
120 if VALID_DENY_CATEGORIES.contains(&lower.as_str()) {
121 Some(lower)
122 } else {
123 eprintln!("Warning: unknown deny category '{cat}', ignoring (valid: all, fs, net, env, process, ffi)");
124 None
125 }
126 })
127 .collect()
128 }
129}
130
131pub fn load_config(
136 workspace_root: &Path,
137 cap: &impl capsec_core::has::Has<capsec_core::permission::FsRead>,
138) -> Result<Config, String> {
139 let config_path = workspace_root.join(CONFIG_FILE);
140
141 if !config_path.exists() {
142 return Ok(Config::default());
143 }
144
145 let content = capsec_std::fs::read_to_string(&config_path, cap)
146 .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
147
148 let config: Config = toml::from_str(&content)
149 .map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
150
151 Ok(config)
152}
153
154pub fn custom_authorities(config: &Config) -> Vec<CustomAuthority> {
157 config
158 .authority
159 .iter()
160 .map(|entry| CustomAuthority {
161 path: entry.path.clone(),
162 category: match entry.category.to_lowercase().as_str() {
163 "fs" => Category::Fs,
164 "net" => Category::Net,
165 "env" => Category::Env,
166 "process" | "proc" => Category::Process,
167 "ffi" => Category::Ffi,
168 _ => Category::Ffi,
169 },
170 risk: Risk::parse(&entry.risk),
171 description: entry.description.clone(),
172 })
173 .collect()
174}
175
176pub fn should_allow(finding: &Finding, config: &Config) -> bool {
178 config.allow.iter().any(|rule| {
179 let crate_match = rule
180 .effective_crate()
181 .is_none_or(|c| c == finding.crate_name);
182 let func_match = rule
183 .function
184 .as_ref()
185 .is_none_or(|f| f == &finding.function);
186 crate_match && func_match && (rule.effective_crate().is_some() || rule.function.is_some())
187 })
188}
189
190pub fn should_exclude(path: &Path, excludes: &[String]) -> bool {
195 let path_str = path.display().to_string();
196 excludes.iter().any(|pattern| {
197 match globset::Glob::new(pattern) {
198 Ok(glob) => match glob.compile_matcher().is_match(&path_str) {
199 true => true,
200 false => {
201 path.file_name()
203 .and_then(|n| n.to_str())
204 .is_some_and(|name| glob.compile_matcher().is_match(name))
205 }
206 },
207 Err(_) => path_str.contains(pattern),
208 }
209 })
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn parse_config() {
218 let toml = r#"
219 [analysis]
220 exclude = ["tests/**", "benches/**"]
221
222 [[authority]]
223 path = ["my_crate", "secrets", "fetch"]
224 category = "net"
225 risk = "critical"
226 description = "Fetches secrets"
227
228 [[allow]]
229 crate = "tracing"
230 reason = "Logging framework"
231 "#;
232
233 let config: Config = toml::from_str(toml).unwrap();
234 assert_eq!(config.analysis.exclude.len(), 2);
235 assert_eq!(config.authority.len(), 1);
236 assert_eq!(
237 config.authority[0].path,
238 vec!["my_crate", "secrets", "fetch"]
239 );
240 assert_eq!(config.allow.len(), 1);
241 assert_eq!(config.allow[0].effective_crate(), Some("tracing"));
242 }
243
244 #[test]
245 fn missing_config_returns_default() {
246 let root = capsec_core::root::test_root();
247 let cap = root.grant::<capsec_core::permission::FsRead>();
248 let config = load_config(Path::new("/nonexistent/path"), &cap).unwrap();
249 assert!(config.authority.is_empty());
250 assert!(config.allow.is_empty());
251 }
252
253 #[test]
254 fn parse_deny_config() {
255 let toml = r#"
256 [deny]
257 categories = ["all"]
258 "#;
259 let config: Config = toml::from_str(toml).unwrap();
260 assert_eq!(config.deny.categories, vec!["all"]);
261 }
262
263 #[test]
264 fn parse_deny_selective_categories() {
265 let toml = r#"
266 [deny]
267 categories = ["fs", "net"]
268 "#;
269 let config: Config = toml::from_str(toml).unwrap();
270 assert_eq!(config.deny.categories, vec!["fs", "net"]);
271 }
272
273 #[test]
274 fn missing_deny_section_defaults_to_empty() {
275 let toml = r#"
276 [[allow]]
277 crate = "tracing"
278 "#;
279 let config: Config = toml::from_str(toml).unwrap();
280 assert!(config.deny.categories.is_empty());
281 }
282
283 #[test]
284 fn normalized_categories_lowercases_and_filters() {
285 let deny = DenyConfig {
286 categories: vec!["FS".to_string(), "bogus".to_string(), "net".to_string()],
287 };
288 let normalized = deny.normalized_categories();
289 assert_eq!(normalized, vec!["fs", "net"]);
290 }
291
292 #[test]
293 fn exclude_pattern_matching() {
294 assert!(should_exclude(
295 Path::new("tests/integration.rs"),
296 &["tests/**".to_string()]
297 ));
298 assert!(!should_exclude(
299 Path::new("src/main.rs"),
300 &["tests/**".to_string()]
301 ));
302 }
303}