1use crate::authorities::{Category, CustomAuthority, Risk};
32use crate::detector::Finding;
33use serde::{Deserialize, Serialize};
34use std::path::Path;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Classification {
44 Pure,
46 Resource,
48}
49
50const CONFIG_FILE: &str = ".capsec.toml";
51
52#[derive(Debug, Deserialize, Default)]
57pub struct Config {
58 #[serde(default)]
59 pub deny: DenyConfig,
60 #[serde(default)]
61 pub analysis: AnalysisConfig,
62 #[serde(default)]
63 pub authority: Vec<AuthorityEntry>,
64 #[serde(default)]
65 pub allow: Vec<AllowEntry>,
66 #[serde(default)]
67 pub classify: Vec<ClassifyEntry>,
68}
69
70#[derive(Debug, Deserialize)]
74pub struct ClassifyEntry {
75 #[serde(rename = "crate")]
77 pub crate_name: String,
78 pub classification: Classification,
80}
81
82#[derive(Debug, Deserialize, Default)]
90pub struct DenyConfig {
91 #[serde(default)]
92 pub categories: Vec<String>,
93}
94
95#[derive(Debug, Deserialize, Default)]
97pub struct AnalysisConfig {
98 #[serde(default)]
99 pub exclude: Vec<String>,
100}
101
102#[derive(Debug, Deserialize)]
104pub struct AuthorityEntry {
105 pub path: Vec<String>,
106 pub category: String,
107 #[serde(default = "default_risk")]
108 pub risk: String,
109 #[serde(default)]
110 pub description: String,
111}
112
113fn default_risk() -> String {
114 "medium".to_string()
115}
116
117#[derive(Debug, Deserialize)]
122pub struct AllowEntry {
123 #[serde(default)]
124 pub crate_name: Option<String>,
125 #[serde(default, rename = "crate")]
127 pub crate_key: Option<String>,
128 #[serde(default)]
129 pub function: Option<String>,
130}
131
132impl AllowEntry {
133 pub fn effective_crate(&self) -> Option<&str> {
135 self.crate_name.as_deref().or(self.crate_key.as_deref())
136 }
137}
138
139const VALID_DENY_CATEGORIES: &[&str] = &["all", "fs", "net", "env", "process", "ffi"];
140
141impl DenyConfig {
142 pub fn normalized_categories(&self) -> Vec<String> {
144 self.categories
145 .iter()
146 .filter_map(|cat| {
147 let lower = cat.to_lowercase();
148 if VALID_DENY_CATEGORIES.contains(&lower.as_str()) {
149 Some(lower)
150 } else {
151 eprintln!("Warning: unknown deny category '{cat}', ignoring (valid: all, fs, net, env, process, ffi)");
152 None
153 }
154 })
155 .collect()
156 }
157}
158
159pub fn load_config(
164 workspace_root: &Path,
165 cap: &impl capsec_core::has::Has<capsec_core::permission::FsRead>,
166) -> Result<Config, String> {
167 let config_path = workspace_root.join(CONFIG_FILE);
168
169 if !config_path.exists() {
170 return Ok(Config::default());
171 }
172
173 let content = capsec_std::fs::read_to_string(&config_path, cap)
174 .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
175
176 let config: Config = toml::from_str(&content)
177 .map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
178
179 Ok(config)
180}
181
182pub fn custom_authorities(config: &Config) -> Vec<CustomAuthority> {
185 config
186 .authority
187 .iter()
188 .map(|entry| CustomAuthority {
189 path: entry.path.clone(),
190 category: match entry.category.to_lowercase().as_str() {
191 "fs" => Category::Fs,
192 "net" => Category::Net,
193 "env" => Category::Env,
194 "process" | "proc" => Category::Process,
195 "ffi" => Category::Ffi,
196 _ => Category::Ffi,
197 },
198 risk: Risk::parse(&entry.risk),
199 description: entry.description.clone(),
200 })
201 .collect()
202}
203
204pub fn should_allow(finding: &Finding, config: &Config) -> bool {
206 config.allow.iter().any(|rule| {
207 let crate_match = rule
208 .effective_crate()
209 .is_none_or(|c| c == finding.crate_name);
210 let func_match = rule
211 .function
212 .as_ref()
213 .is_none_or(|f| f == &finding.function);
214 crate_match && func_match && (rule.effective_crate().is_some() || rule.function.is_some())
215 })
216}
217
218#[derive(Debug, Clone, Serialize)]
220pub struct ClassificationResult {
221 pub crate_name: String,
223 pub crate_version: String,
225 pub classification: Option<Classification>,
227 pub valid: bool,
229 pub violation_count: usize,
231}
232
233pub fn verify_classification(
239 classification: Option<Classification>,
240 findings: &[Finding],
241 crate_name: &str,
242 crate_version: &str,
243) -> ClassificationResult {
244 let violation_count = match classification {
245 Some(Classification::Pure) => findings
246 .iter()
247 .filter(|f| f.crate_name == crate_name && !f.is_build_script)
248 .count(),
249 _ => 0,
250 };
251
252 ClassificationResult {
253 crate_name: crate_name.to_string(),
254 crate_version: crate_version.to_string(),
255 classification,
256 valid: violation_count == 0,
257 violation_count,
258 }
259}
260
261pub fn resolve_classification(
266 crate_name: &str,
267 cargo_toml_classification: Option<Classification>,
268 config: &Config,
269) -> Option<Classification> {
270 for entry in &config.classify {
272 if entry.crate_name == crate_name {
273 return Some(entry.classification);
274 }
275 }
276 cargo_toml_classification
278}
279
280pub fn should_exclude(path: &Path, excludes: &[String]) -> bool {
285 let path_str = path.display().to_string();
286 excludes.iter().any(|pattern| {
287 match globset::Glob::new(pattern) {
288 Ok(glob) => match glob.compile_matcher().is_match(&path_str) {
289 true => true,
290 false => {
291 path.file_name()
293 .and_then(|n| n.to_str())
294 .is_some_and(|name| glob.compile_matcher().is_match(name))
295 }
296 },
297 Err(_) => path_str.contains(pattern),
298 }
299 })
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn parse_config() {
308 let toml = r#"
309 [analysis]
310 exclude = ["tests/**", "benches/**"]
311
312 [[authority]]
313 path = ["my_crate", "secrets", "fetch"]
314 category = "net"
315 risk = "critical"
316 description = "Fetches secrets"
317
318 [[allow]]
319 crate = "tracing"
320 reason = "Logging framework"
321 "#;
322
323 let config: Config = toml::from_str(toml).unwrap();
324 assert_eq!(config.analysis.exclude.len(), 2);
325 assert_eq!(config.authority.len(), 1);
326 assert_eq!(
327 config.authority[0].path,
328 vec!["my_crate", "secrets", "fetch"]
329 );
330 assert_eq!(config.allow.len(), 1);
331 assert_eq!(config.allow[0].effective_crate(), Some("tracing"));
332 }
333
334 #[test]
335 fn missing_config_returns_default() {
336 let root = capsec_core::root::test_root();
337 let cap = root.grant::<capsec_core::permission::FsRead>();
338 let config = load_config(Path::new("/nonexistent/path"), &cap).unwrap();
339 assert!(config.authority.is_empty());
340 assert!(config.allow.is_empty());
341 }
342
343 #[test]
344 fn parse_deny_config() {
345 let toml = r#"
346 [deny]
347 categories = ["all"]
348 "#;
349 let config: Config = toml::from_str(toml).unwrap();
350 assert_eq!(config.deny.categories, vec!["all"]);
351 }
352
353 #[test]
354 fn parse_deny_selective_categories() {
355 let toml = r#"
356 [deny]
357 categories = ["fs", "net"]
358 "#;
359 let config: Config = toml::from_str(toml).unwrap();
360 assert_eq!(config.deny.categories, vec!["fs", "net"]);
361 }
362
363 #[test]
364 fn missing_deny_section_defaults_to_empty() {
365 let toml = r#"
366 [[allow]]
367 crate = "tracing"
368 "#;
369 let config: Config = toml::from_str(toml).unwrap();
370 assert!(config.deny.categories.is_empty());
371 }
372
373 #[test]
374 fn normalized_categories_lowercases_and_filters() {
375 let deny = DenyConfig {
376 categories: vec!["FS".to_string(), "bogus".to_string(), "net".to_string()],
377 };
378 let normalized = deny.normalized_categories();
379 assert_eq!(normalized, vec!["fs", "net"]);
380 }
381
382 #[test]
383 fn parse_classify_entries() {
384 let toml = r#"
385 [[classify]]
386 crate = "serde"
387 classification = "pure"
388
389 [[classify]]
390 crate = "tokio"
391 classification = "resource"
392 "#;
393 let config: Config = toml::from_str(toml).unwrap();
394 assert_eq!(config.classify.len(), 2);
395 assert_eq!(config.classify[0].crate_name, "serde");
396 assert_eq!(config.classify[0].classification, Classification::Pure);
397 assert_eq!(config.classify[1].crate_name, "tokio");
398 assert_eq!(config.classify[1].classification, Classification::Resource);
399 }
400
401 #[test]
402 fn missing_classify_defaults_to_empty() {
403 let toml = r#"
404 [[allow]]
405 crate = "tracing"
406 "#;
407 let config: Config = toml::from_str(toml).unwrap();
408 assert!(config.classify.is_empty());
409 }
410
411 #[test]
412 fn resolve_capsec_toml_overrides_cargo_metadata() {
413 let config = Config {
414 classify: vec![ClassifyEntry {
415 crate_name: "my-lib".to_string(),
416 classification: Classification::Resource,
417 }],
418 ..Config::default()
419 };
420 let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
421 assert_eq!(result, Some(Classification::Resource));
422 }
423
424 #[test]
425 fn resolve_falls_back_to_cargo_metadata() {
426 let config = Config::default();
427 let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
428 assert_eq!(result, Some(Classification::Pure));
429 }
430
431 #[test]
432 fn resolve_unclassified_returns_none() {
433 let config = Config::default();
434 let result = resolve_classification("my-lib", None, &config);
435 assert_eq!(result, None);
436 }
437
438 #[test]
439 fn verify_pure_crate_with_no_findings_passes() {
440 let result = verify_classification(Some(Classification::Pure), &[], "my-lib", "0.1.0");
441 assert!(result.valid);
442 assert_eq!(result.violation_count, 0);
443 }
444
445 #[test]
446 fn verify_pure_crate_with_findings_fails() {
447 let findings = vec![Finding {
448 file: "src/lib.rs".to_string(),
449 function: "do_io".to_string(),
450 function_line: 1,
451 call_line: 2,
452 call_col: 5,
453 call_text: "std::fs::read".to_string(),
454 category: crate::authorities::Category::Fs,
455 subcategory: "read".to_string(),
456 risk: crate::authorities::Risk::Medium,
457 description: "Read file".to_string(),
458 is_build_script: false,
459 crate_name: "my-lib".to_string(),
460 crate_version: "0.1.0".to_string(),
461 is_deny_violation: false,
462 is_transitive: false,
463 }];
464 let result =
465 verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
466 assert!(!result.valid);
467 assert_eq!(result.violation_count, 1);
468 }
469
470 #[test]
471 fn verify_pure_crate_excludes_build_script_findings() {
472 let findings = vec![Finding {
473 file: "build.rs".to_string(),
474 function: "main".to_string(),
475 function_line: 1,
476 call_line: 2,
477 call_col: 5,
478 call_text: "std::env::var".to_string(),
479 category: crate::authorities::Category::Env,
480 subcategory: "read".to_string(),
481 risk: crate::authorities::Risk::Low,
482 description: "Read env var".to_string(),
483 is_build_script: true,
484 crate_name: "my-lib".to_string(),
485 crate_version: "0.1.0".to_string(),
486 is_deny_violation: false,
487 is_transitive: false,
488 }];
489 let result =
490 verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
491 assert!(result.valid);
492 assert_eq!(result.violation_count, 0);
493 }
494
495 #[test]
496 fn verify_resource_crate_always_passes() {
497 let findings = vec![Finding {
498 file: "src/lib.rs".to_string(),
499 function: "do_io".to_string(),
500 function_line: 1,
501 call_line: 2,
502 call_col: 5,
503 call_text: "std::fs::read".to_string(),
504 category: crate::authorities::Category::Fs,
505 subcategory: "read".to_string(),
506 risk: crate::authorities::Risk::Medium,
507 description: "Read file".to_string(),
508 is_build_script: false,
509 crate_name: "my-lib".to_string(),
510 crate_version: "0.1.0".to_string(),
511 is_deny_violation: false,
512 is_transitive: false,
513 }];
514 let result =
515 verify_classification(Some(Classification::Resource), &findings, "my-lib", "0.1.0");
516 assert!(result.valid);
517 }
518
519 #[test]
520 fn invalid_classification_value_errors() {
521 let toml = r#"
522 [[classify]]
523 crate = "bad"
524 classification = "unknown"
525 "#;
526 let result: Result<Config, _> = toml::from_str(toml);
527 assert!(result.is_err());
528 }
529
530 #[test]
531 fn exclude_pattern_matching() {
532 assert!(should_exclude(
533 Path::new("tests/integration.rs"),
534 &["tests/**".to_string()]
535 ));
536 assert!(!should_exclude(
537 Path::new("src/main.rs"),
538 &["tests/**".to_string()]
539 ));
540 }
541}