Skip to main content

deps_cli/
config.rs

1//! `CliConfig` and `deps.toml` loading.
2//!
3//! Reuses [`deps_core::policy_config::PolicyConfig`] — the same type `deps-lsp`'s
4//! `DepsConfig` composes — so `deps-cli` never parses a second, independently-maintained
5//! copy of the policy schema (constitution principle 1).
6
7use deps_core::policy_config::{DiagnosticsConfig, PolicyConfig};
8use serde::Deserialize;
9use std::path::{Path, PathBuf};
10
11/// Default config file name looked up relative to the current working directory when
12/// `--config` is not given (FR-014).
13pub const DEFAULT_CONFIG_FILENAME: &str = "deps.toml";
14
15/// Size cap on a `deps.toml` read, mirroring `main.rs`'s own manifest-read cap (10 MB) — a
16/// config file has no legitimate reason to approach a manifest's own size budget.
17const MAX_CONFIG_FILE_SIZE: u64 = 1_000_000;
18
19/// `deps-cli`'s own top-level configuration, loaded from `deps.toml`.
20///
21/// Composes the same [`PolicyConfig`] `deps-lsp`'s `DepsConfig` does via `#[serde(flatten)]`,
22/// reproducing that type's `deny_unknown_fields`/`flatten` asymmetry exactly: an unknown
23/// top-level key rejects the whole file, but an unknown key nested inside a known section
24/// (`[cache]`, `[network]`, ...) is tolerated for forward-compatibility (spec 062 plan.md §3,
25/// verified by `deps-lsp`'s own `config.rs` tests).
26#[non_exhaustive]
27#[derive(Debug, Deserialize, Default)]
28#[serde(deny_unknown_fields)]
29pub struct CliConfig {
30    /// The shared policy sections (diagnostics, cache, freshness, supply_chain, registries,
31    /// network, license_policy).
32    #[serde(flatten)]
33    pub policy: PolicyConfig,
34}
35
36/// Error loading or parsing a `deps.toml` file.
37#[derive(Debug, thiserror::Error)]
38pub enum ConfigError {
39    /// Reading `path` failed for a reason other than "file does not exist" (which is not an
40    /// error when no `--config` was given — see [`load`]).
41    #[error("failed to read config file {path}: {source}")]
42    Io {
43        /// The config file path.
44        path: PathBuf,
45        /// The underlying I/O error.
46        #[source]
47        source: std::io::Error,
48    },
49    /// `path` exceeds `MAX_CONFIG_FILE_SIZE`.
50    #[error("config file {path} exceeds the {MAX_CONFIG_FILE_SIZE}-byte size cap")]
51    TooLarge {
52        /// The config file path.
53        path: PathBuf,
54    },
55    /// `path`'s content is not valid TOML.
56    #[error("failed to parse TOML in {path}: {message}")]
57    Toml {
58        /// The config file path.
59        path: PathBuf,
60        /// The underlying `toml_span::Error`'s `Display` text, redacted and bounded via
61        /// [`deps_core::net_policy::redact_parse_error_for_log`] at construction time rather
62        /// than stored raw — a duplicate-key/table parse error can embed a credential-shaped
63        /// name verbatim (#1240), and `main.rs` renders this variant via `eprintln!("deps-cli:
64        /// {error}")` straight to stderr/CI logs. Storing the already-redacted text (instead of
65        /// the raw `toml_span::Error`) means every consumer of this variant is safe by
66        /// construction, not just the current call site.
67        message: String,
68    },
69    /// `path` parsed as TOML but does not match [`CliConfig`]'s schema (an unknown top-level
70    /// key, or a field of the wrong type).
71    #[error("invalid configuration in {path}: {message}")]
72    Deserialize {
73        /// The config file path.
74        path: PathBuf,
75        /// The underlying `serde_json::Error`'s `Display` text, redacted and bounded via
76        /// [`deps_core::net_policy::redact_parse_error_for_log`] at construction time rather
77        /// than stored raw — `serde_json`'s "unknown field" and "invalid type" messages both
78        /// embed the offending key or value verbatim, either of which can be credential-shaped
79        /// (#1240 round 2), and `main.rs` renders this variant via `eprintln!("deps-cli:
80        /// {error}")` straight to stderr/CI logs. Same fix as [`Self::Toml`], for the same
81        /// reason: storing the already-redacted text means every consumer of this variant is
82        /// safe by construction, not just the current call site.
83        message: String,
84    },
85}
86
87/// Loads [`CliConfig`] from `explicit_path`, or from [`DEFAULT_CONFIG_FILENAME`] inside
88/// `default_dir` when `explicit_path` is `None`.
89///
90/// `default_dir` is the walked root (FR-014 says "at the walked root", not the process's own
91/// CWD — spec 062 review S4): `deps-cli check /path/to/repo` run from elsewhere must still
92/// pick up that repo's own `deps.toml`, not silently ignore it because the CLI happened to be
93/// launched from a different directory.
94///
95/// A missing file is only an error when `explicit_path` was given explicitly (FR-014's "a
96/// path given via `--config`" case) — the default-location lookup silently falls back to
97/// [`CliConfig::default`] (mirroring `plan.md` §4's "default `./deps.toml` if present").
98/// A file that exists but fails to parse is always an error (FR-016): unlike `deps-lsp`'s
99/// live-reload path, a CLI run has no prior known-good configuration to keep.
100///
101/// **Security (F1/F1-follow-up, spec 062 review, P0/P1):** a `deps.toml` found by
102/// *auto-discovery* (`explicit_path: None`) comes from the target being scanned, not from an
103/// operator's own explicit choice — in a `git checkout && deps-cli check .`-shaped CI job,
104/// that target can be an untrusted PR branch from a fork. Two live-verified attacks follow
105/// from trusting it fully:
106///
107/// - **F1**: `registries.gitlab_instance_host` is the one host `GITLAB_TOKEN` is ever
108///   attached to, and `registries.workspace_registries = "all"` lifts `net_policy`'s SSRF
109///   gate for loopback/RFC1918/cloud-metadata hosts (credential exfiltration/SSRF).
110/// - **F1-follow-up**: `diagnostics.{mutable_ref_pin,vulnerabilities}_enabled = false` or
111///   `network.offline = true` silently disable the exact check that would have caught a
112///   vulnerability the same PR introduces — defeating `check`'s CI-gating purpose without
113///   touching a secret. A PR from a fork can introduce a vulnerable dependency *and* edit
114///   `deps.toml` in the same PR to turn off the check that would have caught it.
115///
116/// [`safe_auto_discovered_policy`] applies to an auto-discovered file only; an explicitly-given
117/// `--config` *is* the operator's own choice and is trusted as written.
118///
119/// # Errors
120///
121/// Returns [`ConfigError`] if the file cannot be read (and was explicitly requested), is
122/// too large, is not valid TOML, or does not match [`CliConfig`]'s schema.
123pub 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/// Reduces a policy loaded from an *auto-discovered* `deps.toml` to only the fields that
152/// cannot weaken what `--fail-on` observes (spec 062 review, F1 follow-up).
153///
154/// Built as an **allowlist** (what to *keep* from `parsed`) rather than a blocklist (what to
155/// reset), deliberately: F1's first fix reset only `registries` and was proven, in the very
156/// next review round, to have missed `diagnostics.*_enabled` and `network.offline` — an
157/// enumerate-the-dangerous-fields approach already failed once on this exact code path. An
158/// allowlist fails closed instead: a `PolicyConfig` field added later defaults to
159/// `PolicyConfig::default()`'s (safe) value here automatically, rather than silently staying
160/// attacker-controlled until someone notices and adds it to a reset list.
161///
162/// The only fields kept from `parsed`: `diagnostics`'s six `*_severity` values. These are
163/// purely cosmetic (`table`/`json` severity display) — [`crate::report::FailOnPolicy::matches`]
164/// checks a finding's `Category`, never its severity, so no severity value can suppress or
165/// weaken a `--fail-on` match. Everything else reverts to [`PolicyConfig::default`]:
166/// `diagnostics.{mutable_ref_pin,vulnerabilities}_enabled` (the two direct "disable the
167/// check" levers), `cache.*` (a low `fetch_timeout_secs` can induce spurious fetch failures
168/// that mask a real finding as an unresolved lookup instead), `freshness.*` and
169/// `license_policy.{allow,deny}` (both change what counts as a violation),
170/// `supply_chain.enabled` (moot for `deps-cli` today — `VersionData.trust` is hover-only and
171/// never set here — reset anyway for uniformity), `network.offline` (F1-follow-up: silently
172/// suppresses every registry/OSV-derived finding), and `registries.*` (F1).
173#[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
187/// Names every section of `policy` that differs from [`PolicyConfig::default`] outside the
188/// always-kept severity fields — used only to print a specific, per-section warning when
189/// [`load`] ignores an auto-discovered file's non-cosmetic settings, so this is visible in CI
190/// logs even if a future `PolicyConfig` field is missed by [`safe_auto_discovered_policy`]'s
191/// allowlist (same "defense in depth" spirit as `report.rs`'s diagnostic-code-list doc).
192fn 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
234/// Parses `content` as TOML and deserializes it into [`CliConfig`].
235///
236/// Goes through `toml_span::parse` (this project's TOML parser of record) and then bridges
237/// the parsed [`toml_span::Value`] into [`CliConfig`] via its `serde::Deserialize` impl
238/// (`toml_span::Value` implements `serde::Serialize` under its own `serde` feature) — so
239/// `deps-cli` reuses `PolicyConfig`'s existing `Deserialize` impl instead of writing a
240/// second, `toml_span::Deserialize`-based one.
241fn 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
259/// Applies `--offline`/`--cooldown` CLI overrides onto a loaded [`CliConfig`] for this run
260/// only (FR-015).
261///
262/// `--offline`'s presence forces `network.offline = true` (a bare on/off flag has no way to
263/// express "explicitly false", so absence never overrides a `deps.toml`-configured `true`
264/// back to `false`); `--cooldown`, when given, replaces `freshness.cooldown_secs` outright.
265pub 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    /// #1240: a duplicate table whose name is credential-shaped must not leak the credential
327    /// into `ConfigError::Toml`'s stored message, which `main.rs` prints straight to stderr.
328    #[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        // Derived from the raw parse, not hardcoded, so assert_eq! gates a redaction regression (#1240 M5).
356        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    /// #1240 round 2: an unknown top-level key whose *name* is credential-shaped must not leak
374    /// the credential into `ConfigError::Deserialize`'s stored message — `serde_json`'s "unknown
375    /// field" message embeds the key verbatim, and this is actually easier to trigger than the
376    /// `ConfigError::Toml` duplicate-key case (round 1): one bad key, no duplicate needed.
377    #[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    /// #1240 round 2: a wrong-typed field *value* that happens to be credential-shaped must not
391    /// leak either — `serde_json`'s "invalid type" message embeds the offending value verbatim.
392    #[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        // Derived from the raw serde_json round-trip, not hardcoded, so assert_eq! gates a redaction regression.
414        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    /// Regression test for S4 (spec 062 review): auto-discovery must resolve against the
443    /// given `default_dir` (the walked root), not the process's own CWD.
444    ///
445    /// Uses a severity field as the signal (not `network.offline` — that's one of the
446    /// fields `safe_auto_discovered_policy` now resets, see the F1-follow-up tests below;
447    /// a severity value is always kept, so it stays a valid probe for *which file* was
448    /// actually read).
449    #[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        // Deliberately does NOT chdir anywhere near `dir` — if `load` fell back to CWD this
458        // would find nothing and return defaults instead.
459        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    /// Regression test for F1 (spec 062 review, P0 security): an auto-discovered
467    /// `deps.toml`'s `registries` section (the credential/SSRF-relevant one) must never take
468    /// effect — only an explicitly-given `--config` file is trusted for it.
469    #[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    /// Regression test for the F1 follow-up (spec 062 review, P1): an auto-discovered
490    /// `deps.toml` must not be able to disable the two diagnostic kinds that gate
491    /// `--fail-on mutable-ref`/no-vulnerability-scan-at-all.
492    #[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    /// Regression test for the F1 follow-up: an auto-discovered `deps.toml` must not be able
510    /// to force the whole run offline, which would silently suppress every
511    /// registry/OSV-derived finding and make the default `--fail-on` policy unable to fire.
512    #[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    /// Regression test for the F1 follow-up: `freshness`/`license_policy`/`cache`/
525    /// `supply_chain` all change what counts as a violation or can induce spurious fetch
526    /// failures, so an auto-discovered file must not control any of them either.
527    #[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    /// The one allowed exception: a severity value has no effect on `--fail-on` matching
568    /// ([`crate::report::FailOnPolicy::matches`] checks `Category`, never severity), so it
569    /// is kept from an auto-discovered file.
570    #[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    /// Companion to the test above: an explicitly-given `--config` *is* the operator's own
586    /// choice, so its `registries` section is trusted as written.
587    #[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}