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}: {source}")]
57    Toml {
58        /// The config file path.
59        path: PathBuf,
60        /// The underlying TOML parse error.
61        #[source]
62        source: toml_span::Error,
63    },
64    /// `path` parsed as TOML but does not match [`CliConfig`]'s schema (an unknown top-level
65    /// key, or a field of the wrong type).
66    #[error("invalid configuration in {path}: {source}")]
67    Deserialize {
68        /// The config file path.
69        path: PathBuf,
70        /// The underlying (de)serialization error.
71        #[source]
72        source: serde_json::Error,
73    },
74}
75
76/// Loads [`CliConfig`] from `explicit_path`, or from [`DEFAULT_CONFIG_FILENAME`] inside
77/// `default_dir` when `explicit_path` is `None`.
78///
79/// `default_dir` is the walked root (FR-014 says "at the walked root", not the process's own
80/// CWD — spec 062 review S4): `deps-cli check /path/to/repo` run from elsewhere must still
81/// pick up that repo's own `deps.toml`, not silently ignore it because the CLI happened to be
82/// launched from a different directory.
83///
84/// A missing file is only an error when `explicit_path` was given explicitly (FR-014's "a
85/// path given via `--config`" case) — the default-location lookup silently falls back to
86/// [`CliConfig::default`] (mirroring `plan.md` §4's "default `./deps.toml` if present").
87/// A file that exists but fails to parse is always an error (FR-016): unlike `deps-lsp`'s
88/// live-reload path, a CLI run has no prior known-good configuration to keep.
89///
90/// **Security (F1/F1-follow-up, spec 062 review, P0/P1):** a `deps.toml` found by
91/// *auto-discovery* (`explicit_path: None`) comes from the target being scanned, not from an
92/// operator's own explicit choice — in a `git checkout && deps-cli check .`-shaped CI job,
93/// that target can be an untrusted PR branch from a fork. Two live-verified attacks follow
94/// from trusting it fully:
95///
96/// - **F1**: `registries.gitlab_instance_host` is the one host `GITLAB_TOKEN` is ever
97///   attached to, and `registries.workspace_registries = "all"` lifts `net_policy`'s SSRF
98///   gate for loopback/RFC1918/cloud-metadata hosts (credential exfiltration/SSRF).
99/// - **F1-follow-up**: `diagnostics.{mutable_ref_pin,vulnerabilities}_enabled = false` or
100///   `network.offline = true` silently disable the exact check that would have caught a
101///   vulnerability the same PR introduces — defeating `check`'s CI-gating purpose without
102///   touching a secret. A PR from a fork can introduce a vulnerable dependency *and* edit
103///   `deps.toml` in the same PR to turn off the check that would have caught it.
104///
105/// [`safe_auto_discovered_policy`] applies to an auto-discovered file only; an explicitly-given
106/// `--config` *is* the operator's own choice and is trusted as written.
107///
108/// # Errors
109///
110/// Returns [`ConfigError`] if the file cannot be read (and was explicitly requested), is
111/// too large, is not valid TOML, or does not match [`CliConfig`]'s schema.
112pub fn load(explicit_path: Option<&Path>, default_dir: &Path) -> Result<CliConfig, ConfigError> {
113    let (path, required): (PathBuf, bool) = match explicit_path {
114        Some(path) => (path.to_path_buf(), true),
115        None => (default_dir.join(DEFAULT_CONFIG_FILENAME), false),
116    };
117
118    let content = match deps_core::fs_probe::read_to_string_capped(&path, MAX_CONFIG_FILE_SIZE) {
119        Ok(Some(content)) => content,
120        Ok(None) => return Err(ConfigError::TooLarge { path }),
121        Err(source) if !required && source.kind() == std::io::ErrorKind::NotFound => {
122            return Ok(CliConfig::default());
123        }
124        Err(source) => return Err(ConfigError::Io { path, source }),
125    };
126
127    let mut config = parse(&content, &path)?;
128    if !required {
129        for section in ignored_sections(&config.policy) {
130            eprintln!(
131                "deps-cli: warning: {path}'s [{section}] section was auto-discovered, not given via --config, and is ignored — see `deps_cli::config::safe_auto_discovered_policy`'s doc for why",
132                path = path.display(),
133            );
134        }
135        config.policy = safe_auto_discovered_policy(config.policy);
136    }
137    Ok(config)
138}
139
140/// Reduces a policy loaded from an *auto-discovered* `deps.toml` to only the fields that
141/// cannot weaken what `--fail-on` observes (spec 062 review, F1 follow-up).
142///
143/// Built as an **allowlist** (what to *keep* from `parsed`) rather than a blocklist (what to
144/// reset), deliberately: F1's first fix reset only `registries` and was proven, in the very
145/// next review round, to have missed `diagnostics.*_enabled` and `network.offline` — an
146/// enumerate-the-dangerous-fields approach already failed once on this exact code path. An
147/// allowlist fails closed instead: a `PolicyConfig` field added later defaults to
148/// `PolicyConfig::default()`'s (safe) value here automatically, rather than silently staying
149/// attacker-controlled until someone notices and adds it to a reset list.
150///
151/// The only fields kept from `parsed`: `diagnostics`'s six `*_severity` values. These are
152/// purely cosmetic (`table`/`json` severity display) — [`crate::report::FailOnPolicy::matches`]
153/// checks a finding's `Category`, never its severity, so no severity value can suppress or
154/// weaken a `--fail-on` match. Everything else reverts to [`PolicyConfig::default`]:
155/// `diagnostics.{mutable_ref_pin,vulnerabilities}_enabled` (the two direct "disable the
156/// check" levers), `cache.*` (a low `fetch_timeout_secs` can induce spurious fetch failures
157/// that mask a real finding as an unresolved lookup instead), `freshness.*` and
158/// `license_policy.{allow,deny}` (both change what counts as a violation),
159/// `supply_chain.enabled` (moot for `deps-cli` today — `VersionData.trust` is hover-only and
160/// never set here — reset anyway for uniformity), `network.offline` (F1-follow-up: silently
161/// suppresses every registry/OSV-derived finding), and `registries.*` (F1).
162#[must_use]
163pub fn safe_auto_discovered_policy(parsed: PolicyConfig) -> PolicyConfig {
164    PolicyConfig {
165        diagnostics: DiagnosticsConfig::new()
166            .with_outdated_severity(parsed.diagnostics.outdated_severity)
167            .with_unknown_severity(parsed.diagnostics.unknown_severity)
168            .with_yanked_severity(parsed.diagnostics.yanked_severity)
169            .with_unsatisfiable_severity(parsed.diagnostics.unsatisfiable_severity)
170            .with_deprecated_severity(parsed.diagnostics.deprecated_severity)
171            .with_mutable_ref_pin_severity(parsed.diagnostics.mutable_ref_pin_severity),
172        ..PolicyConfig::default()
173    }
174}
175
176/// Names every section of `policy` that differs from [`PolicyConfig::default`] outside the
177/// always-kept severity fields — used only to print a specific, per-section warning when
178/// [`load`] ignores an auto-discovered file's non-cosmetic settings, so this is visible in CI
179/// logs even if a future `PolicyConfig` field is missed by [`safe_auto_discovered_policy`]'s
180/// allowlist (same "defense in depth" spirit as `report.rs`'s diagnostic-code-list doc).
181fn ignored_sections(policy: &PolicyConfig) -> Vec<&'static str> {
182    let default = PolicyConfig::default();
183    let mut sections = Vec::new();
184
185    if policy.diagnostics.mutable_ref_pin_enabled != default.diagnostics.mutable_ref_pin_enabled
186        || policy.diagnostics.vulnerabilities_enabled != default.diagnostics.vulnerabilities_enabled
187    {
188        sections.push("diagnostics");
189    }
190    if policy.cache.enabled != default.cache.enabled
191        || policy.cache.fetch_timeout_secs != default.cache.fetch_timeout_secs
192        || policy.cache.max_concurrent_fetches != default.cache.max_concurrent_fetches
193    {
194        sections.push("cache");
195    }
196    if policy.freshness.enabled != default.freshness.enabled
197        || policy.freshness.cooldown_secs != default.freshness.cooldown_secs
198    {
199        sections.push("freshness");
200    }
201    if policy.supply_chain.enabled != default.supply_chain.enabled {
202        sections.push("supply_chain");
203    }
204    if policy.registries.workspace_registries != default.registries.workspace_registries
205        || policy.registries.nuget_user_profile_sources
206            != default.registries.nuget_user_profile_sources
207        || policy.registries.gitlab_instance_host != default.registries.gitlab_instance_host
208    {
209        sections.push("registries");
210    }
211    if policy.network.offline != default.network.offline {
212        sections.push("network");
213    }
214    if policy.license_policy.allow != default.license_policy.allow
215        || policy.license_policy.deny != default.license_policy.deny
216    {
217        sections.push("license_policy");
218    }
219
220    sections
221}
222
223/// Parses `content` as TOML and deserializes it into [`CliConfig`].
224///
225/// Goes through `toml_span::parse` (this project's TOML parser of record) and then bridges
226/// the parsed [`toml_span::Value`] into [`CliConfig`] via its `serde::Deserialize` impl
227/// (`toml_span::Value` implements `serde::Serialize` under its own `serde` feature) — so
228/// `deps-cli` reuses `PolicyConfig`'s existing `Deserialize` impl instead of writing a
229/// second, `toml_span::Deserialize`-based one.
230fn parse(content: &str, path: &Path) -> Result<CliConfig, ConfigError> {
231    let value = toml_span::parse(content).map_err(|source| ConfigError::Toml {
232        path: path.to_path_buf(),
233        source,
234    })?;
235    let json = serde_json::to_value(&value).map_err(|source| ConfigError::Deserialize {
236        path: path.to_path_buf(),
237        source,
238    })?;
239    serde_json::from_value(json).map_err(|source| ConfigError::Deserialize {
240        path: path.to_path_buf(),
241        source,
242    })
243}
244
245/// Applies `--offline`/`--cooldown` CLI overrides onto a loaded [`CliConfig`] for this run
246/// only (FR-015).
247///
248/// `--offline`'s presence forces `network.offline = true` (a bare on/off flag has no way to
249/// express "explicitly false", so absence never overrides a `deps.toml`-configured `true`
250/// back to `false`); `--cooldown`, when given, replaces `freshness.cooldown_secs` outright.
251pub fn apply_overrides(mut config: CliConfig, offline: bool, cooldown: Option<u64>) -> CliConfig {
252    if offline {
253        config.policy.network.offline = true;
254    }
255    if let Some(cooldown_secs) = cooldown {
256        config.policy.freshness.cooldown_secs = cooldown_secs;
257    }
258    config
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::io::Write as _;
265
266    fn write_temp_toml(content: &str) -> tempfile::NamedTempFile {
267        let mut file = tempfile::NamedTempFile::new().expect("create temp file");
268        file.write_all(content.as_bytes()).expect("write temp file");
269        file
270    }
271
272    #[test]
273    fn test_load_missing_default_file_returns_defaults() {
274        let dir = tempfile::tempdir().expect("create temp dir");
275        let config = load(None, dir.path()).expect("missing default file is not an error");
276        assert_eq!(
277            config.policy.network.offline,
278            PolicyConfig::default().network.offline
279        );
280    }
281
282    #[test]
283    fn test_load_missing_explicit_path_is_an_error() {
284        let missing = PathBuf::from("/nonexistent/path/to/deps.toml");
285        let result = load(Some(&missing), Path::new("."));
286        assert!(matches!(result, Err(ConfigError::Io { .. })));
287    }
288
289    #[test]
290    fn test_load_valid_toml_parses_policy_sections() {
291        let file = write_temp_toml(
292            r"
293            [network]
294            offline = true
295
296            [freshness]
297            cooldown_secs = 60
298            ",
299        );
300        let config = load(Some(file.path()), Path::new(".")).expect("valid TOML must parse");
301        assert!(config.policy.network.offline);
302        assert_eq!(config.policy.freshness.cooldown_secs, 60);
303    }
304
305    #[test]
306    fn test_load_malformed_toml_is_an_error() {
307        let file = write_temp_toml("this is not [ valid toml");
308        let result = load(Some(file.path()), Path::new("."));
309        assert!(matches!(result, Err(ConfigError::Toml { .. })));
310    }
311
312    #[test]
313    fn test_load_unknown_top_level_key_is_rejected() {
314        let file = write_temp_toml("totally_unknown_key = true\n");
315        let result = load(Some(file.path()), Path::new("."));
316        assert!(matches!(result, Err(ConfigError::Deserialize { .. })));
317    }
318
319    #[test]
320    fn test_load_unknown_key_nested_in_known_section_is_tolerated() {
321        let file = write_temp_toml(
322            r#"
323            [network]
324            offline = true
325            future_field = "ignored"
326            "#,
327        );
328        let config = load(Some(file.path()), Path::new("."))
329            .expect("nested unknown key must not reject the payload");
330        assert!(config.policy.network.offline);
331    }
332
333    /// Regression test for S4 (spec 062 review): auto-discovery must resolve against the
334    /// given `default_dir` (the walked root), not the process's own CWD.
335    ///
336    /// Uses a severity field as the signal (not `network.offline` — that's one of the
337    /// fields `safe_auto_discovered_policy` now resets, see the F1-follow-up tests below;
338    /// a severity value is always kept, so it stays a valid probe for *which file* was
339    /// actually read).
340    #[test]
341    fn test_load_auto_discovery_resolves_against_given_default_dir_not_cwd() {
342        let dir = tempfile::tempdir().expect("create temp dir");
343        std::fs::write(
344            dir.path().join(DEFAULT_CONFIG_FILENAME),
345            "[diagnostics]\noutdated_severity = 1\n",
346        )
347        .expect("write deps.toml");
348        // Deliberately does NOT chdir anywhere near `dir` — if `load` fell back to CWD this
349        // would find nothing and return defaults instead.
350        let config = load(None, dir.path()).expect("deps.toml in default_dir must be found");
351        assert_eq!(
352            config.policy.diagnostics.outdated_severity,
353            deps_core::diagnostic::Severity::Error
354        );
355    }
356
357    /// Regression test for F1 (spec 062 review, P0 security): an auto-discovered
358    /// `deps.toml`'s `registries` section (the credential/SSRF-relevant one) must never take
359    /// effect — only an explicitly-given `--config` file is trusted for it.
360    #[test]
361    fn test_load_auto_discovered_registries_section_is_ignored() {
362        let dir = tempfile::tempdir().expect("create temp dir");
363        std::fs::write(
364            dir.path().join(DEFAULT_CONFIG_FILENAME),
365            r#"
366            [registries]
367            gitlab_instance_host = "attacker-host.invalid"
368            workspace_registries = "all"
369            "#,
370        )
371        .expect("write deps.toml");
372        let config = load(None, dir.path()).expect("auto-discovered file must still load");
373        assert_eq!(config.policy.registries.gitlab_instance_host, "");
374        assert_eq!(
375            config.policy.registries.workspace_registries,
376            deps_core::policy_config::WorkspaceRegistriesSetting::PublicOnly
377        );
378    }
379
380    /// Regression test for the F1 follow-up (spec 062 review, P1): an auto-discovered
381    /// `deps.toml` must not be able to disable the two diagnostic kinds that gate
382    /// `--fail-on mutable-ref`/no-vulnerability-scan-at-all.
383    #[test]
384    fn test_load_auto_discovered_diagnostics_enabled_flags_are_ignored() {
385        let dir = tempfile::tempdir().expect("create temp dir");
386        std::fs::write(
387            dir.path().join(DEFAULT_CONFIG_FILENAME),
388            r"
389            [diagnostics]
390            mutable_ref_pin_enabled = false
391            vulnerabilities_enabled = false
392            ",
393        )
394        .expect("write deps.toml");
395        let config = load(None, dir.path()).expect("auto-discovered file must still load");
396        assert!(config.policy.diagnostics.mutable_ref_pin_enabled);
397        assert!(config.policy.diagnostics.vulnerabilities_enabled);
398    }
399
400    /// Regression test for the F1 follow-up: an auto-discovered `deps.toml` must not be able
401    /// to force the whole run offline, which would silently suppress every
402    /// registry/OSV-derived finding and make the default `--fail-on` policy unable to fire.
403    #[test]
404    fn test_load_auto_discovered_network_offline_is_ignored() {
405        let dir = tempfile::tempdir().expect("create temp dir");
406        std::fs::write(
407            dir.path().join(DEFAULT_CONFIG_FILENAME),
408            "[network]\noffline = true\n",
409        )
410        .expect("write deps.toml");
411        let config = load(None, dir.path()).expect("auto-discovered file must still load");
412        assert!(!config.policy.network.offline);
413    }
414
415    /// Regression test for the F1 follow-up: `freshness`/`license_policy`/`cache`/
416    /// `supply_chain` all change what counts as a violation or can induce spurious fetch
417    /// failures, so an auto-discovered file must not control any of them either.
418    #[test]
419    fn test_load_auto_discovered_remaining_gate_relevant_sections_are_ignored() {
420        let dir = tempfile::tempdir().expect("create temp dir");
421        std::fs::write(
422            dir.path().join(DEFAULT_CONFIG_FILENAME),
423            r#"
424            [freshness]
425            cooldown_secs = 0
426
427            [license_policy]
428            allow = ["GPL-3.0"]
429
430            [cache]
431            fetch_timeout_secs = 1
432
433            [supply_chain]
434            enabled = false
435            "#,
436        )
437        .expect("write deps.toml");
438        let config = load(None, dir.path()).expect("auto-discovered file must still load");
439        let default = PolicyConfig::default();
440        assert_eq!(
441            config.policy.freshness.cooldown_secs,
442            default.freshness.cooldown_secs
443        );
444        assert_eq!(
445            config.policy.license_policy.allow,
446            default.license_policy.allow
447        );
448        assert_eq!(
449            config.policy.cache.fetch_timeout_secs,
450            default.cache.fetch_timeout_secs
451        );
452        assert_eq!(
453            config.policy.supply_chain.enabled,
454            default.supply_chain.enabled
455        );
456    }
457
458    /// The one allowed exception: a severity value has no effect on `--fail-on` matching
459    /// ([`crate::report::FailOnPolicy::matches`] checks `Category`, never severity), so it
460    /// is kept from an auto-discovered file.
461    #[test]
462    fn test_load_auto_discovered_severity_values_are_kept() {
463        let dir = tempfile::tempdir().expect("create temp dir");
464        std::fs::write(
465            dir.path().join(DEFAULT_CONFIG_FILENAME),
466            "[diagnostics]\nyanked_severity = 4\n",
467        )
468        .expect("write deps.toml");
469        let config = load(None, dir.path()).expect("auto-discovered file must still load");
470        assert_eq!(
471            config.policy.diagnostics.yanked_severity,
472            deps_core::diagnostic::Severity::Hint
473        );
474    }
475
476    /// Companion to the test above: an explicitly-given `--config` *is* the operator's own
477    /// choice, so its `registries` section is trusted as written.
478    #[test]
479    fn test_load_explicit_config_registries_section_is_trusted() {
480        let file = write_temp_toml(
481            r#"
482            [registries]
483            gitlab_instance_host = "gitlab.mycorp.dev"
484            "#,
485        );
486        let config = load(Some(file.path()), Path::new("."))
487            .expect("explicit config with a registries section must load");
488        assert_eq!(
489            config.policy.registries.gitlab_instance_host,
490            "gitlab.mycorp.dev"
491        );
492    }
493
494    #[test]
495    fn test_apply_overrides_offline_flag_forces_true() {
496        let config = apply_overrides(CliConfig::default(), true, None);
497        assert!(config.policy.network.offline);
498    }
499
500    #[test]
501    fn test_apply_overrides_offline_absent_keeps_file_value() {
502        let mut base = CliConfig::default();
503        base.policy.network.offline = true;
504        let config = apply_overrides(base, false, None);
505        assert!(
506            config.policy.network.offline,
507            "absent flag must not clear a file-set true"
508        );
509    }
510
511    #[test]
512    fn test_apply_overrides_cooldown_replaces_file_value() {
513        let mut base = CliConfig::default();
514        base.policy.freshness.cooldown_secs = 999;
515        let config = apply_overrides(base, false, Some(42));
516        assert_eq!(config.policy.freshness.cooldown_secs, 42);
517    }
518
519    #[test]
520    fn test_apply_overrides_no_cooldown_keeps_file_value() {
521        let mut base = CliConfig::default();
522        base.policy.freshness.cooldown_secs = 999;
523        let config = apply_overrides(base, false, None);
524        assert_eq!(config.policy.freshness.cooldown_secs, 999);
525    }
526}