gem_audit/
configuration.rs1use std::collections::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}
13
14#[derive(Debug, Error)]
16pub enum ConfigError {
17 #[error("configuration file not found: {0}")]
19 FileNotFound(String),
20 #[error("invalid YAML in configuration: {0}")]
22 InvalidYaml(String),
23 #[error("invalid configuration: {0}")]
25 InvalidConfiguration(String),
26}
27
28impl Configuration {
29 pub const DEFAULT_FILE: &str = ".gem-audit.yml";
31
32 pub const LEGACY_FILE: &str = ".bundler-audit.yml";
34
35 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 pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
56 if path.exists() {
57 return Self::load(path);
58 }
59
60 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 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 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 #[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 std::fs::write(
259 tmp.join(".bundler-audit.yml"),
260 "---\nignore:\n - CVE-LEGACY-001\n",
261 )
262 .unwrap();
263
264 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 let config = Configuration::load_or_default(Path::new("/nonexistent/custom.yml")).unwrap();
275 assert!(config.ignore.is_empty());
276 }
277
278 #[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}