Skip to main content

gem_audit/
configuration.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use thiserror::Error;
4
5/// Configuration loaded from a `.gem-audit.yml` file.
6#[derive(Debug, Clone, Default)]
7pub struct Configuration {
8    /// Advisory IDs to ignore during scanning.
9    pub ignore: HashSet<String>,
10    /// Maximum database age in days before warning.
11    pub max_db_age_days: Option<u64>,
12    /// Inline comments parsed from the YAML file (advisory ID → comment text).
13    pub ignore_comments: HashMap<String, String>,
14}
15
16/// Errors that can occur when loading a configuration file.
17#[derive(Debug, Error)]
18pub enum ConfigError {
19    /// The file was not found.
20    #[error("configuration file not found: {0}")]
21    FileNotFound(String),
22    /// The YAML content is invalid.
23    #[error("invalid YAML in configuration: {0}")]
24    InvalidYaml(String),
25    /// The configuration structure is invalid.
26    #[error("invalid configuration: {0}")]
27    InvalidConfiguration(String),
28}
29
30impl Configuration {
31    /// The default configuration file name.
32    pub const DEFAULT_FILE: &str = ".gem-audit.yml";
33
34    /// Legacy configuration file name for backward compatibility.
35    pub const LEGACY_FILE: &str = ".bundler-audit.yml";
36
37    /// Load configuration from a YAML file.
38    ///
39    /// Returns an error if the file exists but contains invalid content.
40    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    /// Load configuration from a YAML file path, returning a default
52    /// configuration if the file does not exist.
53    ///
54    /// When the primary path does not exist and its file name matches the
55    /// default (`.gem-audit.yml`), the legacy name (`.bundler-audit.yml`)
56    /// is tried in the same directory for backward compatibility.
57    pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
58        if path.exists() {
59            return Self::load(path);
60        }
61
62        // Fall back to legacy config name in the same directory
63        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    /// Save configuration to a YAML file.
79    ///
80    /// Writes the `ignore` list and optional `max_db_age_days` in a stable,
81    /// sorted order so that diffs are minimal across runs.
82    ///
83    /// An optional `comments` map can annotate each advisory ID with context
84    /// (e.g. gem name, version, criticality).
85    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()); // trailing newline
115        std::fs::write(path, lines.join("\n")).map_err(|e| {
116            ConfigError::InvalidConfiguration(format!("failed to write {}: {}", path.display(), e))
117        })
118    }
119
120    /// Extract inline `# comments` from YAML ignore entries.
121    ///
122    /// Matches lines in the format produced by [`save()`]: `  - ID  # comment`.
123    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    /// Parse configuration from a YAML string.
135    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        // Must be a mapping (Hash)
142        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    // ========== Save ==========
299
300    #[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        // Should be sorted
356        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        // Should still be loadable (YAML ignores comments)
393        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    // ========== Legacy Config Fallback ==========
412
413    #[test]
414    fn legacy_config_fallback() {
415        let tmp = tempfile::tempdir().unwrap();
416
417        // Only create the legacy file
418        std::fs::write(
419            tmp.path().join(".bundler-audit.yml"),
420            "---\nignore:\n  - CVE-LEGACY-001\n",
421        )
422        .unwrap();
423
424        // load_or_default with default name should fall back
425        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        // When a custom config name is used, legacy fallback should NOT apply
432        let config = Configuration::load_or_default(Path::new("/nonexistent/custom.yml")).unwrap();
433        assert!(config.ignore.is_empty());
434    }
435
436    // ========== YAML scalar root rejection ==========
437
438    #[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    // ========== Comment Parsing ==========
463
464    #[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}