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}: {message}")]
57 Toml {
58 path: PathBuf,
60 message: String,
68 },
69 #[error("invalid configuration in {path}: {message}")]
72 Deserialize {
73 path: PathBuf,
75 message: String,
84 },
85}
86
87pub fn load(explicit_path: Option<&Path>, default_dir: &Path) -> Result<CliConfig, ConfigError> {
124 let (path, required): (PathBuf, bool) = match explicit_path {
125 Some(path) => (path.to_path_buf(), true),
126 None => (default_dir.join(DEFAULT_CONFIG_FILENAME), false),
127 };
128
129 let content = match deps_core::fs_probe::read_to_string_capped(&path, MAX_CONFIG_FILE_SIZE) {
130 Ok(Some(content)) => content,
131 Ok(None) => return Err(ConfigError::TooLarge { path }),
132 Err(source) if !required && source.kind() == std::io::ErrorKind::NotFound => {
133 return Ok(CliConfig::default());
134 }
135 Err(source) => return Err(ConfigError::Io { path, source }),
136 };
137
138 let mut config = parse(&content, &path)?;
139 if !required {
140 for section in ignored_sections(&config.policy) {
141 eprintln!(
142 "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",
143 path = path.display(),
144 );
145 }
146 config.policy = safe_auto_discovered_policy(config.policy);
147 }
148 Ok(config)
149}
150
151#[must_use]
174pub fn safe_auto_discovered_policy(parsed: PolicyConfig) -> PolicyConfig {
175 PolicyConfig {
176 diagnostics: DiagnosticsConfig::new()
177 .with_outdated_severity(parsed.diagnostics.outdated_severity)
178 .with_unknown_severity(parsed.diagnostics.unknown_severity)
179 .with_yanked_severity(parsed.diagnostics.yanked_severity)
180 .with_unsatisfiable_severity(parsed.diagnostics.unsatisfiable_severity)
181 .with_deprecated_severity(parsed.diagnostics.deprecated_severity)
182 .with_mutable_ref_pin_severity(parsed.diagnostics.mutable_ref_pin_severity),
183 ..PolicyConfig::default()
184 }
185}
186
187fn ignored_sections(policy: &PolicyConfig) -> Vec<&'static str> {
193 let default = PolicyConfig::default();
194 let mut sections = Vec::new();
195
196 if policy.diagnostics.mutable_ref_pin_enabled != default.diagnostics.mutable_ref_pin_enabled
197 || policy.diagnostics.vulnerabilities_enabled != default.diagnostics.vulnerabilities_enabled
198 {
199 sections.push("diagnostics");
200 }
201 if policy.cache.enabled != default.cache.enabled
202 || policy.cache.fetch_timeout_secs != default.cache.fetch_timeout_secs
203 || policy.cache.max_concurrent_fetches != default.cache.max_concurrent_fetches
204 {
205 sections.push("cache");
206 }
207 if policy.freshness.enabled != default.freshness.enabled
208 || policy.freshness.cooldown_secs != default.freshness.cooldown_secs
209 {
210 sections.push("freshness");
211 }
212 if policy.supply_chain.enabled != default.supply_chain.enabled {
213 sections.push("supply_chain");
214 }
215 if policy.registries.workspace_registries != default.registries.workspace_registries
216 || policy.registries.nuget_user_profile_sources
217 != default.registries.nuget_user_profile_sources
218 || policy.registries.gitlab_instance_host != default.registries.gitlab_instance_host
219 {
220 sections.push("registries");
221 }
222 if policy.network.offline != default.network.offline {
223 sections.push("network");
224 }
225 if policy.license_policy.allow != default.license_policy.allow
226 || policy.license_policy.deny != default.license_policy.deny
227 {
228 sections.push("license_policy");
229 }
230
231 sections
232}
233
234fn parse(content: &str, path: &Path) -> Result<CliConfig, ConfigError> {
242 let value = toml_span::parse(content).map_err(|source| ConfigError::Toml {
243 path: path.to_path_buf(),
244 message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
245 .into_owned(),
246 })?;
247 let json = serde_json::to_value(&value).map_err(|source| ConfigError::Deserialize {
248 path: path.to_path_buf(),
249 message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
250 .into_owned(),
251 })?;
252 serde_json::from_value(json).map_err(|source| ConfigError::Deserialize {
253 path: path.to_path_buf(),
254 message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
255 .into_owned(),
256 })
257}
258
259pub fn apply_overrides(mut config: CliConfig, offline: bool, cooldown: Option<u64>) -> CliConfig {
266 if offline {
267 config.policy.network.offline = true;
268 }
269 if let Some(cooldown_secs) = cooldown {
270 config.policy.freshness.cooldown_secs = cooldown_secs;
271 }
272 config
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use std::io::Write as _;
279
280 fn write_temp_toml(content: &str) -> tempfile::NamedTempFile {
281 let mut file = tempfile::NamedTempFile::new().expect("create temp file");
282 file.write_all(content.as_bytes()).expect("write temp file");
283 file
284 }
285
286 #[test]
287 fn test_load_missing_default_file_returns_defaults() {
288 let dir = tempfile::tempdir().expect("create temp dir");
289 let config = load(None, dir.path()).expect("missing default file is not an error");
290 assert_eq!(
291 config.policy.network.offline,
292 PolicyConfig::default().network.offline
293 );
294 }
295
296 #[test]
297 fn test_load_missing_explicit_path_is_an_error() {
298 let missing = PathBuf::from("/nonexistent/path/to/deps.toml");
299 let result = load(Some(&missing), Path::new("."));
300 assert!(matches!(result, Err(ConfigError::Io { .. })));
301 }
302
303 #[test]
304 fn test_load_valid_toml_parses_policy_sections() {
305 let file = write_temp_toml(
306 r"
307 [network]
308 offline = true
309
310 [freshness]
311 cooldown_secs = 60
312 ",
313 );
314 let config = load(Some(file.path()), Path::new(".")).expect("valid TOML must parse");
315 assert!(config.policy.network.offline);
316 assert_eq!(config.policy.freshness.cooldown_secs, 60);
317 }
318
319 #[test]
320 fn test_load_malformed_toml_is_an_error() {
321 let file = write_temp_toml("this is not [ valid toml");
322 let result = load(Some(file.path()), Path::new("."));
323 assert!(matches!(result, Err(ConfigError::Toml { .. })));
324 }
325
326 #[test]
329 fn test_load_duplicate_table_toml_error_redacts_credential() {
330 let file = write_temp_toml(
331 r#"
332["https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"]
333a = 1
334["https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"]
335b = 2
336"#,
337 );
338 let result = load(Some(file.path()), Path::new("."));
339 let message = result.unwrap_err().to_string();
340 assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
341 assert!(!message.contains("svcacct"));
342 assert!(message.contains("pkg.internal.corp"));
343 }
344
345 #[test]
346 fn test_load_duplicate_table_toml_error_benign_name_unchanged() {
347 let content = r"
348[serde]
349a = 1
350[serde]
351b = 2
352";
353 let file = write_temp_toml(content);
354
355 let raw_err = toml_span::parse(content).unwrap_err();
357 let expected = format!(
358 "failed to parse TOML in {}: {raw_err}",
359 file.path().display()
360 );
361
362 let result = load(Some(file.path()), Path::new("."));
363 assert_eq!(result.unwrap_err().to_string(), expected);
364 }
365
366 #[test]
367 fn test_load_unknown_top_level_key_is_rejected() {
368 let file = write_temp_toml("totally_unknown_key = true\n");
369 let result = load(Some(file.path()), Path::new("."));
370 assert!(matches!(result, Err(ConfigError::Deserialize { .. })));
371 }
372
373 #[test]
378 fn test_load_unknown_field_credential_shaped_name_redacted() {
379 let file = write_temp_toml(
380 "\"https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x\" = true\n",
381 );
382 let message = load(Some(file.path()), Path::new("."))
383 .unwrap_err()
384 .to_string();
385 assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
386 assert!(!message.contains("svcacct"));
387 assert!(message.contains("pkg.internal.corp"));
388 }
389
390 #[test]
393 fn test_load_wrong_typed_value_credential_shaped_string_redacted() {
394 let file = write_temp_toml(
395 r#"
396 [cache]
397 enabled = "https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"
398 "#,
399 );
400 let message = load(Some(file.path()), Path::new("."))
401 .unwrap_err()
402 .to_string();
403 assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
404 assert!(!message.contains("svcacct"));
405 assert!(message.contains("pkg.internal.corp"));
406 }
407
408 #[test]
409 fn test_load_unknown_field_benign_name_unchanged() {
410 let content = "totally_unknown_key = true\n";
411 let file = write_temp_toml(content);
412
413 let value = toml_span::parse(content).unwrap();
415 let json = serde_json::to_value(&value).unwrap();
416 let raw_err = serde_json::from_value::<CliConfig>(json).unwrap_err();
417 let expected = format!(
418 "invalid configuration in {}: {raw_err}",
419 file.path().display()
420 );
421
422 let message = load(Some(file.path()), Path::new("."))
423 .unwrap_err()
424 .to_string();
425 assert_eq!(message, expected);
426 }
427
428 #[test]
429 fn test_load_unknown_key_nested_in_known_section_is_tolerated() {
430 let file = write_temp_toml(
431 r#"
432 [network]
433 offline = true
434 future_field = "ignored"
435 "#,
436 );
437 let config = load(Some(file.path()), Path::new("."))
438 .expect("nested unknown key must not reject the payload");
439 assert!(config.policy.network.offline);
440 }
441
442 #[test]
450 fn test_load_auto_discovery_resolves_against_given_default_dir_not_cwd() {
451 let dir = tempfile::tempdir().expect("create temp dir");
452 std::fs::write(
453 dir.path().join(DEFAULT_CONFIG_FILENAME),
454 "[diagnostics]\noutdated_severity = 1\n",
455 )
456 .expect("write deps.toml");
457 let config = load(None, dir.path()).expect("deps.toml in default_dir must be found");
460 assert_eq!(
461 config.policy.diagnostics.outdated_severity,
462 deps_core::diagnostic::Severity::Error
463 );
464 }
465
466 #[test]
470 fn test_load_auto_discovered_registries_section_is_ignored() {
471 let dir = tempfile::tempdir().expect("create temp dir");
472 std::fs::write(
473 dir.path().join(DEFAULT_CONFIG_FILENAME),
474 r#"
475 [registries]
476 gitlab_instance_host = "attacker-host.invalid"
477 workspace_registries = "all"
478 "#,
479 )
480 .expect("write deps.toml");
481 let config = load(None, dir.path()).expect("auto-discovered file must still load");
482 assert_eq!(config.policy.registries.gitlab_instance_host, "");
483 assert_eq!(
484 config.policy.registries.workspace_registries,
485 deps_core::policy_config::WorkspaceRegistriesSetting::PublicOnly
486 );
487 }
488
489 #[test]
493 fn test_load_auto_discovered_diagnostics_enabled_flags_are_ignored() {
494 let dir = tempfile::tempdir().expect("create temp dir");
495 std::fs::write(
496 dir.path().join(DEFAULT_CONFIG_FILENAME),
497 r"
498 [diagnostics]
499 mutable_ref_pin_enabled = false
500 vulnerabilities_enabled = false
501 ",
502 )
503 .expect("write deps.toml");
504 let config = load(None, dir.path()).expect("auto-discovered file must still load");
505 assert!(config.policy.diagnostics.mutable_ref_pin_enabled);
506 assert!(config.policy.diagnostics.vulnerabilities_enabled);
507 }
508
509 #[test]
513 fn test_load_auto_discovered_network_offline_is_ignored() {
514 let dir = tempfile::tempdir().expect("create temp dir");
515 std::fs::write(
516 dir.path().join(DEFAULT_CONFIG_FILENAME),
517 "[network]\noffline = true\n",
518 )
519 .expect("write deps.toml");
520 let config = load(None, dir.path()).expect("auto-discovered file must still load");
521 assert!(!config.policy.network.offline);
522 }
523
524 #[test]
528 fn test_load_auto_discovered_remaining_gate_relevant_sections_are_ignored() {
529 let dir = tempfile::tempdir().expect("create temp dir");
530 std::fs::write(
531 dir.path().join(DEFAULT_CONFIG_FILENAME),
532 r#"
533 [freshness]
534 cooldown_secs = 0
535
536 [license_policy]
537 allow = ["GPL-3.0"]
538
539 [cache]
540 fetch_timeout_secs = 1
541
542 [supply_chain]
543 enabled = false
544 "#,
545 )
546 .expect("write deps.toml");
547 let config = load(None, dir.path()).expect("auto-discovered file must still load");
548 let default = PolicyConfig::default();
549 assert_eq!(
550 config.policy.freshness.cooldown_secs,
551 default.freshness.cooldown_secs
552 );
553 assert_eq!(
554 config.policy.license_policy.allow,
555 default.license_policy.allow
556 );
557 assert_eq!(
558 config.policy.cache.fetch_timeout_secs,
559 default.cache.fetch_timeout_secs
560 );
561 assert_eq!(
562 config.policy.supply_chain.enabled,
563 default.supply_chain.enabled
564 );
565 }
566
567 #[test]
571 fn test_load_auto_discovered_severity_values_are_kept() {
572 let dir = tempfile::tempdir().expect("create temp dir");
573 std::fs::write(
574 dir.path().join(DEFAULT_CONFIG_FILENAME),
575 "[diagnostics]\nyanked_severity = 4\n",
576 )
577 .expect("write deps.toml");
578 let config = load(None, dir.path()).expect("auto-discovered file must still load");
579 assert_eq!(
580 config.policy.diagnostics.yanked_severity,
581 deps_core::diagnostic::Severity::Hint
582 );
583 }
584
585 #[test]
588 fn test_load_explicit_config_registries_section_is_trusted() {
589 let file = write_temp_toml(
590 r#"
591 [registries]
592 gitlab_instance_host = "gitlab.mycorp.dev"
593 "#,
594 );
595 let config = load(Some(file.path()), Path::new("."))
596 .expect("explicit config with a registries section must load");
597 assert_eq!(
598 config.policy.registries.gitlab_instance_host,
599 "gitlab.mycorp.dev"
600 );
601 }
602
603 #[test]
604 fn test_apply_overrides_offline_flag_forces_true() {
605 let config = apply_overrides(CliConfig::default(), true, None);
606 assert!(config.policy.network.offline);
607 }
608
609 #[test]
610 fn test_apply_overrides_offline_absent_keeps_file_value() {
611 let mut base = CliConfig::default();
612 base.policy.network.offline = true;
613 let config = apply_overrides(base, false, None);
614 assert!(
615 config.policy.network.offline,
616 "absent flag must not clear a file-set true"
617 );
618 }
619
620 #[test]
621 fn test_apply_overrides_cooldown_replaces_file_value() {
622 let mut base = CliConfig::default();
623 base.policy.freshness.cooldown_secs = 999;
624 let config = apply_overrides(base, false, Some(42));
625 assert_eq!(config.policy.freshness.cooldown_secs, 42);
626 }
627
628 #[test]
629 fn test_apply_overrides_no_cooldown_keeps_file_value() {
630 let mut base = CliConfig::default();
631 base.policy.freshness.cooldown_secs = 999;
632 let config = apply_overrides(base, false, None);
633 assert_eq!(config.policy.freshness.cooldown_secs, 999);
634 }
635}