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}