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    /// The `[update]` section (#1119) — `deps-cli update`'s ignore rules. Honored only when
35    /// loaded from an explicit `--config <path>`; never auto-discovered (FR-007) —
36    /// [`safe_auto_discovered_config`] drops it by omission for the `check` auto-discovery
37    /// path.
38    #[serde(default)]
39    pub update: UpdateConfig,
40}
41
42/// The `[update]` config section (#1119): `deps-cli update`'s ignore rules.
43///
44/// # Examples
45///
46/// ```
47/// use deps_cli::config::UpdateConfig;
48///
49/// assert!(UpdateConfig::default().ignore.is_empty());
50/// ```
51#[non_exhaustive]
52#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)]
53#[serde(deny_unknown_fields)]
54pub struct UpdateConfig {
55    /// Dependencies (optionally scoped by update kind) `deps-cli update`'s default mode
56    /// should skip.
57    #[serde(default)]
58    pub ignore: Vec<IgnoreRule>,
59}
60
61/// One `[update].ignore` entry: a dependency name, optionally scoped to specific update
62/// kinds.
63///
64/// `#[non_exhaustive]`: constructed only via deserialization (a future field addition should
65/// not force every construction site to update).
66///
67/// # Examples
68///
69/// ```
70/// use deps_cli::config::IgnoreRule;
71///
72/// let rule: IgnoreRule = serde_json::from_str(r#"{"name": "tokio"}"#).unwrap();
73/// assert_eq!(rule.name, "tokio");
74/// assert_eq!(rule.update_types, None);
75/// ```
76#[non_exhaustive]
77#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
78#[serde(deny_unknown_fields)]
79pub struct IgnoreRule {
80    /// The dependency name this rule matches, after
81    /// [`deps_core::lsp_helpers::PackageNaming::normalize_package_name`] is applied to both
82    /// sides (FR-006).
83    pub name: String,
84    /// The update kinds this rule matches. `None` matches every kind, including
85    /// [`deps_core::edit::UpdateKind::Unknown`]; `Some(kinds)` matches the listed kinds
86    /// **and** `Unknown` (fail-closed — FR-006: a rule that cannot confirm an update is
87    /// below its stated threshold treats it as if it met the threshold).
88    #[serde(default)]
89    pub update_types: Option<Vec<UpdateTypeToken>>,
90}
91
92/// One `update_types` token in an `[update].ignore` entry.
93#[non_exhaustive]
94#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
95#[serde(rename_all = "lowercase")]
96pub enum UpdateTypeToken {
97    /// Matches [`deps_core::edit::UpdateKind::Major`].
98    Major,
99    /// Matches [`deps_core::edit::UpdateKind::Minor`].
100    Minor,
101    /// Matches [`deps_core::edit::UpdateKind::Patch`].
102    Patch,
103}
104
105impl UpdateTypeToken {
106    /// Whether this token matches `kind`.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use deps_cli::config::UpdateTypeToken;
112    /// use deps_core::edit::UpdateKind;
113    ///
114    /// assert!(UpdateTypeToken::Major.matches(UpdateKind::Major));
115    /// assert!(!UpdateTypeToken::Major.matches(UpdateKind::Minor));
116    /// ```
117    #[must_use]
118    pub fn matches(self, kind: deps_core::edit::UpdateKind) -> bool {
119        matches!(
120            (self, kind),
121            (Self::Major, deps_core::edit::UpdateKind::Major)
122                | (Self::Minor, deps_core::edit::UpdateKind::Minor)
123                | (Self::Patch, deps_core::edit::UpdateKind::Patch)
124        )
125    }
126}
127
128/// Error loading or parsing a `deps.toml` file.
129#[derive(Debug, thiserror::Error)]
130pub enum ConfigError {
131    /// Reading `path` failed for a reason other than "file does not exist" (which is not an
132    /// error when no `--config` was given — see [`load`]).
133    #[error("failed to read config file {path}: {source}")]
134    Io {
135        /// The config file path.
136        path: PathBuf,
137        /// The underlying I/O error.
138        #[source]
139        source: std::io::Error,
140    },
141    /// `path` exceeds `MAX_CONFIG_FILE_SIZE`.
142    #[error("config file {path} exceeds the {MAX_CONFIG_FILE_SIZE}-byte size cap")]
143    TooLarge {
144        /// The config file path.
145        path: PathBuf,
146    },
147    /// `path`'s content is not valid TOML.
148    #[error("failed to parse TOML in {path}: {message}")]
149    Toml {
150        /// The config file path.
151        path: PathBuf,
152        /// The underlying `toml_span::Error`'s `Display` text, redacted and bounded via
153        /// [`deps_core::net_policy::redact_parse_error_for_log`] at construction time rather
154        /// than stored raw — a duplicate-key/table parse error can embed a credential-shaped
155        /// name verbatim (#1240), and `main.rs` renders this variant via `eprintln!("deps-cli:
156        /// {error}")` straight to stderr/CI logs. Storing the already-redacted text (instead of
157        /// the raw `toml_span::Error`) means every consumer of this variant is safe by
158        /// construction, not just the current call site. Also used for
159        /// [`deps_core::parse_toml_checked`]'s `NestingTooDeep` message (#1403) when `content`
160        /// exceeds [`deps_core::MAX_TOML_NESTING_DEPTH`] before `toml_span::parse` ever runs.
161        message: String,
162    },
163    /// `path` parsed as TOML but does not match [`CliConfig`]'s schema (an unknown top-level
164    /// key, or a field of the wrong type).
165    #[error("invalid configuration in {path}: {message}")]
166    Deserialize {
167        /// The config file path.
168        path: PathBuf,
169        /// The underlying `serde_json::Error`'s `Display` text, redacted and bounded via
170        /// [`deps_core::net_policy::redact_parse_error_for_log`] at construction time rather
171        /// than stored raw — `serde_json`'s "unknown field" and "invalid type" messages both
172        /// embed the offending key or value verbatim, either of which can be credential-shaped
173        /// (#1240 round 2), and `main.rs` renders this variant via `eprintln!("deps-cli:
174        /// {error}")` straight to stderr/CI logs. Same fix as [`Self::Toml`], for the same
175        /// reason: storing the already-redacted text means every consumer of this variant is
176        /// safe by construction, not just the current call site.
177        message: String,
178    },
179}
180
181/// Loads [`CliConfig`] from `explicit_path`, or from [`DEFAULT_CONFIG_FILENAME`] inside
182/// `default_dir` when `explicit_path` is `None`.
183///
184/// `default_dir` is the walked root (FR-014 says "at the walked root", not the process's own
185/// CWD — spec 062 review S4): `deps-cli check /path/to/repo` run from elsewhere must still
186/// pick up that repo's own `deps.toml`, not silently ignore it because the CLI happened to be
187/// launched from a different directory.
188///
189/// A missing file is only an error when `explicit_path` was given explicitly (FR-014's "a
190/// path given via `--config`" case) — the default-location lookup silently falls back to
191/// [`CliConfig::default`] (mirroring `plan.md` §4's "default `./deps.toml` if present").
192/// A file that exists but fails to parse is always an error (FR-016): unlike `deps-lsp`'s
193/// live-reload path, a CLI run has no prior known-good configuration to keep.
194///
195/// **Security (F1/F1-follow-up, spec 062 review, P0/P1):** a `deps.toml` found by
196/// *auto-discovery* (`explicit_path: None`) comes from the target being scanned, not from an
197/// operator's own explicit choice — in a `git checkout && deps-cli check .`-shaped CI job,
198/// that target can be an untrusted PR branch from a fork. Two live-verified attacks follow
199/// from trusting it fully:
200///
201/// - **F1**: `registries.gitlab_instance_host` is the one host `GITLAB_TOKEN` is ever
202///   attached to, and `registries.workspace_registries = "all"` lifts `net_policy`'s SSRF
203///   gate for loopback/RFC1918/cloud-metadata hosts (credential exfiltration/SSRF).
204/// - **F1-follow-up**: `diagnostics.{mutable_ref_pin,vulnerabilities}_enabled = false` or
205///   `network.offline = true` silently disable the exact check that would have caught a
206///   vulnerability the same PR introduces — defeating `check`'s CI-gating purpose without
207///   touching a secret. A PR from a fork can introduce a vulnerable dependency *and* edit
208///   `deps.toml` in the same PR to turn off the check that would have caught it.
209///
210/// [`safe_auto_discovered_config`] applies to an auto-discovered file only; an explicitly-given
211/// `--config` *is* the operator's own choice and is trusted as written.
212///
213/// # Errors
214///
215/// Returns [`ConfigError`] if the file cannot be read (and was explicitly requested), is
216/// too large, is not valid TOML, or does not match [`CliConfig`]'s schema.
217pub fn load(explicit_path: Option<&Path>, default_dir: &Path) -> Result<CliConfig, ConfigError> {
218    let (path, required): (PathBuf, bool) = match explicit_path {
219        Some(path) => (path.to_path_buf(), true),
220        None => (default_dir.join(DEFAULT_CONFIG_FILENAME), false),
221    };
222
223    let content = match deps_core::fs_probe::read_to_string_capped(&path, MAX_CONFIG_FILE_SIZE) {
224        Ok(Some(content)) => content,
225        Ok(None) => return Err(ConfigError::TooLarge { path }),
226        Err(source) if !required && source.kind() == std::io::ErrorKind::NotFound => {
227            return Ok(CliConfig::default());
228        }
229        Err(source) => return Err(ConfigError::Io { path, source }),
230    };
231
232    let config = parse(&content, &path)?;
233    // Spec 074 FR-008: this loop now runs for both an auto-discovered and an explicit
234    // `--config` file — only `safe_auto_discovered_config`'s field-reset below stays scoped
235    // to the auto-discovered (`!required`) branch; `ignored_sections` itself already
236    // distinguishes which sections are worth warning about for which path (see its own doc).
237    for section in ignored_sections(&config.policy, required) {
238        if required {
239            eprintln!(
240                "deps-cli: warning: {path}'s [{section}] section has no effect in deps-cli today — see `deps_cli::config::ignored_sections`'s doc for why",
241                path = crate::sanitize::sanitize_path_for_display(&path).display(),
242            );
243        } else {
244            eprintln!(
245                "deps-cli: warning: {path}'s [{section}] section was auto-discovered, not given via --config, and is ignored — see `deps_cli::config::safe_auto_discovered_config`'s doc for why",
246                path = crate::sanitize::sanitize_path_for_display(&path).display(),
247            );
248        }
249    }
250    if !required {
251        return Ok(safe_auto_discovered_config(config));
252    }
253    Ok(config)
254}
255
256/// Reduces a [`CliConfig`] loaded from an *auto-discovered* `deps.toml` to only the fields
257/// that cannot weaken what `--fail-on` observes (spec 062 review, F1 follow-up).
258///
259/// Widened one level from a `PolicyConfig`-only allowlist (#1329) so a `CliConfig` field
260/// added later (like [`CliConfig::update`]) is safe-by-default under auto-discovery rather
261/// than attacker-controlled by default, without needing its own explicit reset.
262///
263/// Built as an **allowlist** (what to *keep* from `parsed`) rather than a blocklist (what to
264/// reset), deliberately: F1's first fix reset only `registries` and was proven, in the very
265/// next review round, to have missed `diagnostics.*_enabled` and `network.offline` — an
266/// enumerate-the-dangerous-fields approach already failed once on this exact code path. An
267/// allowlist fails closed instead: a field added later defaults to `CliConfig::default`'s
268/// (safe) value here automatically, rather than silently staying attacker-controlled until
269/// someone notices and adds it to a reset list.
270///
271/// The only fields kept from `parsed`: `policy.diagnostics`'s `*_severity` values. These
272/// are purely cosmetic (`table`/`json` severity display) —
273/// [`crate::report::FailOnPolicy::matches`] checks a finding's `Category`, never its
274/// severity, so no severity value can suppress or weaken a `--fail-on` match. Everything else
275/// reverts to its default: `policy.diagnostics.{mutable_ref_pin,vulnerabilities}_enabled`
276/// (the two direct "disable the check" levers), `policy.cache.*` (a low `fetch_timeout_secs`
277/// can induce spurious fetch failures that mask a real finding as an unresolved lookup
278/// instead), `policy.freshness.*` and `policy.license_policy.{allow,deny}` (both change what
279/// counts as a violation), `policy.supply_chain.enabled` (moot for `deps-cli` today —
280/// `VersionData.trust` is hover-only and never set here — reset anyway for uniformity),
281/// `policy.network.offline` (F1-follow-up: silently suppresses every registry/OSV-derived
282/// finding), `policy.registries.*` (F1), and `update.ignore` (FR-007 — provably a no-op
283/// regardless: `update` never auto-discovers a config at all, so `check`'s own auto-discovery
284/// path — the only caller of this function — never renders `[update].ignore` in the first
285/// place; dropped here anyway so the allowlist stays the single source of truth for what an
286/// auto-discovered file can influence).
287#[must_use]
288pub fn safe_auto_discovered_config(parsed: CliConfig) -> CliConfig {
289    CliConfig {
290        policy: PolicyConfig {
291            diagnostics: DiagnosticsConfig::new()
292                .with_outdated_severity(parsed.policy.diagnostics.outdated_severity)
293                .with_unknown_severity(parsed.policy.diagnostics.unknown_severity)
294                .with_yanked_severity(parsed.policy.diagnostics.yanked_severity)
295                .with_unsatisfiable_severity(parsed.policy.diagnostics.unsatisfiable_severity)
296                .with_deprecated_severity(parsed.policy.diagnostics.deprecated_severity)
297                .with_mutable_ref_pin_severity(parsed.policy.diagnostics.mutable_ref_pin_severity)
298                .with_sha_comment_mismatch_severity(
299                    parsed.policy.diagnostics.sha_comment_mismatch_severity,
300                ),
301            ..PolicyConfig::default()
302        },
303        ..CliConfig::default()
304    }
305}
306
307/// Names every section of `policy` that differs from [`PolicyConfig::default`] outside the
308/// always-kept severity fields, and is currently a no-op for `deps-cli` — used only to print a
309/// specific, per-section warning, so this is visible in CI logs even if a future
310/// `PolicyConfig` field is missed (same "defense in depth" spirit as `report.rs`'s
311/// diagnostic-code-list doc).
312///
313/// Two independent reasons a section can be a no-op, and `required` (whether this is an
314/// *explicit* `--config <path>`, never an auto-discovered `deps.toml`) distinguishes them:
315///
316/// - **Reset by [`safe_auto_discovered_config`]** (`diagnostics`/`cache`/`freshness`/
317///   `supply_chain`/`registries`/`network`/`license_policy`): each of these fields IS read by
318///   `deps-cli`'s own pipeline, so it only becomes a no-op when [`load`] resets it back to
319///   default for untrusted auto-discovered input (spec 062 F1/F1-follow-up) — never for an
320///   explicit `--config`, which stays fully trusted and un-reset. These checks are skipped
321///   entirely when `required` (an explicit config), or they would be false positives: warning
322///   that a section "has no effect" when it demonstrably does.
323/// - **Not wired up at all** (`typosquat` — spec 074 FR-007): `deps-cli` has no code path that
324///   reads `policy.typosquat.enabled` anywhere, so this section is a no-op for *both* an
325///   auto-discovered and an explicit config, and this check fires unconditionally.
326///
327/// `gossip` (spec 072 M14) used to belong to the second class, alongside `typosquat` — before
328/// spec 074, `deps-cli` had zero code reading `policy.gossip.enabled` either. Spec 074 (FR-001
329/// through FR-003) gave GOSSIP real, working wiring (`main.rs`'s `RuntimeHandles`,
330/// `analyze.rs`'s prefetch, `deps-engine`'s fetch-level filter) that reads whatever `policy`
331/// [`load`] ultimately returns — including an explicit config's own value, unmodified. `gossip`
332/// therefore moved into the first class: it remains a genuine no-op only for the
333/// auto-discovered path (still reset there), and must NOT warn for an explicit `--config`,
334/// where it now has real effect.
335fn ignored_sections(policy: &PolicyConfig, required: bool) -> Vec<&'static str> {
336    let default = PolicyConfig::default();
337    let mut sections = Vec::new();
338
339    if !required {
340        if policy.diagnostics.mutable_ref_pin_enabled != default.diagnostics.mutable_ref_pin_enabled
341            || policy.diagnostics.vulnerabilities_enabled
342                != default.diagnostics.vulnerabilities_enabled
343        {
344            sections.push("diagnostics");
345        }
346        if policy.cache.enabled != default.cache.enabled
347            || policy.cache.fetch_timeout_secs != default.cache.fetch_timeout_secs
348            || policy.cache.max_concurrent_fetches != default.cache.max_concurrent_fetches
349        {
350            sections.push("cache");
351        }
352        if policy.freshness.enabled != default.freshness.enabled
353            || policy.freshness.cooldown_secs != default.freshness.cooldown_secs
354        {
355            sections.push("freshness");
356        }
357        if policy.supply_chain.enabled != default.supply_chain.enabled {
358            sections.push("supply_chain");
359        }
360        if policy.registries.workspace_registries != default.registries.workspace_registries
361            || policy.registries.nuget_user_profile_sources
362                != default.registries.nuget_user_profile_sources
363            || policy.registries.gitlab_instance_host != default.registries.gitlab_instance_host
364        {
365            sections.push("registries");
366        }
367        if policy.network.offline != default.network.offline {
368            sections.push("network");
369        }
370        if policy.license_policy.allow != default.license_policy.allow
371            || policy.license_policy.deny != default.license_policy.deny
372        {
373            sections.push("license_policy");
374        }
375        if policy.gossip.enabled != default.gossip.enabled {
376            sections.push("gossip");
377        }
378    }
379    // Spec 074 FR-007: unconditional (both auto-discovered and explicit) — see this
380    // function's own doc for why `typosquat` differs from every check above.
381    if policy.typosquat.enabled != default.typosquat.enabled {
382        sections.push("typosquat");
383    }
384
385    sections
386}
387
388/// Parses `content` as TOML and deserializes it into [`CliConfig`].
389///
390/// Goes through [`deps_core::parse_toml_checked`] (this project's TOML parser of record) and
391/// then bridges the parsed [`toml_span::Value`] into [`CliConfig`] via its `serde::Deserialize`
392/// impl (`toml_span::Value` implements `serde::Serialize` under its own `serde` feature) — so
393/// `deps-cli` reuses `PolicyConfig`'s existing `Deserialize` impl instead of writing a
394/// second, `toml_span::Deserialize`-based one.
395fn parse(content: &str, path: &Path) -> Result<CliConfig, ConfigError> {
396    let value = deps_core::parse_toml_checked(content).map_err(|source| ConfigError::Toml {
397        path: path.to_path_buf(),
398        message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
399            .into_owned(),
400    })?;
401    let json = serde_json::to_value(&value).map_err(|source| ConfigError::Deserialize {
402        path: path.to_path_buf(),
403        message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
404            .into_owned(),
405    })?;
406    serde_json::from_value(json).map_err(|source| ConfigError::Deserialize {
407        path: path.to_path_buf(),
408        message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
409            .into_owned(),
410    })
411}
412
413/// Fuzz-only entry point for [`parse`] (issue #1404), using a fixed dummy path since the
414/// fuzz target only supplies file content. Gated on the `fuzzing` Cargo feature (never
415/// enabled by this crate's own default set) so this stays out of the crate's public API
416/// surface in a normal build.
417#[cfg(feature = "fuzzing")]
418#[doc(hidden)]
419pub fn fuzz_parse_config(content: &str) {
420    let _ = parse(content, Path::new(DEFAULT_CONFIG_FILENAME));
421}
422
423/// Applies `--offline`/`--cooldown` CLI overrides onto a loaded [`CliConfig`] for this run
424/// only (FR-015).
425///
426/// `--offline`'s presence forces `network.offline = true` (a bare on/off flag has no way to
427/// express "explicitly false", so absence never overrides a `deps.toml`-configured `true`
428/// back to `false`); `--cooldown`, when given, replaces `freshness.cooldown_secs` outright.
429pub fn apply_overrides(
430    mut config: CliConfig,
431    offline: deps_core::NetworkMode,
432    cooldown: Option<u64>,
433) -> CliConfig {
434    if offline == deps_core::NetworkMode::Offline {
435        config.policy.network.offline = true;
436    }
437    if let Some(cooldown_secs) = cooldown {
438        config.policy.freshness.cooldown_secs = cooldown_secs;
439    }
440    config
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use std::io::Write as _;
447
448    fn write_temp_toml(content: &str) -> tempfile::NamedTempFile {
449        let mut file = tempfile::NamedTempFile::new().expect("create temp file");
450        file.write_all(content.as_bytes()).expect("write temp file");
451        file
452    }
453
454    /// Issue #1456, spec 072, M14: a `[gossip]` section differing from default must warn
455    /// via `ignored_sections` for an *auto-discovered* file — spec 074 gave `deps-cli` real
456    /// GOSSIP wiring, but only `safe_auto_discovered_config` still resets it, so this remains
457    /// a genuine no-op for that path only (see `ignored_sections`'s own doc).
458    #[test]
459    fn test_ignored_sections_includes_gossip_when_enabled_and_auto_discovered() {
460        let mut policy = PolicyConfig::default();
461        policy.gossip.enabled = true;
462        assert!(ignored_sections(&policy, false).contains(&"gossip"));
463    }
464
465    #[test]
466    fn test_ignored_sections_omits_gossip_when_default() {
467        let policy = PolicyConfig::default();
468        assert!(!ignored_sections(&policy, false).contains(&"gossip"));
469    }
470
471    /// Spec 074: an explicit `--config` file's `[gossip]` section is no longer a no-op
472    /// (`main.rs`/`analyze.rs`/`deps-engine` now read it) — `ignored_sections` must not warn
473    /// about it for `required: true`, unlike every other section reset only for
474    /// auto-discovered input.
475    #[test]
476    fn test_ignored_sections_omits_gossip_when_required() {
477        let mut policy = PolicyConfig::default();
478        policy.gossip.enabled = true;
479        assert!(!ignored_sections(&policy, true).contains(&"gossip"));
480    }
481
482    /// Spec 074 FR-007: `[typosquat]` has no `deps-cli` wiring at all (unlike `gossip` after
483    /// this spec), so it must warn for both an auto-discovered and an explicit config.
484    #[test]
485    fn test_ignored_sections_includes_typosquat_when_enabled_regardless_of_required() {
486        let mut policy = PolicyConfig::default();
487        policy.typosquat.enabled = true;
488        assert!(ignored_sections(&policy, false).contains(&"typosquat"));
489        assert!(ignored_sections(&policy, true).contains(&"typosquat"));
490    }
491
492    #[test]
493    fn test_ignored_sections_omits_typosquat_when_default() {
494        let policy = PolicyConfig::default();
495        assert!(!ignored_sections(&policy, false).contains(&"typosquat"));
496        assert!(!ignored_sections(&policy, true).contains(&"typosquat"));
497    }
498
499    /// Spec 074 FR-008: a non-default `[typosquat]` section in an *explicit* `--config` file
500    /// now prints the "has no effect" warning too — before this spec, `load`'s warning loop
501    /// ran only for an auto-discovered file (`if !required`), so an explicit config silently
502    /// accepted a no-op `[typosquat]` section with zero visible warning.
503    #[test]
504    fn test_load_warns_for_typosquat_in_explicit_config() {
505        let file = write_temp_toml(
506            r"
507            [typosquat]
508            enabled = true
509            ",
510        );
511        let config = load(Some(file.path()), Path::new("."))
512            .expect("explicit config with a non-default [typosquat] section must still load");
513        // The explicit path stays fully trusted (spec 062 F1-follow-up) — the section's
514        // value survives even though it has no effect yet.
515        assert!(config.policy.typosquat.enabled);
516    }
517
518    #[test]
519    fn test_load_missing_default_file_returns_defaults() {
520        let dir = tempfile::tempdir().expect("create temp dir");
521        let config = load(None, dir.path()).expect("missing default file is not an error");
522        assert_eq!(
523            config.policy.network.offline,
524            PolicyConfig::default().network.offline
525        );
526    }
527
528    #[test]
529    fn test_load_missing_explicit_path_is_an_error() {
530        let missing = PathBuf::from("/nonexistent/path/to/deps.toml");
531        let result = load(Some(&missing), Path::new("."));
532        assert!(matches!(result, Err(ConfigError::Io { .. })));
533    }
534
535    #[test]
536    fn test_load_valid_toml_parses_policy_sections() {
537        let file = write_temp_toml(
538            r"
539            [network]
540            offline = true
541
542            [freshness]
543            cooldown_secs = 60
544            ",
545        );
546        let config = load(Some(file.path()), Path::new(".")).expect("valid TOML must parse");
547        assert!(config.policy.network.offline);
548        assert_eq!(config.policy.freshness.cooldown_secs, 60);
549    }
550
551    #[test]
552    fn test_load_malformed_toml_is_an_error() {
553        let file = write_temp_toml("this is not [ valid toml");
554        let result = load(Some(file.path()), Path::new("."));
555        assert!(matches!(result, Err(ConfigError::Toml { .. })));
556    }
557
558    /// #1403: a deeply nested `deps.toml` (auto-discovered from an untrusted checkout) must be
559    /// rejected via `ConfigError`, not fed straight to `toml_span::parse` and overflow the
560    /// stack (SIGABRT, exit 134) — mirrors `deps-cargo`'s `Cargo.lock` regression test for the
561    /// same class of bug.
562    #[test]
563    fn test_load_excessively_nested_toml_is_rejected_not_stack_overflow() {
564        let depth = deps_core::MAX_TOML_NESTING_DEPTH + 1;
565        let content = format!("x = {}1{}\n", "{a=".repeat(depth), "}".repeat(depth));
566        let file = write_temp_toml(&content);
567        let result = load(Some(file.path()), Path::new("."));
568        let Err(ConfigError::Toml { message, .. }) = result else {
569            panic!("expected ConfigError::Toml, got {result:?}");
570        };
571        assert!(
572            message.contains("nesting depth"),
573            "expected a nesting-depth message, got: {message}"
574        );
575    }
576
577    /// #1240: a duplicate table whose name is credential-shaped must not leak the credential
578    /// into `ConfigError::Toml`'s stored message, which `main.rs` prints straight to stderr.
579    #[test]
580    fn test_load_duplicate_table_toml_error_redacts_credential() {
581        let file = write_temp_toml(
582            r#"
583["https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"]
584a = 1
585["https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"]
586b = 2
587"#,
588        );
589        let result = load(Some(file.path()), Path::new("."));
590        let message = result.unwrap_err().to_string();
591        assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
592        assert!(!message.contains("svcacct"));
593        assert!(message.contains("pkg.internal.corp"));
594    }
595
596    #[test]
597    fn test_load_duplicate_table_toml_error_benign_name_unchanged() {
598        let content = r"
599[serde]
600a = 1
601[serde]
602b = 2
603";
604        let file = write_temp_toml(content);
605
606        // Derived from the raw parse, not hardcoded, so assert_eq! gates a redaction regression (#1240 M5).
607        let raw_err = toml_span::parse(content).unwrap_err();
608        let expected = format!(
609            "failed to parse TOML in {}: {raw_err}",
610            file.path().display()
611        );
612
613        let result = load(Some(file.path()), Path::new("."));
614        assert_eq!(result.unwrap_err().to_string(), expected);
615    }
616
617    #[test]
618    fn test_load_unknown_top_level_key_is_rejected() {
619        let file = write_temp_toml("totally_unknown_key = true\n");
620        let result = load(Some(file.path()), Path::new("."));
621        assert!(matches!(result, Err(ConfigError::Deserialize { .. })));
622    }
623
624    /// #1240 round 2: an unknown top-level key whose *name* is credential-shaped must not leak
625    /// the credential into `ConfigError::Deserialize`'s stored message — `serde_json`'s "unknown
626    /// field" message embeds the key verbatim, and this is actually easier to trigger than the
627    /// `ConfigError::Toml` duplicate-key case (round 1): one bad key, no duplicate needed.
628    #[test]
629    fn test_load_unknown_field_credential_shaped_name_redacted() {
630        let file = write_temp_toml(
631            "\"https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x\" = true\n",
632        );
633        let message = load(Some(file.path()), Path::new("."))
634            .unwrap_err()
635            .to_string();
636        assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
637        assert!(!message.contains("svcacct"));
638        assert!(message.contains("pkg.internal.corp"));
639    }
640
641    /// #1240 round 2: a wrong-typed field *value* that happens to be credential-shaped must not
642    /// leak either — `serde_json`'s "invalid type" message embeds the offending value verbatim.
643    #[test]
644    fn test_load_wrong_typed_value_credential_shaped_string_redacted() {
645        let file = write_temp_toml(
646            r#"
647            [cache]
648            enabled = "https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"
649            "#,
650        );
651        let message = load(Some(file.path()), Path::new("."))
652            .unwrap_err()
653            .to_string();
654        assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
655        assert!(!message.contains("svcacct"));
656        assert!(message.contains("pkg.internal.corp"));
657    }
658
659    #[test]
660    fn test_load_unknown_field_benign_name_unchanged() {
661        let content = "totally_unknown_key = true\n";
662        let file = write_temp_toml(content);
663
664        // Derived from the raw serde_json round-trip, not hardcoded, so assert_eq! gates a redaction regression.
665        let value = toml_span::parse(content).unwrap();
666        let json = serde_json::to_value(&value).unwrap();
667        let raw_err = serde_json::from_value::<CliConfig>(json).unwrap_err();
668        let expected = format!(
669            "invalid configuration in {}: {raw_err}",
670            file.path().display()
671        );
672
673        let message = load(Some(file.path()), Path::new("."))
674            .unwrap_err()
675            .to_string();
676        assert_eq!(message, expected);
677    }
678
679    #[test]
680    fn test_load_unknown_key_nested_in_known_section_is_tolerated() {
681        let file = write_temp_toml(
682            r#"
683            [network]
684            offline = true
685            future_field = "ignored"
686            "#,
687        );
688        let config = load(Some(file.path()), Path::new("."))
689            .expect("nested unknown key must not reject the payload");
690        assert!(config.policy.network.offline);
691    }
692
693    /// Regression test for S4 (spec 062 review): auto-discovery must resolve against the
694    /// given `default_dir` (the walked root), not the process's own CWD.
695    ///
696    /// Uses a severity field as the signal (not `network.offline` — that's one of the
697    /// fields `safe_auto_discovered_config` now resets, see the F1-follow-up tests below;
698    /// a severity value is always kept, so it stays a valid probe for *which file* was
699    /// actually read).
700    #[test]
701    fn test_load_auto_discovery_resolves_against_given_default_dir_not_cwd() {
702        let dir = tempfile::tempdir().expect("create temp dir");
703        std::fs::write(
704            dir.path().join(DEFAULT_CONFIG_FILENAME),
705            "[diagnostics]\noutdated_severity = 1\n",
706        )
707        .expect("write deps.toml");
708        // Deliberately does NOT chdir anywhere near `dir` — if `load` fell back to CWD this
709        // would find nothing and return defaults instead.
710        let config = load(None, dir.path()).expect("deps.toml in default_dir must be found");
711        assert_eq!(
712            config.policy.diagnostics.outdated_severity,
713            deps_core::diagnostic::Severity::Error
714        );
715    }
716
717    /// Regression test for F1 (spec 062 review, P0 security): an auto-discovered
718    /// `deps.toml`'s `registries` section (the credential/SSRF-relevant one) must never take
719    /// effect — only an explicitly-given `--config` file is trusted for it.
720    #[test]
721    fn test_load_auto_discovered_registries_section_is_ignored() {
722        let dir = tempfile::tempdir().expect("create temp dir");
723        std::fs::write(
724            dir.path().join(DEFAULT_CONFIG_FILENAME),
725            r#"
726            [registries]
727            gitlab_instance_host = "attacker-host.invalid"
728            workspace_registries = "all"
729            "#,
730        )
731        .expect("write deps.toml");
732        let config = load(None, dir.path()).expect("auto-discovered file must still load");
733        assert_eq!(config.policy.registries.gitlab_instance_host, "");
734        assert_eq!(
735            config.policy.registries.workspace_registries,
736            deps_core::policy_config::WorkspaceRegistriesSetting::PublicOnly
737        );
738    }
739
740    /// Regression test for the F1 follow-up (spec 062 review, P1): an auto-discovered
741    /// `deps.toml` must not be able to disable the two diagnostic kinds that gate
742    /// `--fail-on mutable-ref`/no-vulnerability-scan-at-all.
743    #[test]
744    fn test_load_auto_discovered_diagnostics_enabled_flags_are_ignored() {
745        let dir = tempfile::tempdir().expect("create temp dir");
746        std::fs::write(
747            dir.path().join(DEFAULT_CONFIG_FILENAME),
748            r"
749            [diagnostics]
750            mutable_ref_pin_enabled = false
751            vulnerabilities_enabled = false
752            ",
753        )
754        .expect("write deps.toml");
755        let config = load(None, dir.path()).expect("auto-discovered file must still load");
756        assert!(config.policy.diagnostics.mutable_ref_pin_enabled);
757        assert!(config.policy.diagnostics.vulnerabilities_enabled);
758    }
759
760    /// Regression test for the F1 follow-up: an auto-discovered `deps.toml` must not be able
761    /// to force the whole run offline, which would silently suppress every
762    /// registry/OSV-derived finding and make the default `--fail-on` policy unable to fire.
763    #[test]
764    fn test_load_auto_discovered_network_offline_is_ignored() {
765        let dir = tempfile::tempdir().expect("create temp dir");
766        std::fs::write(
767            dir.path().join(DEFAULT_CONFIG_FILENAME),
768            "[network]\noffline = true\n",
769        )
770        .expect("write deps.toml");
771        let config = load(None, dir.path()).expect("auto-discovered file must still load");
772        assert!(!config.policy.network.offline);
773    }
774
775    /// Regression test for the F1 follow-up: `freshness`/`license_policy`/`cache`/
776    /// `supply_chain` all change what counts as a violation or can induce spurious fetch
777    /// failures, so an auto-discovered file must not control any of them either.
778    #[test]
779    fn test_load_auto_discovered_remaining_gate_relevant_sections_are_ignored() {
780        let dir = tempfile::tempdir().expect("create temp dir");
781        std::fs::write(
782            dir.path().join(DEFAULT_CONFIG_FILENAME),
783            r#"
784            [freshness]
785            cooldown_secs = 0
786
787            [license_policy]
788            allow = ["GPL-3.0"]
789
790            [cache]
791            fetch_timeout_secs = 1
792
793            [supply_chain]
794            enabled = false
795            "#,
796        )
797        .expect("write deps.toml");
798        let config = load(None, dir.path()).expect("auto-discovered file must still load");
799        let default = PolicyConfig::default();
800        assert_eq!(
801            config.policy.freshness.cooldown_secs,
802            default.freshness.cooldown_secs
803        );
804        assert_eq!(
805            config.policy.license_policy.allow,
806            default.license_policy.allow
807        );
808        assert_eq!(
809            config.policy.cache.fetch_timeout_secs,
810            default.cache.fetch_timeout_secs
811        );
812        assert_eq!(
813            config.policy.supply_chain.enabled,
814            default.supply_chain.enabled
815        );
816    }
817
818    /// The one allowed exception: a severity value has no effect on `--fail-on` matching
819    /// ([`crate::report::FailOnPolicy::matches`] checks `Category`, never severity), so it
820    /// is kept from an auto-discovered file.
821    #[test]
822    fn test_load_auto_discovered_severity_values_are_kept() {
823        let dir = tempfile::tempdir().expect("create temp dir");
824        std::fs::write(
825            dir.path().join(DEFAULT_CONFIG_FILENAME),
826            "[diagnostics]\nyanked_severity = 4\n",
827        )
828        .expect("write deps.toml");
829        let config = load(None, dir.path()).expect("auto-discovered file must still load");
830        assert_eq!(
831            config.policy.diagnostics.yanked_severity,
832            deps_core::diagnostic::Severity::Hint
833        );
834    }
835
836    #[test]
837    fn test_load_auto_discovered_pin_severities_are_kept() {
838        let dir = tempfile::tempdir().expect("create temp dir");
839        std::fs::write(
840            dir.path().join(DEFAULT_CONFIG_FILENAME),
841            "[diagnostics]\nmutable_ref_pin_severity = 1\nsha_comment_mismatch_severity = 1\n",
842        )
843        .expect("write deps.toml");
844        let config = load(None, dir.path()).expect("auto-discovered file must still load");
845        assert_eq!(
846            config.policy.diagnostics.mutable_ref_pin_severity,
847            deps_core::diagnostic::Severity::Error
848        );
849        assert_eq!(
850            config.policy.diagnostics.sha_comment_mismatch_severity,
851            deps_core::diagnostic::Severity::Error
852        );
853    }
854
855    /// Companion to the test above: an explicitly-given `--config` *is* the operator's own
856    /// choice, so its `registries` section is trusted as written.
857    #[test]
858    fn test_load_explicit_config_registries_section_is_trusted() {
859        let file = write_temp_toml(
860            r#"
861            [registries]
862            gitlab_instance_host = "gitlab.mycorp.dev"
863            "#,
864        );
865        let config = load(Some(file.path()), Path::new("."))
866            .expect("explicit config with a registries section must load");
867        assert_eq!(
868            config.policy.registries.gitlab_instance_host,
869            "gitlab.mycorp.dev"
870        );
871    }
872
873    #[test]
874    fn test_apply_overrides_offline_flag_forces_true() {
875        let config = apply_overrides(CliConfig::default(), deps_core::NetworkMode::Offline, None);
876        assert!(config.policy.network.offline);
877    }
878
879    #[test]
880    fn test_apply_overrides_offline_absent_keeps_file_value() {
881        let mut base = CliConfig::default();
882        base.policy.network.offline = true;
883        let config = apply_overrides(base, deps_core::NetworkMode::Online, None);
884        assert!(
885            config.policy.network.offline,
886            "absent flag must not clear a file-set true"
887        );
888    }
889
890    #[test]
891    fn test_apply_overrides_cooldown_replaces_file_value() {
892        let mut base = CliConfig::default();
893        base.policy.freshness.cooldown_secs = 999;
894        let config = apply_overrides(base, deps_core::NetworkMode::Online, Some(42));
895        assert_eq!(config.policy.freshness.cooldown_secs, 42);
896    }
897
898    #[test]
899    fn test_apply_overrides_no_cooldown_keeps_file_value() {
900        let mut base = CliConfig::default();
901        base.policy.freshness.cooldown_secs = 999;
902        let config = apply_overrides(base, deps_core::NetworkMode::Online, None);
903        assert_eq!(config.policy.freshness.cooldown_secs, 999);
904    }
905
906    // --- [update] section (#1119, T006) ---
907
908    #[test]
909    fn test_load_auto_discovered_update_ignore_is_dropped() {
910        let dir = tempfile::tempdir().expect("create temp dir");
911        std::fs::write(
912            dir.path().join(DEFAULT_CONFIG_FILENAME),
913            r#"
914            [diagnostics]
915            yanked_severity = 4
916
917            [[update.ignore]]
918            name = "tokio"
919            update_types = ["major"]
920            "#,
921        )
922        .expect("write deps.toml");
923        let config = load(None, dir.path()).expect("auto-discovered file must still load");
924        assert!(
925            config.update.ignore.is_empty(),
926            "an auto-discovered [update].ignore must never take effect"
927        );
928        // The allowlisted severity field must still survive, proving the whole file wasn't
929        // silently dropped.
930        assert_eq!(
931            config.policy.diagnostics.yanked_severity,
932            deps_core::diagnostic::Severity::Hint
933        );
934    }
935
936    #[test]
937    fn test_load_explicit_config_update_ignore_is_trusted_verbatim() {
938        let file = write_temp_toml(
939            r#"
940            [[update.ignore]]
941            name = "tokio"
942            update_types = ["major"]
943
944            [[update.ignore]]
945            name = "legacy-thing"
946            "#,
947        );
948        let config = load(Some(file.path()), Path::new("."))
949            .expect("explicit --config must load [update].ignore verbatim");
950        assert_eq!(config.update.ignore.len(), 2);
951        assert_eq!(config.update.ignore[0].name, "tokio");
952        assert_eq!(
953            config.update.ignore[0].update_types,
954            Some(vec![UpdateTypeToken::Major])
955        );
956        assert_eq!(config.update.ignore[1].name, "legacy-thing");
957        assert_eq!(config.update.ignore[1].update_types, None);
958    }
959
960    #[test]
961    fn test_load_update_ignore_unrecognized_update_types_token_is_a_hard_error() {
962        let file = write_temp_toml(
963            r#"
964            [[update.ignore]]
965            name = "tokio"
966            update_types = ["unknown"]
967            "#,
968        );
969        let result = load(Some(file.path()), Path::new("."));
970        assert!(matches!(result, Err(ConfigError::Deserialize { .. })));
971    }
972
973    #[test]
974    fn test_update_type_token_matches_only_its_own_kind() {
975        assert!(UpdateTypeToken::Major.matches(deps_core::edit::UpdateKind::Major));
976        assert!(!UpdateTypeToken::Major.matches(deps_core::edit::UpdateKind::Minor));
977        assert!(!UpdateTypeToken::Major.matches(deps_core::edit::UpdateKind::Unknown));
978        assert!(UpdateTypeToken::Minor.matches(deps_core::edit::UpdateKind::Minor));
979        assert!(UpdateTypeToken::Patch.matches(deps_core::edit::UpdateKind::Patch));
980    }
981}