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 "build/".to_string(),
83 "target/".to_string(),
84 "dist/".to_string(),
85 "out/".to_string(),
86 ".next/".to_string(),
87 ],
88 }
89 }
90}
91
92impl SecurityConfig {
93 #[must_use]
97 #[deprecated(since = "0.6.0", note = "Use `SecurityConfig::default()` instead")]
98 pub fn with_defaults() -> Self {
99 Self::default()
100 }
101
102 #[must_use]
106 pub fn empty() -> Self {
107 Self {
108 ignore_patterns: vec![],
109 ignore_paths: vec![],
110 }
111 }
112
113 #[must_use]
126 pub fn should_ignore_path(&self, file_path: &str) -> bool {
127 self.ignore_paths
128 .iter()
129 .any(|prefix| path_matches_prefix(file_path, prefix))
130 }
131
132 #[must_use]
140 pub fn load() -> Self {
141 if let Some(path) = Self::config_path() {
142 match Self::load_from_path(&path) {
143 Ok(config) => config,
144 Err(e) => {
145 tracing::warn!("Failed to load security config: {:#}", e);
146 Self::default()
147 }
148 }
149 } else {
150 tracing::warn!("Config directory not available, using default security config");
151 Self::default()
152 }
153 }
154
155 #[must_use]
159 pub fn config_path() -> Option<PathBuf> {
160 dirs::config_dir().map(|dir| dir.join("aptu").join("security.toml"))
161 }
162
163 fn load_from_path(path: &PathBuf) -> Result<Self> {
173 if !path.exists() {
174 return Ok(Self::default());
175 }
176
177 let contents = fs::read_to_string(path)
178 .with_context(|| format!("Failed to read config file: {}", path.display()))?;
179
180 toml::from_str(&contents)
181 .with_context(|| format!("Failed to parse config file: {}", path.display()))
182 }
183
184 #[must_use]
198 pub fn should_ignore(&self, finding: &Finding) -> bool {
199 if self.ignore_patterns.contains(&finding.pattern_id) {
201 return true;
202 }
203
204 for prefix in &self.ignore_paths {
206 if path_matches_prefix(&finding.file_path, prefix) {
207 return true;
208 }
209 }
210
211 false
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::security::{Confidence, Severity};
219
220 #[test]
221 fn test_security_config_default_has_sensible_paths() {
222 let config = SecurityConfig::default();
223 assert!(config.ignore_patterns.is_empty());
224 assert_eq!(config.ignore_paths.len(), 12);
225 assert!(config.ignore_paths.contains(&"tests/".to_string()));
226 assert!(config.ignore_paths.contains(&"test/".to_string()));
227 assert!(config.ignore_paths.contains(&"benches/".to_string()));
228 assert!(config.ignore_paths.contains(&"fixtures/".to_string()));
229 assert!(config.ignore_paths.contains(&"vendor/".to_string()));
230 for prefix in [
231 "generated/",
232 "docs/",
233 "build/",
234 "target/",
235 "dist/",
236 "out/",
237 ".next/",
238 ] {
239 assert!(config.should_ignore_path(&format!("./{prefix}file.rs")));
240 }
241 }
242
243 #[test]
244 fn test_empty_config() {
245 let config = SecurityConfig::empty();
246 assert!(config.ignore_patterns.is_empty());
247 assert!(config.ignore_paths.is_empty());
248 }
249
250 #[test]
251 #[allow(deprecated)]
252 fn test_with_defaults_deprecated() {
253 let config = SecurityConfig::with_defaults();
255 assert!(config.ignore_patterns.is_empty());
256 assert_eq!(config.ignore_paths.len(), 12);
257 }
258
259 #[test]
260 fn test_should_ignore_path_method() {
261 let config = SecurityConfig::default();
262
263 assert!(config.should_ignore_path("tests/unit/test.rs"));
265 assert!(config.should_ignore_path("test/fixtures/data.rs"));
266 assert!(config.should_ignore_path("vendor/lib.rs"));
267
268 assert!(!config.should_ignore_path("src/main.rs"));
270 assert!(!config.should_ignore_path("src/test.rs"));
271 }
272
273 #[test]
274 fn test_should_ignore_pattern() {
275 let config = SecurityConfig {
276 ignore_patterns: vec!["test-pattern".to_string(), "another-pattern".to_string()],
277 ignore_paths: vec![],
278 };
279
280 let finding = Finding {
281 pattern_id: "test-pattern".to_string(),
282 description: "Test".to_string(),
283 severity: Severity::Low,
284 confidence: Confidence::Low,
285 file_path: "src/main.rs".to_string(),
286 line_number: 1,
287 matched_text: "test".to_string(),
288 cwe: None,
289 };
290
291 assert!(config.should_ignore(&finding));
292 }
293
294 #[test]
295 fn test_should_ignore_path() {
296 let config = SecurityConfig {
297 ignore_patterns: vec![],
298 ignore_paths: vec!["test/".to_string(), "vendor/".to_string()],
299 };
300
301 let finding = Finding {
302 pattern_id: "pattern".to_string(),
303 description: "Test".to_string(),
304 severity: Severity::Low,
305 confidence: Confidence::Low,
306 file_path: "test/fixtures/data.rs".to_string(),
307 line_number: 1,
308 matched_text: "test".to_string(),
309 cwe: None,
310 };
311
312 assert!(config.should_ignore(&finding));
313 }
314
315 #[test]
316 fn test_should_not_ignore() {
317 let config = SecurityConfig {
318 ignore_patterns: vec!["other-pattern".to_string()],
319 ignore_paths: vec!["vendor/".to_string()],
320 };
321
322 let finding = Finding {
323 pattern_id: "real-pattern".to_string(),
324 description: "Test".to_string(),
325 severity: Severity::High,
326 confidence: Confidence::High,
327 file_path: "src/main.rs".to_string(),
328 line_number: 42,
329 matched_text: "code".to_string(),
330 cwe: Some("CWE-123".to_string()),
331 };
332
333 assert!(!config.should_ignore(&finding));
334 }
335
336 #[test]
337 fn test_should_ignore_path_prefix() {
338 let config = SecurityConfig {
339 ignore_patterns: vec![],
340 ignore_paths: vec!["test/".to_string()],
341 };
342
343 let finding1 = Finding {
345 pattern_id: "pattern".to_string(),
346 description: "Test".to_string(),
347 severity: Severity::Low,
348 confidence: Confidence::Low,
349 file_path: "test/unit/test.rs".to_string(),
350 line_number: 1,
351 matched_text: "test".to_string(),
352 cwe: None,
353 };
354 assert!(config.should_ignore(&finding1));
355
356 let finding2 = Finding {
358 pattern_id: "pattern".to_string(),
359 description: "Test".to_string(),
360 severity: Severity::Low,
361 confidence: Confidence::Low,
362 file_path: "src/test.rs".to_string(),
363 line_number: 1,
364 matched_text: "test".to_string(),
365 cwe: None,
366 };
367 assert!(!config.should_ignore(&finding2));
368 }
369
370 #[test]
371 fn test_path_matches_prefix_component_matching() {
372 assert!(path_matches_prefix("./tests/foo.rs", "tests/"));
374 assert!(path_matches_prefix("tests/foo.rs", "tests/"));
376 assert!(path_matches_prefix("/absolute/path/tests/foo.rs", "tests/"));
378 assert!(!path_matches_prefix("src/main.rs", "tests/"));
380 assert!(path_matches_prefix("generated/sub/deep.rs", "generated/"));
382 assert!(!path_matches_prefix("src/tests/foo.rs", "tests/"));
384 }
385
386 #[test]
387 fn test_config_serialization() {
388 let config = SecurityConfig {
389 ignore_patterns: vec!["pattern1".to_string(), "pattern2".to_string()],
390 ignore_paths: vec!["test/".to_string(), "vendor/".to_string()],
391 };
392
393 let toml = toml::to_string(&config).expect("serialize");
394 let deserialized: SecurityConfig = toml::from_str(&toml).expect("deserialize");
395
396 assert_eq!(config.ignore_patterns, deserialized.ignore_patterns);
397 assert_eq!(config.ignore_paths, deserialized.ignore_paths);
398 }
399
400 #[test]
401 fn test_load_nonexistent_file_returns_defaults() {
402 let path = PathBuf::from("/nonexistent/path/security.toml");
403 let config = SecurityConfig::load_from_path(&path).expect("load default");
404 assert!(config.ignore_patterns.is_empty());
406 assert_eq!(config.ignore_paths.len(), 12);
407 }
408
409 #[test]
410 fn test_config_path() {
411 if let Some(path) = SecurityConfig::config_path() {
412 assert!(path.ends_with("aptu/security.toml"));
413 }
414 }
416}