use deps_core::policy_config::{DiagnosticsConfig, PolicyConfig};
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const DEFAULT_CONFIG_FILENAME: &str = "deps.toml";
const MAX_CONFIG_FILE_SIZE: u64 = 1_000_000;
#[non_exhaustive]
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CliConfig {
#[serde(flatten)]
pub policy: PolicyConfig,
#[serde(default)]
pub update: UpdateConfig,
}
#[non_exhaustive]
#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct UpdateConfig {
#[serde(default)]
pub ignore: Vec<IgnoreRule>,
}
#[non_exhaustive]
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct IgnoreRule {
pub name: String,
#[serde(default)]
pub update_types: Option<Vec<UpdateTypeToken>>,
}
#[non_exhaustive]
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UpdateTypeToken {
Major,
Minor,
Patch,
}
impl UpdateTypeToken {
#[must_use]
pub fn matches(self, kind: deps_core::edit::UpdateKind) -> bool {
matches!(
(self, kind),
(Self::Major, deps_core::edit::UpdateKind::Major)
| (Self::Minor, deps_core::edit::UpdateKind::Minor)
| (Self::Patch, deps_core::edit::UpdateKind::Patch)
)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("config file {path} exceeds the {MAX_CONFIG_FILE_SIZE}-byte size cap")]
TooLarge {
path: PathBuf,
},
#[error("failed to parse TOML in {path}: {message}")]
Toml {
path: PathBuf,
message: String,
},
#[error("invalid configuration in {path}: {message}")]
Deserialize {
path: PathBuf,
message: String,
},
}
pub fn load(explicit_path: Option<&Path>, default_dir: &Path) -> Result<CliConfig, ConfigError> {
let (path, required): (PathBuf, bool) = match explicit_path {
Some(path) => (path.to_path_buf(), true),
None => (default_dir.join(DEFAULT_CONFIG_FILENAME), false),
};
let content = match deps_core::fs_probe::read_to_string_capped(&path, MAX_CONFIG_FILE_SIZE) {
Ok(Some(content)) => content,
Ok(None) => return Err(ConfigError::TooLarge { path }),
Err(source) if !required && source.kind() == std::io::ErrorKind::NotFound => {
return Ok(CliConfig::default());
}
Err(source) => return Err(ConfigError::Io { path, source }),
};
let config = parse(&content, &path)?;
for section in ignored_sections(&config.policy, required) {
if required {
eprintln!(
"deps-cli: warning: {path}'s [{section}] section has no effect in deps-cli today — see `deps_cli::config::ignored_sections`'s doc for why",
path = crate::sanitize::sanitize_path_for_display(&path).display(),
);
} else {
eprintln!(
"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",
path = crate::sanitize::sanitize_path_for_display(&path).display(),
);
}
}
if !required {
return Ok(safe_auto_discovered_config(config));
}
Ok(config)
}
#[must_use]
pub fn safe_auto_discovered_config(parsed: CliConfig) -> CliConfig {
CliConfig {
policy: PolicyConfig {
diagnostics: DiagnosticsConfig::new()
.with_outdated_severity(parsed.policy.diagnostics.outdated_severity)
.with_unknown_severity(parsed.policy.diagnostics.unknown_severity)
.with_yanked_severity(parsed.policy.diagnostics.yanked_severity)
.with_unsatisfiable_severity(parsed.policy.diagnostics.unsatisfiable_severity)
.with_deprecated_severity(parsed.policy.diagnostics.deprecated_severity)
.with_mutable_ref_pin_severity(parsed.policy.diagnostics.mutable_ref_pin_severity)
.with_sha_comment_mismatch_severity(
parsed.policy.diagnostics.sha_comment_mismatch_severity,
),
..PolicyConfig::default()
},
..CliConfig::default()
}
}
fn ignored_sections(policy: &PolicyConfig, required: bool) -> Vec<&'static str> {
let default = PolicyConfig::default();
let mut sections = Vec::new();
if !required {
if policy.diagnostics.mutable_ref_pin_enabled != default.diagnostics.mutable_ref_pin_enabled
|| policy.diagnostics.vulnerabilities_enabled
!= default.diagnostics.vulnerabilities_enabled
{
sections.push("diagnostics");
}
if policy.cache.enabled != default.cache.enabled
|| policy.cache.fetch_timeout_secs != default.cache.fetch_timeout_secs
|| policy.cache.max_concurrent_fetches != default.cache.max_concurrent_fetches
{
sections.push("cache");
}
if policy.freshness.enabled != default.freshness.enabled
|| policy.freshness.cooldown_secs != default.freshness.cooldown_secs
{
sections.push("freshness");
}
if policy.supply_chain.enabled != default.supply_chain.enabled {
sections.push("supply_chain");
}
if policy.registries.workspace_registries != default.registries.workspace_registries
|| policy.registries.nuget_user_profile_sources
!= default.registries.nuget_user_profile_sources
|| policy.registries.gitlab_instance_host != default.registries.gitlab_instance_host
{
sections.push("registries");
}
if policy.network.offline != default.network.offline {
sections.push("network");
}
if policy.license_policy.allow != default.license_policy.allow
|| policy.license_policy.deny != default.license_policy.deny
{
sections.push("license_policy");
}
if policy.gossip.enabled != default.gossip.enabled {
sections.push("gossip");
}
}
if policy.typosquat.enabled != default.typosquat.enabled {
sections.push("typosquat");
}
sections
}
fn parse(content: &str, path: &Path) -> Result<CliConfig, ConfigError> {
let value = deps_core::parse_toml_checked(content).map_err(|source| ConfigError::Toml {
path: path.to_path_buf(),
message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
.into_owned(),
})?;
let json = serde_json::to_value(&value).map_err(|source| ConfigError::Deserialize {
path: path.to_path_buf(),
message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
.into_owned(),
})?;
serde_json::from_value(json).map_err(|source| ConfigError::Deserialize {
path: path.to_path_buf(),
message: deps_core::net_policy::redact_parse_error_for_log(&source.to_string())
.into_owned(),
})
}
#[cfg(feature = "fuzzing")]
#[doc(hidden)]
pub fn fuzz_parse_config(content: &str) {
let _ = parse(content, Path::new(DEFAULT_CONFIG_FILENAME));
}
pub fn apply_overrides(
mut config: CliConfig,
offline: deps_core::NetworkMode,
cooldown: Option<u64>,
) -> CliConfig {
if offline == deps_core::NetworkMode::Offline {
config.policy.network.offline = true;
}
if let Some(cooldown_secs) = cooldown {
config.policy.freshness.cooldown_secs = cooldown_secs;
}
config
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn write_temp_toml(content: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::NamedTempFile::new().expect("create temp file");
file.write_all(content.as_bytes()).expect("write temp file");
file
}
#[test]
fn test_ignored_sections_includes_gossip_when_enabled_and_auto_discovered() {
let mut policy = PolicyConfig::default();
policy.gossip.enabled = true;
assert!(ignored_sections(&policy, false).contains(&"gossip"));
}
#[test]
fn test_ignored_sections_omits_gossip_when_default() {
let policy = PolicyConfig::default();
assert!(!ignored_sections(&policy, false).contains(&"gossip"));
}
#[test]
fn test_ignored_sections_omits_gossip_when_required() {
let mut policy = PolicyConfig::default();
policy.gossip.enabled = true;
assert!(!ignored_sections(&policy, true).contains(&"gossip"));
}
#[test]
fn test_ignored_sections_includes_typosquat_when_enabled_regardless_of_required() {
let mut policy = PolicyConfig::default();
policy.typosquat.enabled = true;
assert!(ignored_sections(&policy, false).contains(&"typosquat"));
assert!(ignored_sections(&policy, true).contains(&"typosquat"));
}
#[test]
fn test_ignored_sections_omits_typosquat_when_default() {
let policy = PolicyConfig::default();
assert!(!ignored_sections(&policy, false).contains(&"typosquat"));
assert!(!ignored_sections(&policy, true).contains(&"typosquat"));
}
#[test]
fn test_load_warns_for_typosquat_in_explicit_config() {
let file = write_temp_toml(
r"
[typosquat]
enabled = true
",
);
let config = load(Some(file.path()), Path::new("."))
.expect("explicit config with a non-default [typosquat] section must still load");
assert!(config.policy.typosquat.enabled);
}
#[test]
fn test_load_missing_default_file_returns_defaults() {
let dir = tempfile::tempdir().expect("create temp dir");
let config = load(None, dir.path()).expect("missing default file is not an error");
assert_eq!(
config.policy.network.offline,
PolicyConfig::default().network.offline
);
}
#[test]
fn test_load_missing_explicit_path_is_an_error() {
let missing = PathBuf::from("/nonexistent/path/to/deps.toml");
let result = load(Some(&missing), Path::new("."));
assert!(matches!(result, Err(ConfigError::Io { .. })));
}
#[test]
fn test_load_valid_toml_parses_policy_sections() {
let file = write_temp_toml(
r"
[network]
offline = true
[freshness]
cooldown_secs = 60
",
);
let config = load(Some(file.path()), Path::new(".")).expect("valid TOML must parse");
assert!(config.policy.network.offline);
assert_eq!(config.policy.freshness.cooldown_secs, 60);
}
#[test]
fn test_load_malformed_toml_is_an_error() {
let file = write_temp_toml("this is not [ valid toml");
let result = load(Some(file.path()), Path::new("."));
assert!(matches!(result, Err(ConfigError::Toml { .. })));
}
#[test]
fn test_load_excessively_nested_toml_is_rejected_not_stack_overflow() {
let depth = deps_core::MAX_TOML_NESTING_DEPTH + 1;
let content = format!("x = {}1{}\n", "{a=".repeat(depth), "}".repeat(depth));
let file = write_temp_toml(&content);
let result = load(Some(file.path()), Path::new("."));
let Err(ConfigError::Toml { message, .. }) = result else {
panic!("expected ConfigError::Toml, got {result:?}");
};
assert!(
message.contains("nesting depth"),
"expected a nesting-depth message, got: {message}"
);
}
#[test]
fn test_load_duplicate_table_toml_error_redacts_credential() {
let file = write_temp_toml(
r#"
["https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"]
a = 1
["https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"]
b = 2
"#,
);
let result = load(Some(file.path()), Path::new("."));
let message = result.unwrap_err().to_string();
assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
assert!(!message.contains("svcacct"));
assert!(message.contains("pkg.internal.corp"));
}
#[test]
fn test_load_duplicate_table_toml_error_benign_name_unchanged() {
let content = r"
[serde]
a = 1
[serde]
b = 2
";
let file = write_temp_toml(content);
let raw_err = toml_span::parse(content).unwrap_err();
let expected = format!(
"failed to parse TOML in {}: {raw_err}",
file.path().display()
);
let result = load(Some(file.path()), Path::new("."));
assert_eq!(result.unwrap_err().to_string(), expected);
}
#[test]
fn test_load_unknown_top_level_key_is_rejected() {
let file = write_temp_toml("totally_unknown_key = true\n");
let result = load(Some(file.path()), Path::new("."));
assert!(matches!(result, Err(ConfigError::Deserialize { .. })));
}
#[test]
fn test_load_unknown_field_credential_shaped_name_redacted() {
let file = write_temp_toml(
"\"https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x\" = true\n",
);
let message = load(Some(file.path()), Path::new("."))
.unwrap_err()
.to_string();
assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
assert!(!message.contains("svcacct"));
assert!(message.contains("pkg.internal.corp"));
}
#[test]
fn test_load_wrong_typed_value_credential_shaped_string_redacted() {
let file = write_temp_toml(
r#"
[cache]
enabled = "https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x"
"#,
);
let message = load(Some(file.path()), Path::new("."))
.unwrap_err()
.to_string();
assert!(!message.contains("ghp_SUPERSECRETTOKEN123"));
assert!(!message.contains("svcacct"));
assert!(message.contains("pkg.internal.corp"));
}
#[test]
fn test_load_unknown_field_benign_name_unchanged() {
let content = "totally_unknown_key = true\n";
let file = write_temp_toml(content);
let value = toml_span::parse(content).unwrap();
let json = serde_json::to_value(&value).unwrap();
let raw_err = serde_json::from_value::<CliConfig>(json).unwrap_err();
let expected = format!(
"invalid configuration in {}: {raw_err}",
file.path().display()
);
let message = load(Some(file.path()), Path::new("."))
.unwrap_err()
.to_string();
assert_eq!(message, expected);
}
#[test]
fn test_load_unknown_key_nested_in_known_section_is_tolerated() {
let file = write_temp_toml(
r#"
[network]
offline = true
future_field = "ignored"
"#,
);
let config = load(Some(file.path()), Path::new("."))
.expect("nested unknown key must not reject the payload");
assert!(config.policy.network.offline);
}
#[test]
fn test_load_auto_discovery_resolves_against_given_default_dir_not_cwd() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
"[diagnostics]\noutdated_severity = 1\n",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("deps.toml in default_dir must be found");
assert_eq!(
config.policy.diagnostics.outdated_severity,
deps_core::diagnostic::Severity::Error
);
}
#[test]
fn test_load_auto_discovered_registries_section_is_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
r#"
[registries]
gitlab_instance_host = "attacker-host.invalid"
workspace_registries = "all"
"#,
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert_eq!(config.policy.registries.gitlab_instance_host, "");
assert_eq!(
config.policy.registries.workspace_registries,
deps_core::policy_config::WorkspaceRegistriesSetting::PublicOnly
);
}
#[test]
fn test_load_auto_discovered_diagnostics_enabled_flags_are_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
r"
[diagnostics]
mutable_ref_pin_enabled = false
vulnerabilities_enabled = false
",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert!(config.policy.diagnostics.mutable_ref_pin_enabled);
assert!(config.policy.diagnostics.vulnerabilities_enabled);
}
#[test]
fn test_load_auto_discovered_network_offline_is_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
"[network]\noffline = true\n",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert!(!config.policy.network.offline);
}
#[test]
fn test_load_auto_discovered_remaining_gate_relevant_sections_are_ignored() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
r#"
[freshness]
cooldown_secs = 0
[license_policy]
allow = ["GPL-3.0"]
[cache]
fetch_timeout_secs = 1
[supply_chain]
enabled = false
"#,
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
let default = PolicyConfig::default();
assert_eq!(
config.policy.freshness.cooldown_secs,
default.freshness.cooldown_secs
);
assert_eq!(
config.policy.license_policy.allow,
default.license_policy.allow
);
assert_eq!(
config.policy.cache.fetch_timeout_secs,
default.cache.fetch_timeout_secs
);
assert_eq!(
config.policy.supply_chain.enabled,
default.supply_chain.enabled
);
}
#[test]
fn test_load_auto_discovered_severity_values_are_kept() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
"[diagnostics]\nyanked_severity = 4\n",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert_eq!(
config.policy.diagnostics.yanked_severity,
deps_core::diagnostic::Severity::Hint
);
}
#[test]
fn test_load_auto_discovered_pin_severities_are_kept() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
"[diagnostics]\nmutable_ref_pin_severity = 1\nsha_comment_mismatch_severity = 1\n",
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert_eq!(
config.policy.diagnostics.mutable_ref_pin_severity,
deps_core::diagnostic::Severity::Error
);
assert_eq!(
config.policy.diagnostics.sha_comment_mismatch_severity,
deps_core::diagnostic::Severity::Error
);
}
#[test]
fn test_load_explicit_config_registries_section_is_trusted() {
let file = write_temp_toml(
r#"
[registries]
gitlab_instance_host = "gitlab.mycorp.dev"
"#,
);
let config = load(Some(file.path()), Path::new("."))
.expect("explicit config with a registries section must load");
assert_eq!(
config.policy.registries.gitlab_instance_host,
"gitlab.mycorp.dev"
);
}
#[test]
fn test_apply_overrides_offline_flag_forces_true() {
let config = apply_overrides(CliConfig::default(), deps_core::NetworkMode::Offline, None);
assert!(config.policy.network.offline);
}
#[test]
fn test_apply_overrides_offline_absent_keeps_file_value() {
let mut base = CliConfig::default();
base.policy.network.offline = true;
let config = apply_overrides(base, deps_core::NetworkMode::Online, None);
assert!(
config.policy.network.offline,
"absent flag must not clear a file-set true"
);
}
#[test]
fn test_apply_overrides_cooldown_replaces_file_value() {
let mut base = CliConfig::default();
base.policy.freshness.cooldown_secs = 999;
let config = apply_overrides(base, deps_core::NetworkMode::Online, Some(42));
assert_eq!(config.policy.freshness.cooldown_secs, 42);
}
#[test]
fn test_apply_overrides_no_cooldown_keeps_file_value() {
let mut base = CliConfig::default();
base.policy.freshness.cooldown_secs = 999;
let config = apply_overrides(base, deps_core::NetworkMode::Online, None);
assert_eq!(config.policy.freshness.cooldown_secs, 999);
}
#[test]
fn test_load_auto_discovered_update_ignore_is_dropped() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(
dir.path().join(DEFAULT_CONFIG_FILENAME),
r#"
[diagnostics]
yanked_severity = 4
[[update.ignore]]
name = "tokio"
update_types = ["major"]
"#,
)
.expect("write deps.toml");
let config = load(None, dir.path()).expect("auto-discovered file must still load");
assert!(
config.update.ignore.is_empty(),
"an auto-discovered [update].ignore must never take effect"
);
assert_eq!(
config.policy.diagnostics.yanked_severity,
deps_core::diagnostic::Severity::Hint
);
}
#[test]
fn test_load_explicit_config_update_ignore_is_trusted_verbatim() {
let file = write_temp_toml(
r#"
[[update.ignore]]
name = "tokio"
update_types = ["major"]
[[update.ignore]]
name = "legacy-thing"
"#,
);
let config = load(Some(file.path()), Path::new("."))
.expect("explicit --config must load [update].ignore verbatim");
assert_eq!(config.update.ignore.len(), 2);
assert_eq!(config.update.ignore[0].name, "tokio");
assert_eq!(
config.update.ignore[0].update_types,
Some(vec![UpdateTypeToken::Major])
);
assert_eq!(config.update.ignore[1].name, "legacy-thing");
assert_eq!(config.update.ignore[1].update_types, None);
}
#[test]
fn test_load_update_ignore_unrecognized_update_types_token_is_a_hard_error() {
let file = write_temp_toml(
r#"
[[update.ignore]]
name = "tokio"
update_types = ["unknown"]
"#,
);
let result = load(Some(file.path()), Path::new("."));
assert!(matches!(result, Err(ConfigError::Deserialize { .. })));
}
#[test]
fn test_update_type_token_matches_only_its_own_kind() {
assert!(UpdateTypeToken::Major.matches(deps_core::edit::UpdateKind::Major));
assert!(!UpdateTypeToken::Major.matches(deps_core::edit::UpdateKind::Minor));
assert!(!UpdateTypeToken::Major.matches(deps_core::edit::UpdateKind::Unknown));
assert!(UpdateTypeToken::Minor.matches(deps_core::edit::UpdateKind::Minor));
assert!(UpdateTypeToken::Patch.matches(deps_core::edit::UpdateKind::Patch));
}
}