Skip to main content

gem_audit/
configuration.rs

1use std::collections::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}
13
14/// Errors that can occur when loading a configuration file.
15#[derive(Debug, Error)]
16pub enum ConfigError {
17    /// The file was not found.
18    #[error("configuration file not found: {0}")]
19    FileNotFound(String),
20    /// The YAML content is invalid.
21    #[error("invalid YAML in configuration: {0}")]
22    InvalidYaml(String),
23    /// The configuration structure is invalid.
24    #[error("invalid configuration: {0}")]
25    InvalidConfiguration(String),
26}
27
28impl Configuration {
29    /// The default configuration file name.
30    pub const DEFAULT_FILE: &str = ".gem-audit.yml";
31
32    /// Legacy configuration file name for backward compatibility.
33    pub const LEGACY_FILE: &str = ".bundler-audit.yml";
34
35    /// Load configuration from a YAML file.
36    ///
37    /// Returns an error if the file exists but contains invalid content.
38    pub fn load(path: &Path) -> Result<Self, ConfigError> {
39        if !path.exists() {
40            return Err(ConfigError::FileNotFound(path.display().to_string()));
41        }
42
43        let content =
44            std::fs::read_to_string(path).map_err(|e| ConfigError::FileNotFound(e.to_string()))?;
45
46        Self::from_yaml(&content)
47    }
48
49    /// Load configuration from a YAML file path, returning a default
50    /// configuration if the file does not exist.
51    ///
52    /// When the primary path does not exist and its file name matches the
53    /// default (`.gem-audit.yml`), the legacy name (`.bundler-audit.yml`)
54    /// is tried in the same directory for backward compatibility.
55    pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
56        if path.exists() {
57            return Self::load(path);
58        }
59
60        // Fall back to legacy config name in the same directory
61        if path
62            .file_name()
63            .map(|f| f == Self::DEFAULT_FILE)
64            .unwrap_or(false)
65            && let Some(parent) = path.parent()
66        {
67            let legacy = parent.join(Self::LEGACY_FILE);
68            if legacy.exists() {
69                return Self::load(&legacy);
70            }
71        }
72
73        Ok(Self::default())
74    }
75
76    /// Parse configuration from a YAML string.
77    pub fn from_yaml(yaml: &str) -> Result<Self, ConfigError> {
78        let value: serde_yaml::Value =
79            serde_yaml::from_str(yaml).map_err(|e| ConfigError::InvalidYaml(e.to_string()))?;
80
81        // Must be a mapping (Hash)
82        let mapping = match value.as_mapping() {
83            Some(m) => m,
84            None => {
85                return Err(ConfigError::InvalidConfiguration(
86                    "expected a YAML mapping, not a scalar or sequence".to_string(),
87                ));
88            }
89        };
90
91        let mut ignore = HashSet::new();
92
93        if let Some(ignore_val) = mapping.get(serde_yaml::Value::String("ignore".to_string())) {
94            let arr = match ignore_val.as_sequence() {
95                Some(seq) => seq,
96                None => {
97                    return Err(ConfigError::InvalidConfiguration(
98                        "'ignore' must be an Array".to_string(),
99                    ));
100                }
101            };
102
103            for item in arr {
104                match item.as_str() {
105                    Some(s) => {
106                        ignore.insert(s.to_string());
107                    }
108                    None => {
109                        return Err(ConfigError::InvalidConfiguration(
110                            "'ignore' contains a non-String value".to_string(),
111                        ));
112                    }
113                }
114            }
115        }
116
117        let max_db_age_days = mapping
118            .get(serde_yaml::Value::String("max_db_age_days".to_string()))
119            .and_then(|v| v.as_u64());
120
121        Ok(Configuration {
122            ignore,
123            max_db_age_days,
124        })
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::path::PathBuf;
132
133    fn fixtures_dir() -> PathBuf {
134        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/config")
135    }
136
137    #[test]
138    fn load_valid_config() {
139        let config = Configuration::load(&fixtures_dir().join("valid.yml")).unwrap();
140        assert_eq!(config.ignore.len(), 2);
141        assert!(config.ignore.contains("CVE-123"));
142        assert!(config.ignore.contains("CVE-456"));
143    }
144
145    #[test]
146    fn load_empty_ignore_list() {
147        let config = Configuration::from_yaml("---\nignore: []\n").unwrap();
148        assert!(config.ignore.is_empty());
149    }
150
151    #[test]
152    fn load_no_ignore_key() {
153        let config = Configuration::from_yaml("---\n{}\n").unwrap();
154        assert!(config.ignore.is_empty());
155    }
156
157    #[test]
158    fn load_missing_file_returns_default() {
159        let config =
160            Configuration::load_or_default(Path::new("/nonexistent/.gem-audit.yml")).unwrap();
161        assert!(config.ignore.is_empty());
162    }
163
164    #[test]
165    fn load_missing_file_returns_error() {
166        let result = Configuration::load(Path::new("/nonexistent/.gem-audit.yml"));
167        assert!(result.is_err());
168        let err = result.unwrap_err();
169        assert!(matches!(err, ConfigError::FileNotFound(_)));
170    }
171
172    #[test]
173    fn reject_empty_yaml_file() {
174        let result = Configuration::load(&fixtures_dir().join("bad/empty.yml"));
175        assert!(result.is_err());
176    }
177
178    #[test]
179    fn reject_ignore_not_array() {
180        let result = Configuration::load(&fixtures_dir().join("bad/ignore_is_not_an_array.yml"));
181        assert!(result.is_err());
182        let err = result.unwrap_err();
183        match err {
184            ConfigError::InvalidConfiguration(msg) => {
185                assert!(msg.contains("Array"), "expected 'Array' in error: {}", msg);
186            }
187            other => panic!("expected InvalidConfiguration, got: {:?}", other),
188        }
189    }
190
191    #[test]
192    fn reject_ignore_contains_non_string() {
193        let result =
194            Configuration::load(&fixtures_dir().join("bad/ignore_contains_a_non_string.yml"));
195        assert!(result.is_err());
196        let err = result.unwrap_err();
197        match err {
198            ConfigError::InvalidConfiguration(msg) => {
199                assert!(
200                    msg.contains("non-String"),
201                    "expected 'non-String' in error: {}",
202                    msg
203                );
204            }
205            other => panic!("expected InvalidConfiguration, got: {:?}", other),
206        }
207    }
208
209    #[test]
210    fn default_config_is_empty() {
211        let config = Configuration::default();
212        assert!(config.ignore.is_empty());
213    }
214
215    #[test]
216    fn parse_real_dot_config() {
217        let yaml = "---\nignore:\n- OSVDB-89025\n";
218        let config = Configuration::from_yaml(yaml).unwrap();
219        assert_eq!(config.ignore.len(), 1);
220        assert!(config.ignore.contains("OSVDB-89025"));
221    }
222
223    #[test]
224    fn parse_max_db_age_days() {
225        let yaml = "---\nmax_db_age_days: 7\n";
226        let config = Configuration::from_yaml(yaml).unwrap();
227        assert_eq!(config.max_db_age_days, Some(7));
228    }
229
230    #[test]
231    fn parse_config_without_max_db_age() {
232        let yaml = "---\nignore:\n- CVE-123\n";
233        let config = Configuration::from_yaml(yaml).unwrap();
234        assert_eq!(config.max_db_age_days, None);
235    }
236
237    #[test]
238    fn display_errors() {
239        let e1 = ConfigError::FileNotFound("foo.yml".to_string());
240        assert!(e1.to_string().contains("foo.yml"));
241
242        let e2 = ConfigError::InvalidYaml("bad".to_string());
243        assert!(e2.to_string().contains("bad"));
244
245        let e3 = ConfigError::InvalidConfiguration("oops".to_string());
246        assert!(e3.to_string().contains("oops"));
247    }
248
249    // ========== Legacy Config Fallback ==========
250
251    #[test]
252    fn legacy_config_fallback() {
253        let tmp = std::env::temp_dir().join("gem_audit_test_legacy");
254        let _ = std::fs::remove_dir_all(&tmp);
255        std::fs::create_dir_all(&tmp).unwrap();
256
257        // Only create the legacy file
258        std::fs::write(
259            tmp.join(".bundler-audit.yml"),
260            "---\nignore:\n  - CVE-LEGACY-001\n",
261        )
262        .unwrap();
263
264        // load_or_default with default name should fall back
265        let config = Configuration::load_or_default(&tmp.join(".gem-audit.yml")).unwrap();
266        assert!(config.ignore.contains("CVE-LEGACY-001"));
267
268        std::fs::remove_dir_all(&tmp).unwrap();
269    }
270
271    #[test]
272    fn no_legacy_fallback_for_custom_name() {
273        // When a custom config name is used, legacy fallback should NOT apply
274        let config = Configuration::load_or_default(Path::new("/nonexistent/custom.yml")).unwrap();
275        assert!(config.ignore.is_empty());
276    }
277
278    // ========== YAML scalar root rejection ==========
279
280    #[test]
281    fn reject_yaml_scalar_root() {
282        let result = Configuration::from_yaml("hello");
283        assert!(result.is_err());
284        match result.unwrap_err() {
285            ConfigError::InvalidConfiguration(msg) => {
286                assert!(msg.contains("expected a YAML mapping"));
287            }
288            other => panic!("expected InvalidConfiguration, got: {:?}", other),
289        }
290    }
291
292    #[test]
293    fn reject_yaml_sequence_root() {
294        let result = Configuration::from_yaml("- item1\n- item2\n");
295        assert!(result.is_err());
296        match result.unwrap_err() {
297            ConfigError::InvalidConfiguration(msg) => {
298                assert!(msg.contains("expected a YAML mapping"));
299            }
300            other => panic!("expected InvalidConfiguration, got: {:?}", other),
301        }
302    }
303}