1use deps_core::policy_config::{DiagnosticsConfig, PolicyConfig};
8use serde::Deserialize;
9use std::path::{Path, PathBuf};
10
11pub const DEFAULT_CONFIG_FILENAME: &str = "deps.toml";
14
15const MAX_CONFIG_FILE_SIZE: u64 = 1_000_000;
18
19#[non_exhaustive]
27#[derive(Debug, Deserialize, Default)]
28#[serde(deny_unknown_fields)]
29pub struct CliConfig {
30 #[serde(flatten)]
33 pub policy: PolicyConfig,
34}
35
36#[derive(Debug, thiserror::Error)]
38pub enum ConfigError {
39 #[error("failed to read config file {path}: {source}")]
42 Io {
43 path: PathBuf,
45 #[source]
47 source: std::io::Error,
48 },
49 #[error("config file {path} exceeds the {MAX_CONFIG_FILE_SIZE}-byte size cap")]
51 TooLarge {
52 path: PathBuf,
54 },
55 #[error("failed to parse TOML in {path}: {source}")]
57 Toml {
58 path: PathBuf,
60 #[source]
62 source: toml_span::Error,
63 },
64 #[error("invalid configuration in {path}: {source}")]
67 Deserialize {
68 path: PathBuf,
70 #[source]
72 source: serde_json::Error,
73 },
74}
75
76pub fn load(explicit_path: Option<&Path>, default_dir: &Path) -> Result<CliConfig, ConfigError> {
113 let (path, required): (PathBuf, bool) = match explicit_path {
114 Some(path) => (path.to_path_buf(), true),
115 None => (default_dir.join(DEFAULT_CONFIG_FILENAME), false),
116 };
117
118 let content = match deps_core::fs_probe::read_to_string_capped(&path, MAX_CONFIG_FILE_SIZE) {
119 Ok(Some(content)) => content,
120 Ok(None) => return Err(ConfigError::TooLarge { path }),
121 Err(source) if !required && source.kind() == std::io::ErrorKind::NotFound => {
122 return Ok(CliConfig::default());
123 }
124 Err(source) => return Err(ConfigError::Io { path, source }),
125 };
126
127 let mut config = parse(&content, &path)?;
128 if !required {
129 for section in ignored_sections(&config.policy) {
130 eprintln!(
131 "deps-cli: warning: {path}'s [{section}] section was auto-discovered, not given via --config, and is ignored — see `deps_cli::config::safe_auto_discovered_policy`'s doc for why",
132 path = path.display(),
133 );
134 }
135 config.policy = safe_auto_discovered_policy(config.policy);
136 }
137 Ok(config)
138}
139
140#[must_use]
163pub fn safe_auto_discovered_policy(parsed: PolicyConfig) -> PolicyConfig {
164 PolicyConfig {
165 diagnostics: DiagnosticsConfig::new()
166 .with_outdated_severity(parsed.diagnostics.outdated_severity)
167 .with_unknown_severity(parsed.diagnostics.unknown_severity)
168 .with_yanked_severity(parsed.diagnostics.yanked_severity)
169 .with_unsatisfiable_severity(parsed.diagnostics.unsatisfiable_severity)
170 .with_deprecated_severity(parsed.diagnostics.deprecated_severity)
171 .with_mutable_ref_pin_severity(parsed.diagnostics.mutable_ref_pin_severity),
172 ..PolicyConfig::default()
173 }
174}
175
176fn ignored_sections(policy: &PolicyConfig) -> Vec<&'static str> {
182 let default = PolicyConfig::default();
183 let mut sections = Vec::new();
184
185 if policy.diagnostics.mutable_ref_pin_enabled != default.diagnostics.mutable_ref_pin_enabled
186 || policy.diagnostics.vulnerabilities_enabled != default.diagnostics.vulnerabilities_enabled
187 {
188 sections.push("diagnostics");
189 }
190 if policy.cache.enabled != default.cache.enabled
191 || policy.cache.fetch_timeout_secs != default.cache.fetch_timeout_secs
192 || policy.cache.max_concurrent_fetches != default.cache.max_concurrent_fetches
193 {
194 sections.push("cache");
195 }
196 if policy.freshness.enabled != default.freshness.enabled
197 || policy.freshness.cooldown_secs != default.freshness.cooldown_secs
198 {
199 sections.push("freshness");
200 }
201 if policy.supply_chain.enabled != default.supply_chain.enabled {
202 sections.push("supply_chain");
203 }
204 if policy.registries.workspace_registries != default.registries.workspace_registries
205 || policy.registries.nuget_user_profile_sources
206 != default.registries.nuget_user_profile_sources
207 || policy.registries.gitlab_instance_host != default.registries.gitlab_instance_host
208 {
209 sections.push("registries");
210 }
211 if policy.network.offline != default.network.offline {
212 sections.push("network");
213 }
214 if policy.license_policy.allow != default.license_policy.allow
215 || policy.license_policy.deny != default.license_policy.deny
216 {
217 sections.push("license_policy");
218 }
219
220 sections
221}
222
223fn parse(content: &str, path: &Path) -> Result<CliConfig, ConfigError> {
231 let value = toml_span::parse(content).map_err(|source| ConfigError::Toml {
232 path: path.to_path_buf(),
233 source,
234 })?;
235 let json = serde_json::to_value(&value).map_err(|source| ConfigError::Deserialize {
236 path: path.to_path_buf(),
237 source,
238 })?;
239 serde_json::from_value(json).map_err(|source| ConfigError::Deserialize {
240 path: path.to_path_buf(),
241 source,
242 })
243}
244
245pub fn apply_overrides(mut config: CliConfig, offline: bool, cooldown: Option<u64>) -> CliConfig {
252 if offline {
253 config.policy.network.offline = true;
254 }
255 if let Some(cooldown_secs) = cooldown {
256 config.policy.freshness.cooldown_secs = cooldown_secs;
257 }
258 config
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::io::Write as _;
265
266 fn write_temp_toml(content: &str) -> tempfile::NamedTempFile {
267 let mut file = tempfile::NamedTempFile::new().expect("create temp file");
268 file.write_all(content.as_bytes()).expect("write temp file");
269 file
270 }
271
272 #[test]
273 fn test_load_missing_default_file_returns_defaults() {
274 let dir = tempfile::tempdir().expect("create temp dir");
275 let config = load(None, dir.path()).expect("missing default file is not an error");
276 assert_eq!(
277 config.policy.network.offline,
278 PolicyConfig::default().network.offline
279 );
280 }
281
282 #[test]
283 fn test_load_missing_explicit_path_is_an_error() {
284 let missing = PathBuf::from("/nonexistent/path/to/deps.toml");
285 let result = load(Some(&missing), Path::new("."));
286 assert!(matches!(result, Err(ConfigError::Io { .. })));
287 }
288
289 #[test]
290 fn test_load_valid_toml_parses_policy_sections() {
291 let file = write_temp_toml(
292 r"
293 [network]
294 offline = true
295
296 [freshness]
297 cooldown_secs = 60
298 ",
299 );
300 let config = load(Some(file.path()), Path::new(".")).expect("valid TOML must parse");
301 assert!(config.policy.network.offline);
302 assert_eq!(config.policy.freshness.cooldown_secs, 60);
303 }
304
305 #[test]
306 fn test_load_malformed_toml_is_an_error() {
307 let file = write_temp_toml("this is not [ valid toml");
308 let result = load(Some(file.path()), Path::new("."));
309 assert!(matches!(result, Err(ConfigError::Toml { .. })));
310 }
311
312 #[test]
313 fn test_load_unknown_top_level_key_is_rejected() {
314 let file = write_temp_toml("totally_unknown_key = true\n");
315 let result = load(Some(file.path()), Path::new("."));
316 assert!(matches!(result, Err(ConfigError::Deserialize { .. })));
317 }
318
319 #[test]
320 fn test_load_unknown_key_nested_in_known_section_is_tolerated() {
321 let file = write_temp_toml(
322 r#"
323 [network]
324 offline = true
325 future_field = "ignored"
326 "#,
327 );
328 let config = load(Some(file.path()), Path::new("."))
329 .expect("nested unknown key must not reject the payload");
330 assert!(config.policy.network.offline);
331 }
332
333 #[test]
341 fn test_load_auto_discovery_resolves_against_given_default_dir_not_cwd() {
342 let dir = tempfile::tempdir().expect("create temp dir");
343 std::fs::write(
344 dir.path().join(DEFAULT_CONFIG_FILENAME),
345 "[diagnostics]\noutdated_severity = 1\n",
346 )
347 .expect("write deps.toml");
348 let config = load(None, dir.path()).expect("deps.toml in default_dir must be found");
351 assert_eq!(
352 config.policy.diagnostics.outdated_severity,
353 deps_core::diagnostic::Severity::Error
354 );
355 }
356
357 #[test]
361 fn test_load_auto_discovered_registries_section_is_ignored() {
362 let dir = tempfile::tempdir().expect("create temp dir");
363 std::fs::write(
364 dir.path().join(DEFAULT_CONFIG_FILENAME),
365 r#"
366 [registries]
367 gitlab_instance_host = "attacker-host.invalid"
368 workspace_registries = "all"
369 "#,
370 )
371 .expect("write deps.toml");
372 let config = load(None, dir.path()).expect("auto-discovered file must still load");
373 assert_eq!(config.policy.registries.gitlab_instance_host, "");
374 assert_eq!(
375 config.policy.registries.workspace_registries,
376 deps_core::policy_config::WorkspaceRegistriesSetting::PublicOnly
377 );
378 }
379
380 #[test]
384 fn test_load_auto_discovered_diagnostics_enabled_flags_are_ignored() {
385 let dir = tempfile::tempdir().expect("create temp dir");
386 std::fs::write(
387 dir.path().join(DEFAULT_CONFIG_FILENAME),
388 r"
389 [diagnostics]
390 mutable_ref_pin_enabled = false
391 vulnerabilities_enabled = false
392 ",
393 )
394 .expect("write deps.toml");
395 let config = load(None, dir.path()).expect("auto-discovered file must still load");
396 assert!(config.policy.diagnostics.mutable_ref_pin_enabled);
397 assert!(config.policy.diagnostics.vulnerabilities_enabled);
398 }
399
400 #[test]
404 fn test_load_auto_discovered_network_offline_is_ignored() {
405 let dir = tempfile::tempdir().expect("create temp dir");
406 std::fs::write(
407 dir.path().join(DEFAULT_CONFIG_FILENAME),
408 "[network]\noffline = true\n",
409 )
410 .expect("write deps.toml");
411 let config = load(None, dir.path()).expect("auto-discovered file must still load");
412 assert!(!config.policy.network.offline);
413 }
414
415 #[test]
419 fn test_load_auto_discovered_remaining_gate_relevant_sections_are_ignored() {
420 let dir = tempfile::tempdir().expect("create temp dir");
421 std::fs::write(
422 dir.path().join(DEFAULT_CONFIG_FILENAME),
423 r#"
424 [freshness]
425 cooldown_secs = 0
426
427 [license_policy]
428 allow = ["GPL-3.0"]
429
430 [cache]
431 fetch_timeout_secs = 1
432
433 [supply_chain]
434 enabled = false
435 "#,
436 )
437 .expect("write deps.toml");
438 let config = load(None, dir.path()).expect("auto-discovered file must still load");
439 let default = PolicyConfig::default();
440 assert_eq!(
441 config.policy.freshness.cooldown_secs,
442 default.freshness.cooldown_secs
443 );
444 assert_eq!(
445 config.policy.license_policy.allow,
446 default.license_policy.allow
447 );
448 assert_eq!(
449 config.policy.cache.fetch_timeout_secs,
450 default.cache.fetch_timeout_secs
451 );
452 assert_eq!(
453 config.policy.supply_chain.enabled,
454 default.supply_chain.enabled
455 );
456 }
457
458 #[test]
462 fn test_load_auto_discovered_severity_values_are_kept() {
463 let dir = tempfile::tempdir().expect("create temp dir");
464 std::fs::write(
465 dir.path().join(DEFAULT_CONFIG_FILENAME),
466 "[diagnostics]\nyanked_severity = 4\n",
467 )
468 .expect("write deps.toml");
469 let config = load(None, dir.path()).expect("auto-discovered file must still load");
470 assert_eq!(
471 config.policy.diagnostics.yanked_severity,
472 deps_core::diagnostic::Severity::Hint
473 );
474 }
475
476 #[test]
479 fn test_load_explicit_config_registries_section_is_trusted() {
480 let file = write_temp_toml(
481 r#"
482 [registries]
483 gitlab_instance_host = "gitlab.mycorp.dev"
484 "#,
485 );
486 let config = load(Some(file.path()), Path::new("."))
487 .expect("explicit config with a registries section must load");
488 assert_eq!(
489 config.policy.registries.gitlab_instance_host,
490 "gitlab.mycorp.dev"
491 );
492 }
493
494 #[test]
495 fn test_apply_overrides_offline_flag_forces_true() {
496 let config = apply_overrides(CliConfig::default(), true, None);
497 assert!(config.policy.network.offline);
498 }
499
500 #[test]
501 fn test_apply_overrides_offline_absent_keeps_file_value() {
502 let mut base = CliConfig::default();
503 base.policy.network.offline = true;
504 let config = apply_overrides(base, false, None);
505 assert!(
506 config.policy.network.offline,
507 "absent flag must not clear a file-set true"
508 );
509 }
510
511 #[test]
512 fn test_apply_overrides_cooldown_replaces_file_value() {
513 let mut base = CliConfig::default();
514 base.policy.freshness.cooldown_secs = 999;
515 let config = apply_overrides(base, false, Some(42));
516 assert_eq!(config.policy.freshness.cooldown_secs, 42);
517 }
518
519 #[test]
520 fn test_apply_overrides_no_cooldown_keeps_file_value() {
521 let mut base = CliConfig::default();
522 base.policy.freshness.cooldown_secs = 999;
523 let config = apply_overrides(base, false, None);
524 assert_eq!(config.policy.freshness.cooldown_secs, 999);
525 }
526}