1use std::fs;
9use std::path::{Component, Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14use super::Finding;
15
16fn path_matches_prefix(file_path: &str, prefix: &str) -> bool {
17 let file_components: Vec<Component<'_>> = Path::new(file_path)
18 .components()
19 .filter(|component| !matches!(component, Component::CurDir))
20 .collect();
21 let prefix_components: Vec<Component<'_>> = Path::new(prefix).components().collect();
22
23 if prefix_components.is_empty() || file_components.is_empty() {
24 return false;
25 }
26
27 if file_components.len() >= prefix_components.len()
29 && file_components[..prefix_components.len()]
30 .iter()
31 .zip(&prefix_components)
32 .all(|(file_component, prefix_component)| file_component == prefix_component)
33 {
34 return true;
35 }
36
37 matches!(file_components.first(), Some(Component::RootDir))
39 && file_components
40 .windows(prefix_components.len())
41 .any(|window| {
42 window
43 .iter()
44 .zip(&prefix_components)
45 .all(|(file_component, prefix_component)| file_component == prefix_component)
46 })
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SecurityConfig {
57 #[serde(default)]
59 pub ignore_patterns: Vec<String>,
60
61 #[serde(default)]
63 pub ignore_paths: Vec<String>,
64}
65
66impl Default for SecurityConfig {
67 fn default() -> Self {
72 Self {
73 ignore_patterns: vec![],
74 ignore_paths: vec![
75 "tests/".to_string(),
76 "test/".to_string(),
77 "benches/".to_string(),
78 "fixtures/".to_string(),
79 "vendor/".to_string(),
80 "generated/".to_string(),
81 "docs/".to_string(),
82 "examples/".to_string(),
83 "build/".to_string(),
84 "target/".to_string(),
85 "dist/".to_string(),
86 "out/".to_string(),
87 ".next/".to_string(),
88 ],
89 }
90 }
91}
92
93impl SecurityConfig {
94 #[must_use]
98 #[deprecated(since = "0.6.0", note = "Use `SecurityConfig::default()` instead")]
99 pub fn with_defaults() -> Self {
100 Self::default()
101 }
102
103 #[must_use]
107 pub fn empty() -> Self {
108 Self {
109 ignore_patterns: vec![],
110 ignore_paths: vec![],
111 }
112 }
113
114 #[must_use]
127 pub fn should_ignore_path(&self, file_path: &str) -> bool {
128 self.ignore_paths
129 .iter()
130 .any(|prefix| path_matches_prefix(file_path, prefix))
131 }
132
133 #[must_use]
141 pub fn load() -> Self {
142 if let Some(path) = Self::config_path() {
143 match Self::load_from_path(&path) {
144 Ok(config) => config,
145 Err(e) => {
146 tracing::warn!("Failed to load security config: {:#}", e);
147 Self::default()
148 }
149 }
150 } else {
151 tracing::warn!("Config directory not available, using default security config");
152 Self::default()
153 }
154 }
155
156 #[must_use]
160 pub fn config_path() -> Option<PathBuf> {
161 dirs::config_dir().map(|dir| dir.join("aptu").join("security.toml"))
162 }
163
164 fn load_from_path(path: &PathBuf) -> Result<Self> {
174 if !path.exists() {
175 return Ok(Self::default());
176 }
177
178 let contents = fs::read_to_string(path)
179 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
180
181 toml::from_str(&contents)
182 .with_context(|| format!("Failed to parse config file: {}", path.display()))
183 }
184
185 #[must_use]
199 pub fn should_ignore(&self, finding: &Finding) -> bool {
200 if self.ignore_patterns.contains(&finding.pattern_id) {
202 return true;
203 }
204
205 for prefix in &self.ignore_paths {
207 if path_matches_prefix(&finding.file_path, prefix) {
208 return true;
209 }
210 }
211
212 false
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::security::{Confidence, Severity};
220
221 #[test]
222 fn test_security_config_default_has_sensible_paths() {
223 let config = SecurityConfig::default();
224 assert!(config.ignore_patterns.is_empty());
225 assert_eq!(config.ignore_paths.len(), 13);
226 assert!(config.ignore_paths.contains(&"tests/".to_string()));
227 assert!(config.ignore_paths.contains(&"test/".to_string()));
228 assert!(config.ignore_paths.contains(&"benches/".to_string()));
229 assert!(config.ignore_paths.contains(&"fixtures/".to_string()));
230 assert!(config.ignore_paths.contains(&"vendor/".to_string()));
231 assert!(config.ignore_paths.contains(&"examples/".to_string()));
232 for prefix in [
233 "generated/",
234 "docs/",
235 "examples/",
236 "build/",
237 "target/",
238 "dist/",
239 "out/",
240 ".next/",
241 ] {
242 assert!(config.should_ignore_path(&format!("./{prefix}file.rs")));
243 }
244 }
245
246 #[test]
247 fn test_empty_config() {
248 let config = SecurityConfig::empty();
249 assert!(config.ignore_patterns.is_empty());
250 assert!(config.ignore_paths.is_empty());
251 }
252
253 #[test]
254 #[allow(deprecated)]
255 fn test_with_defaults_deprecated() {
256 let config = SecurityConfig::with_defaults();
258 assert!(config.ignore_patterns.is_empty());
259 assert_eq!(config.ignore_paths.len(), 13);
260 }
261
262 #[test]
263 fn test_should_ignore_path_method() {
264 let config = SecurityConfig::default();
265
266 assert!(config.should_ignore_path("tests/unit/test.rs"));
268 assert!(config.should_ignore_path("test/fixtures/data.rs"));
269 assert!(config.should_ignore_path("vendor/lib.rs"));
270
271 assert!(!config.should_ignore_path("src/main.rs"));
273 assert!(!config.should_ignore_path("src/test.rs"));
274 }
275
276 #[test]
277 fn test_should_ignore_pattern() {
278 let config = SecurityConfig {
279 ignore_patterns: vec!["test-pattern".to_string(), "another-pattern".to_string()],
280 ignore_paths: vec![],
281 };
282
283 let finding = Finding {
284 pattern_id: "test-pattern".to_string(),
285 description: "Test".to_string(),
286 severity: Severity::Low,
287 confidence: Confidence::Low,
288 file_path: "src/main.rs".to_string(),
289 line_number: 1,
290 matched_text: "test".to_string(),
291 cwe: None,
292 };
293
294 assert!(config.should_ignore(&finding));
295 }
296
297 #[test]
298 fn test_should_ignore_path() {
299 let config = SecurityConfig {
300 ignore_patterns: vec![],
301 ignore_paths: vec!["test/".to_string(), "vendor/".to_string()],
302 };
303
304 let finding = Finding {
305 pattern_id: "pattern".to_string(),
306 description: "Test".to_string(),
307 severity: Severity::Low,
308 confidence: Confidence::Low,
309 file_path: "test/fixtures/data.rs".to_string(),
310 line_number: 1,
311 matched_text: "test".to_string(),
312 cwe: None,
313 };
314
315 assert!(config.should_ignore(&finding));
316 }
317
318 #[test]
319 fn test_should_not_ignore() {
320 let config = SecurityConfig {
321 ignore_patterns: vec!["other-pattern".to_string()],
322 ignore_paths: vec!["vendor/".to_string()],
323 };
324
325 let finding = Finding {
326 pattern_id: "real-pattern".to_string(),
327 description: "Test".to_string(),
328 severity: Severity::High,
329 confidence: Confidence::High,
330 file_path: "src/main.rs".to_string(),
331 line_number: 42,
332 matched_text: "code".to_string(),
333 cwe: Some("CWE-123".to_string()),
334 };
335
336 assert!(!config.should_ignore(&finding));
337 }
338
339 #[test]
340 fn test_should_ignore_path_prefix() {
341 let config = SecurityConfig {
342 ignore_patterns: vec![],
343 ignore_paths: vec!["test/".to_string()],
344 };
345
346 let finding1 = Finding {
348 pattern_id: "pattern".to_string(),
349 description: "Test".to_string(),
350 severity: Severity::Low,
351 confidence: Confidence::Low,
352 file_path: "test/unit/test.rs".to_string(),
353 line_number: 1,
354 matched_text: "test".to_string(),
355 cwe: None,
356 };
357 assert!(config.should_ignore(&finding1));
358
359 let finding2 = Finding {
361 pattern_id: "pattern".to_string(),
362 description: "Test".to_string(),
363 severity: Severity::Low,
364 confidence: Confidence::Low,
365 file_path: "src/test.rs".to_string(),
366 line_number: 1,
367 matched_text: "test".to_string(),
368 cwe: None,
369 };
370 assert!(!config.should_ignore(&finding2));
371 }
372
373 #[test]
374 fn test_path_matches_prefix_component_matching() {
375 assert!(path_matches_prefix("./tests/foo.rs", "tests/"));
377 assert!(path_matches_prefix("tests/foo.rs", "tests/"));
379 assert!(path_matches_prefix("/absolute/path/tests/foo.rs", "tests/"));
381 assert!(!path_matches_prefix("src/main.rs", "tests/"));
383 assert!(path_matches_prefix("generated/sub/deep.rs", "generated/"));
385 assert!(!path_matches_prefix("src/tests/foo.rs", "tests/"));
387 }
388
389 #[test]
390 fn test_config_serialization() {
391 let config = SecurityConfig {
392 ignore_patterns: vec!["pattern1".to_string(), "pattern2".to_string()],
393 ignore_paths: vec!["test/".to_string(), "vendor/".to_string()],
394 };
395
396 let toml = toml::to_string(&config).expect("serialize");
397 let deserialized: SecurityConfig = toml::from_str(&toml).expect("deserialize");
398
399 assert_eq!(config.ignore_patterns, deserialized.ignore_patterns);
400 assert_eq!(config.ignore_paths, deserialized.ignore_paths);
401 }
402
403 #[test]
404 fn test_load_nonexistent_file_returns_defaults() {
405 let path = PathBuf::from("/nonexistent/path/security.toml");
406 let config = SecurityConfig::load_from_path(&path).expect("load default");
407 assert!(config.ignore_patterns.is_empty());
409 assert_eq!(config.ignore_paths.len(), 13);
410 }
411
412 #[test]
413 fn test_config_path() {
414 if let Some(path) = SecurityConfig::config_path() {
415 assert!(path.ends_with("aptu/security.toml"));
416 }
417 }
419}