1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use thiserror::Error;
4
5#[derive(Debug, Clone, Default)]
7pub struct Configuration {
8 pub ignore: HashSet<String>,
10 pub max_db_age_days: Option<u64>,
12 pub ignore_comments: HashMap<String, String>,
14}
15
16#[derive(Debug, Error)]
18pub enum ConfigError {
19 #[error("configuration file not found: {0}")]
21 FileNotFound(String),
22 #[error("invalid YAML in configuration: {0}")]
24 InvalidYaml(String),
25 #[error("invalid configuration: {0}")]
27 InvalidConfiguration(String),
28}
29
30impl Configuration {
31 pub const DEFAULT_FILE: &str = ".gem-audit.yml";
33
34 pub const LEGACY_FILE: &str = ".bundler-audit.yml";
36
37 pub fn load(path: &Path) -> Result<Self, ConfigError> {
41 if !path.exists() {
42 return Err(ConfigError::FileNotFound(path.display().to_string()));
43 }
44
45 let content =
46 std::fs::read_to_string(path).map_err(|e| ConfigError::FileNotFound(e.to_string()))?;
47
48 Self::from_yaml(&content)
49 }
50
51 pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
58 if path.exists() {
59 return Self::load(path);
60 }
61
62 if path
64 .file_name()
65 .map(|f| f == Self::DEFAULT_FILE)
66 .unwrap_or(false)
67 && let Some(parent) = path.parent()
68 {
69 let legacy = parent.join(Self::LEGACY_FILE);
70 if legacy.exists() {
71 return Self::load(&legacy);
72 }
73 }
74
75 Ok(Self::default())
76 }
77
78 pub fn save(
86 &self,
87 path: &Path,
88 comments: Option<&std::collections::HashMap<String, String>>,
89 ) -> Result<(), ConfigError> {
90 let mut lines = Vec::new();
91 lines.push("---".to_string());
92
93 if self.ignore.is_empty() && self.max_db_age_days.is_none() {
94 lines.push("ignore: []".to_string());
95 } else {
96 if !self.ignore.is_empty() {
97 lines.push("ignore:".to_string());
98 let mut sorted: Vec<&String> = self.ignore.iter().collect();
99 sorted.sort();
100 for id in sorted {
101 let comment = comments.and_then(|c| c.get(id.as_str()));
102 match comment {
103 Some(c) => lines.push(format!(" - {} # {}", id, c)),
104 None => lines.push(format!(" - {}", id)),
105 }
106 }
107 }
108
109 if let Some(days) = self.max_db_age_days {
110 lines.push(format!("max_db_age_days: {}", days));
111 }
112 }
113
114 lines.push(String::new()); std::fs::write(path, lines.join("\n")).map_err(|e| {
116 ConfigError::InvalidConfiguration(format!("failed to write {}: {}", path.display(), e))
117 })
118 }
119
120 fn parse_ignore_comments(yaml: &str) -> HashMap<String, String> {
124 yaml.lines()
125 .filter_map(|line| {
126 let trimmed = line.trim();
127 let entry = trimmed.strip_prefix("- ")?;
128 let (id, comment) = entry.split_once(" # ")?;
129 Some((id.trim().to_string(), comment.to_string()))
130 })
131 .collect()
132 }
133
134 pub fn from_yaml(yaml: &str) -> Result<Self, ConfigError> {
136 let ignore_comments = Self::parse_ignore_comments(yaml);
137
138 let value: serde_yml::Value =
139 serde_yml::from_str(yaml).map_err(|e| ConfigError::InvalidYaml(e.to_string()))?;
140
141 let mapping = match value.as_mapping() {
143 Some(m) => m,
144 None => {
145 return Err(ConfigError::InvalidConfiguration(
146 "expected a YAML mapping, not a scalar or sequence".to_string(),
147 ));
148 }
149 };
150
151 let mut ignore = HashSet::new();
152
153 if let Some(ignore_val) = mapping.get(serde_yml::Value::String("ignore".to_string())) {
154 let arr = match ignore_val.as_sequence() {
155 Some(seq) => seq,
156 None => {
157 return Err(ConfigError::InvalidConfiguration(
158 "'ignore' must be an Array".to_string(),
159 ));
160 }
161 };
162
163 for item in arr {
164 match item.as_str() {
165 Some(s) => {
166 ignore.insert(s.to_string());
167 }
168 None => {
169 return Err(ConfigError::InvalidConfiguration(
170 "'ignore' contains a non-String value".to_string(),
171 ));
172 }
173 }
174 }
175 }
176
177 let max_db_age_days = mapping
178 .get(serde_yml::Value::String("max_db_age_days".to_string()))
179 .and_then(|v| v.as_u64());
180
181 Ok(Configuration {
182 ignore,
183 max_db_age_days,
184 ignore_comments,
185 })
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use std::path::PathBuf;
193
194 fn fixtures_dir() -> PathBuf {
195 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/config")
196 }
197
198 #[test]
199 fn load_valid_config() {
200 let config = Configuration::load(&fixtures_dir().join("valid.yml")).unwrap();
201 assert_eq!(config.ignore.len(), 2);
202 assert!(config.ignore.contains("CVE-123"));
203 assert!(config.ignore.contains("CVE-456"));
204 }
205
206 #[test]
207 fn load_empty_ignore_list() {
208 let config = Configuration::from_yaml("---\nignore: []\n").unwrap();
209 assert!(config.ignore.is_empty());
210 }
211
212 #[test]
213 fn load_no_ignore_key() {
214 let config = Configuration::from_yaml("---\n{}\n").unwrap();
215 assert!(config.ignore.is_empty());
216 }
217
218 #[test]
219 fn load_missing_file_returns_default() {
220 let config =
221 Configuration::load_or_default(Path::new("/nonexistent/.gem-audit.yml")).unwrap();
222 assert!(config.ignore.is_empty());
223 }
224
225 #[test]
226 fn load_missing_file_returns_error() {
227 let result = Configuration::load(Path::new("/nonexistent/.gem-audit.yml"));
228 assert!(result.is_err());
229 let err = result.unwrap_err();
230 assert!(matches!(err, ConfigError::FileNotFound(_)));
231 }
232
233 #[test]
234 fn reject_empty_yaml_file() {
235 let result = Configuration::load(&fixtures_dir().join("bad/empty.yml"));
236 assert!(result.is_err());
237 }
238
239 #[test]
240 fn reject_ignore_not_array() {
241 let result = Configuration::load(&fixtures_dir().join("bad/ignore_is_not_an_array.yml"));
242 assert!(result.is_err());
243 let err = result.unwrap_err();
244 match err {
245 ConfigError::InvalidConfiguration(msg) => {
246 assert!(msg.contains("Array"), "expected 'Array' in error: {}", msg);
247 }
248 other => panic!("expected InvalidConfiguration, got: {:?}", other),
249 }
250 }
251
252 #[test]
253 fn reject_ignore_contains_non_string() {
254 let result =
255 Configuration::load(&fixtures_dir().join("bad/ignore_contains_a_non_string.yml"));
256 assert!(result.is_err());
257 let err = result.unwrap_err();
258 match err {
259 ConfigError::InvalidConfiguration(msg) => {
260 assert!(
261 msg.contains("non-String"),
262 "expected 'non-String' in error: {}",
263 msg
264 );
265 }
266 other => panic!("expected InvalidConfiguration, got: {:?}", other),
267 }
268 }
269
270 #[test]
271 fn default_config_is_empty() {
272 let config = Configuration::default();
273 assert!(config.ignore.is_empty());
274 }
275
276 #[test]
277 fn parse_real_dot_config() {
278 let yaml = "---\nignore:\n- OSVDB-89025\n";
279 let config = Configuration::from_yaml(yaml).unwrap();
280 assert_eq!(config.ignore.len(), 1);
281 assert!(config.ignore.contains("OSVDB-89025"));
282 }
283
284 #[test]
285 fn parse_max_db_age_days() {
286 let yaml = "---\nmax_db_age_days: 7\n";
287 let config = Configuration::from_yaml(yaml).unwrap();
288 assert_eq!(config.max_db_age_days, Some(7));
289 }
290
291 #[test]
292 fn parse_config_without_max_db_age() {
293 let yaml = "---\nignore:\n- CVE-123\n";
294 let config = Configuration::from_yaml(yaml).unwrap();
295 assert_eq!(config.max_db_age_days, None);
296 }
297
298 #[test]
301 fn save_and_reload_roundtrip() {
302 let tmp = tempfile::tempdir().unwrap();
303
304 let path = tmp.path().join(".gem-audit.yml");
305 let mut ignore = HashSet::new();
306 ignore.insert("CVE-2020-1234".to_string());
307 ignore.insert("GHSA-aaaa-bbbb-cccc".to_string());
308
309 let config = Configuration {
310 ignore,
311 max_db_age_days: Some(7),
312 ..Configuration::default()
313 };
314 config.save(&path, None).unwrap();
315
316 let reloaded = Configuration::load(&path).unwrap();
317 assert_eq!(reloaded.ignore.len(), 2);
318 assert!(reloaded.ignore.contains("CVE-2020-1234"));
319 assert!(reloaded.ignore.contains("GHSA-aaaa-bbbb-cccc"));
320 assert_eq!(reloaded.max_db_age_days, Some(7));
321 }
322
323 #[test]
324 fn save_empty_config() {
325 let tmp = tempfile::tempdir().unwrap();
326
327 let path = tmp.path().join(".gem-audit.yml");
328 let config = Configuration::default();
329 config.save(&path, None).unwrap();
330
331 let reloaded = Configuration::load(&path).unwrap();
332 assert!(reloaded.ignore.is_empty());
333 assert_eq!(reloaded.max_db_age_days, None);
334 }
335
336 #[test]
337 fn save_sorted_output() {
338 let tmp = tempfile::tempdir().unwrap();
339
340 let path = tmp.path().join(".gem-audit.yml");
341 let mut ignore = HashSet::new();
342 ignore.insert("CVE-2020-9999".to_string());
343 ignore.insert("CVE-2020-0001".to_string());
344 ignore.insert("GHSA-zzzz-yyyy-xxxx".to_string());
345
346 let config = Configuration {
347 ignore,
348 max_db_age_days: None,
349 ..Configuration::default()
350 };
351 config.save(&path, None).unwrap();
352
353 let content = std::fs::read_to_string(&path).unwrap();
354 let lines: Vec<&str> = content.lines().collect();
355 assert_eq!(lines[2], " - CVE-2020-0001");
357 assert_eq!(lines[3], " - CVE-2020-9999");
358 assert_eq!(lines[4], " - GHSA-zzzz-yyyy-xxxx");
359 }
360
361 #[test]
362 fn save_with_comments() {
363 let tmp = tempfile::tempdir().unwrap();
364
365 let path = tmp.path().join(".gem-audit.yml");
366 let mut ignore = HashSet::new();
367 ignore.insert("CVE-2020-1234".to_string());
368 ignore.insert("GHSA-aaaa-bbbb-cccc".to_string());
369
370 let config = Configuration {
371 ignore,
372 max_db_age_days: None,
373 ..Configuration::default()
374 };
375
376 let mut comments = std::collections::HashMap::new();
377 comments.insert(
378 "CVE-2020-1234".to_string(),
379 "activerecord 3.2.10 (Critical)".to_string(),
380 );
381 comments.insert(
382 "GHSA-aaaa-bbbb-cccc".to_string(),
383 "rack 1.5.0 (Medium)".to_string(),
384 );
385
386 config.save(&path, Some(&comments)).unwrap();
387
388 let content = std::fs::read_to_string(&path).unwrap();
389 assert!(content.contains("CVE-2020-1234 # activerecord 3.2.10 (Critical)"));
390 assert!(content.contains("GHSA-aaaa-bbbb-cccc # rack 1.5.0 (Medium)"));
391
392 let reloaded = Configuration::load(&path).unwrap();
394 assert_eq!(reloaded.ignore.len(), 2);
395 assert!(reloaded.ignore.contains("CVE-2020-1234"));
396 assert!(reloaded.ignore.contains("GHSA-aaaa-bbbb-cccc"));
397 }
398
399 #[test]
400 fn display_errors() {
401 let e1 = ConfigError::FileNotFound("foo.yml".to_string());
402 assert!(e1.to_string().contains("foo.yml"));
403
404 let e2 = ConfigError::InvalidYaml("bad".to_string());
405 assert!(e2.to_string().contains("bad"));
406
407 let e3 = ConfigError::InvalidConfiguration("oops".to_string());
408 assert!(e3.to_string().contains("oops"));
409 }
410
411 #[test]
414 fn legacy_config_fallback() {
415 let tmp = tempfile::tempdir().unwrap();
416
417 std::fs::write(
419 tmp.path().join(".bundler-audit.yml"),
420 "---\nignore:\n - CVE-LEGACY-001\n",
421 )
422 .unwrap();
423
424 let config = Configuration::load_or_default(&tmp.path().join(".gem-audit.yml")).unwrap();
426 assert!(config.ignore.contains("CVE-LEGACY-001"));
427 }
428
429 #[test]
430 fn no_legacy_fallback_for_custom_name() {
431 let config = Configuration::load_or_default(Path::new("/nonexistent/custom.yml")).unwrap();
433 assert!(config.ignore.is_empty());
434 }
435
436 #[test]
439 fn reject_yaml_scalar_root() {
440 let result = Configuration::from_yaml("hello");
441 assert!(result.is_err());
442 match result.unwrap_err() {
443 ConfigError::InvalidConfiguration(msg) => {
444 assert!(msg.contains("expected a YAML mapping"));
445 }
446 other => panic!("expected InvalidConfiguration, got: {:?}", other),
447 }
448 }
449
450 #[test]
451 fn reject_yaml_sequence_root() {
452 let result = Configuration::from_yaml("- item1\n- item2\n");
453 assert!(result.is_err());
454 match result.unwrap_err() {
455 ConfigError::InvalidConfiguration(msg) => {
456 assert!(msg.contains("expected a YAML mapping"));
457 }
458 other => panic!("expected InvalidConfiguration, got: {:?}", other),
459 }
460 }
461
462 #[test]
465 fn parse_ignore_comments_extracts_comments() {
466 let yaml = "---\nignore:\n - CVE-2020-1234 # gem 1.0 (Critical) - Title\n - GHSA-aaaa-bbbb-cccc # rack 2.0 (Medium) - Other\n";
467 let comments = Configuration::parse_ignore_comments(yaml);
468 assert_eq!(comments.len(), 2);
469 assert_eq!(
470 comments.get("CVE-2020-1234").unwrap(),
471 "gem 1.0 (Critical) - Title"
472 );
473 assert_eq!(
474 comments.get("GHSA-aaaa-bbbb-cccc").unwrap(),
475 "rack 2.0 (Medium) - Other"
476 );
477 }
478
479 #[test]
480 fn parse_ignore_comments_skips_uncommented_entries() {
481 let yaml = "---\nignore:\n - CVE-2020-1234\n - GHSA-aaaa-bbbb-cccc # has comment\n";
482 let comments = Configuration::parse_ignore_comments(yaml);
483 assert_eq!(comments.len(), 1);
484 assert!(comments.contains_key("GHSA-aaaa-bbbb-cccc"));
485 assert!(!comments.contains_key("CVE-2020-1234"));
486 }
487
488 #[test]
489 fn parse_ignore_comments_empty_yaml() {
490 let comments = Configuration::parse_ignore_comments("---\nignore: []\n");
491 assert!(comments.is_empty());
492 }
493
494 #[test]
495 fn from_yaml_preserves_comments() {
496 let yaml = "---\nignore:\n - CVE-2020-1234 # gem 1.0 (Critical) - Title\n - GHSA-aaaa-bbbb-cccc\n";
497 let config = Configuration::from_yaml(yaml).unwrap();
498 assert_eq!(config.ignore.len(), 2);
499 assert_eq!(config.ignore_comments.len(), 1);
500 assert_eq!(
501 config.ignore_comments.get("CVE-2020-1234").unwrap(),
502 "gem 1.0 (Critical) - Title"
503 );
504 }
505
506 #[test]
507 fn save_and_reload_preserves_comments() {
508 let tmp = tempfile::tempdir().unwrap();
509 let path = tmp.path().join(".gem-audit.yml");
510
511 let mut ignore = HashSet::new();
512 ignore.insert("CVE-2020-1234".to_string());
513 ignore.insert("GHSA-aaaa-bbbb-cccc".to_string());
514
515 let mut comments = HashMap::new();
516 comments.insert(
517 "CVE-2020-1234".to_string(),
518 "gem 1.0 (Critical) - Title".to_string(),
519 );
520 comments.insert(
521 "GHSA-aaaa-bbbb-cccc".to_string(),
522 "rack 2.0 (Medium) - Other".to_string(),
523 );
524
525 let config = Configuration {
526 ignore,
527 max_db_age_days: None,
528 ..Configuration::default()
529 };
530 config.save(&path, Some(&comments)).unwrap();
531
532 let reloaded = Configuration::load(&path).unwrap();
533 assert_eq!(reloaded.ignore_comments.len(), 2);
534 assert_eq!(
535 reloaded.ignore_comments.get("CVE-2020-1234").unwrap(),
536 "gem 1.0 (Critical) - Title"
537 );
538 assert_eq!(
539 reloaded.ignore_comments.get("GHSA-aaaa-bbbb-cccc").unwrap(),
540 "rack 2.0 (Medium) - Other"
541 );
542 }
543}