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::cap_provider::CapProvider<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
280#[allow(dead_code)]
282pub struct CompiledExcludes {
283 set: globset::GlobSet,
284}
285
286#[allow(dead_code)]
287impl CompiledExcludes {
288 pub fn new(patterns: &[String]) -> Self {
290 let mut builder = globset::GlobSetBuilder::new();
291 for p in patterns {
292 if let Ok(glob) = globset::Glob::new(p) {
293 builder.add(glob);
294 }
295 }
296 Self {
297 set: builder
298 .build()
299 .unwrap_or_else(|_| globset::GlobSetBuilder::new().build().unwrap()),
300 }
301 }
302
303 pub fn is_excluded(&self, path: &Path) -> bool {
305 let path_str = path.display().to_string();
306 self.set.is_match(&path_str)
307 || path
308 .file_name()
309 .and_then(|n| n.to_str())
310 .is_some_and(|name| self.set.is_match(name))
311 }
312}
313
314pub fn should_exclude(path: &Path, excludes: &[String]) -> bool {
319 let path_str = path.display().to_string();
320 excludes
321 .iter()
322 .any(|pattern| match globset::Glob::new(pattern) {
323 Ok(glob) => match glob.compile_matcher().is_match(&path_str) {
324 true => true,
325 false => path
326 .file_name()
327 .and_then(|n| n.to_str())
328 .is_some_and(|name| glob.compile_matcher().is_match(name)),
329 },
330 Err(_) => path_str.contains(pattern),
331 })
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn parse_config() {
340 let toml = r#"
341 [analysis]
342 exclude = ["tests/**", "benches/**"]
343
344 [[authority]]
345 path = ["my_crate", "secrets", "fetch"]
346 category = "net"
347 risk = "critical"
348 description = "Fetches secrets"
349
350 [[allow]]
351 crate = "tracing"
352 reason = "Logging framework"
353 "#;
354
355 let config: Config = toml::from_str(toml).unwrap();
356 assert_eq!(config.analysis.exclude.len(), 2);
357 assert_eq!(config.authority.len(), 1);
358 assert_eq!(
359 config.authority[0].path,
360 vec!["my_crate", "secrets", "fetch"]
361 );
362 assert_eq!(config.allow.len(), 1);
363 assert_eq!(config.allow[0].effective_crate(), Some("tracing"));
364 }
365
366 #[test]
367 fn missing_config_returns_default() {
368 let root = capsec_core::root::test_root();
369 let cap = root.grant::<capsec_core::permission::FsRead>();
370 let config = load_config(Path::new("/nonexistent/path"), &cap).unwrap();
371 assert!(config.authority.is_empty());
372 assert!(config.allow.is_empty());
373 }
374
375 #[test]
376 fn parse_deny_config() {
377 let toml = r#"
378 [deny]
379 categories = ["all"]
380 "#;
381 let config: Config = toml::from_str(toml).unwrap();
382 assert_eq!(config.deny.categories, vec!["all"]);
383 }
384
385 #[test]
386 fn parse_deny_selective_categories() {
387 let toml = r#"
388 [deny]
389 categories = ["fs", "net"]
390 "#;
391 let config: Config = toml::from_str(toml).unwrap();
392 assert_eq!(config.deny.categories, vec!["fs", "net"]);
393 }
394
395 #[test]
396 fn missing_deny_section_defaults_to_empty() {
397 let toml = r#"
398 [[allow]]
399 crate = "tracing"
400 "#;
401 let config: Config = toml::from_str(toml).unwrap();
402 assert!(config.deny.categories.is_empty());
403 }
404
405 #[test]
406 fn normalized_categories_lowercases_and_filters() {
407 let deny = DenyConfig {
408 categories: vec!["FS".to_string(), "bogus".to_string(), "net".to_string()],
409 };
410 let normalized = deny.normalized_categories();
411 assert_eq!(normalized, vec!["fs", "net"]);
412 }
413
414 #[test]
415 fn parse_classify_entries() {
416 let toml = r#"
417 [[classify]]
418 crate = "serde"
419 classification = "pure"
420
421 [[classify]]
422 crate = "tokio"
423 classification = "resource"
424 "#;
425 let config: Config = toml::from_str(toml).unwrap();
426 assert_eq!(config.classify.len(), 2);
427 assert_eq!(config.classify[0].crate_name, "serde");
428 assert_eq!(config.classify[0].classification, Classification::Pure);
429 assert_eq!(config.classify[1].crate_name, "tokio");
430 assert_eq!(config.classify[1].classification, Classification::Resource);
431 }
432
433 #[test]
434 fn missing_classify_defaults_to_empty() {
435 let toml = r#"
436 [[allow]]
437 crate = "tracing"
438 "#;
439 let config: Config = toml::from_str(toml).unwrap();
440 assert!(config.classify.is_empty());
441 }
442
443 #[test]
444 fn resolve_capsec_toml_overrides_cargo_metadata() {
445 let config = Config {
446 classify: vec![ClassifyEntry {
447 crate_name: "my-lib".to_string(),
448 classification: Classification::Resource,
449 }],
450 ..Config::default()
451 };
452 let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
453 assert_eq!(result, Some(Classification::Resource));
454 }
455
456 #[test]
457 fn resolve_falls_back_to_cargo_metadata() {
458 let config = Config::default();
459 let result = resolve_classification("my-lib", Some(Classification::Pure), &config);
460 assert_eq!(result, Some(Classification::Pure));
461 }
462
463 #[test]
464 fn resolve_unclassified_returns_none() {
465 let config = Config::default();
466 let result = resolve_classification("my-lib", None, &config);
467 assert_eq!(result, None);
468 }
469
470 #[test]
471 fn verify_pure_crate_with_no_findings_passes() {
472 let result = verify_classification(Some(Classification::Pure), &[], "my-lib", "0.1.0");
473 assert!(result.valid);
474 assert_eq!(result.violation_count, 0);
475 }
476
477 #[test]
478 fn verify_pure_crate_with_findings_fails() {
479 let findings = vec![Finding {
480 file: "src/lib.rs".to_string(),
481 function: "do_io".to_string(),
482 function_line: 1,
483 call_line: 2,
484 call_col: 5,
485 call_text: "std::fs::read".to_string(),
486 category: crate::authorities::Category::Fs,
487 subcategory: "read".to_string(),
488 risk: crate::authorities::Risk::Medium,
489 description: "Read file".to_string(),
490 is_build_script: false,
491 crate_name: "my-lib".to_string(),
492 crate_version: "0.1.0".to_string(),
493 is_deny_violation: false,
494 is_transitive: false,
495 }];
496 let result =
497 verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
498 assert!(!result.valid);
499 assert_eq!(result.violation_count, 1);
500 }
501
502 #[test]
503 fn verify_pure_crate_excludes_build_script_findings() {
504 let findings = vec![Finding {
505 file: "build.rs".to_string(),
506 function: "main".to_string(),
507 function_line: 1,
508 call_line: 2,
509 call_col: 5,
510 call_text: "std::env::var".to_string(),
511 category: crate::authorities::Category::Env,
512 subcategory: "read".to_string(),
513 risk: crate::authorities::Risk::Low,
514 description: "Read env var".to_string(),
515 is_build_script: true,
516 crate_name: "my-lib".to_string(),
517 crate_version: "0.1.0".to_string(),
518 is_deny_violation: false,
519 is_transitive: false,
520 }];
521 let result =
522 verify_classification(Some(Classification::Pure), &findings, "my-lib", "0.1.0");
523 assert!(result.valid);
524 assert_eq!(result.violation_count, 0);
525 }
526
527 #[test]
528 fn verify_resource_crate_always_passes() {
529 let findings = vec![Finding {
530 file: "src/lib.rs".to_string(),
531 function: "do_io".to_string(),
532 function_line: 1,
533 call_line: 2,
534 call_col: 5,
535 call_text: "std::fs::read".to_string(),
536 category: crate::authorities::Category::Fs,
537 subcategory: "read".to_string(),
538 risk: crate::authorities::Risk::Medium,
539 description: "Read file".to_string(),
540 is_build_script: false,
541 crate_name: "my-lib".to_string(),
542 crate_version: "0.1.0".to_string(),
543 is_deny_violation: false,
544 is_transitive: false,
545 }];
546 let result =
547 verify_classification(Some(Classification::Resource), &findings, "my-lib", "0.1.0");
548 assert!(result.valid);
549 }
550
551 #[test]
552 fn invalid_classification_value_errors() {
553 let toml = r#"
554 [[classify]]
555 crate = "bad"
556 classification = "unknown"
557 "#;
558 let result: Result<Config, _> = toml::from_str(toml);
559 assert!(result.is_err());
560 }
561
562 #[test]
563 fn exclude_pattern_matching() {
564 assert!(should_exclude(
565 Path::new("tests/integration.rs"),
566 &["tests/**".to_string()]
567 ));
568 assert!(!should_exclude(
569 Path::new("src/main.rs"),
570 &["tests/**".to_string()]
571 ));
572 }
573}