#![allow(deprecated)]
use std::io::Write;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawSecurityConfig {
#[serde(flatten)]
pub canonical: crate::profile::SecurityConfig,
#[serde(default, rename = "groups")]
pub legacy_groups: Vec<String>,
#[serde(default, rename = "allowed_commands")]
pub legacy_allowed_commands: Vec<String>,
}
impl From<&RawSecurityConfig> for crate::profile::SecurityConfig {
fn from(raw: &RawSecurityConfig) -> Self {
raw.canonical.clone()
}
}
pub(crate) fn drain_legacy_security_into_canonical(
raw: &RawSecurityConfig,
profile: &mut crate::profile::Profile,
) {
if !raw.legacy_groups.is_empty() {
emit_deprecation_warning("security.groups", "groups.include", "v1.0.0", "#594");
profile
.groups
.include
.extend(raw.legacy_groups.iter().cloned());
}
if !raw.legacy_allowed_commands.is_empty() {
emit_deprecation_warning(
"security.allowed_commands",
"commands.allow",
"v1.0.0",
"#594",
);
profile
.commands
.allow
.extend(raw.legacy_allowed_commands.iter().cloned());
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct LegacyPolicyPatch {
#[serde(default)]
pub exclude_groups: Vec<String>,
#[serde(default)]
pub add_allow_read: Vec<String>,
#[serde(default)]
pub add_allow_write: Vec<String>,
#[serde(default)]
pub add_allow_readwrite: Vec<String>,
#[serde(default)]
pub add_deny_access: Vec<String>,
#[serde(default)]
pub add_deny_commands: Vec<String>,
#[serde(default)]
pub override_deny: Vec<String>,
}
pub(crate) fn emit_deprecation_warning(
legacy: &str,
canonical: &str,
remove_by: &str,
issue: &str,
) {
if crate::deprecation_warnings::is_suppressed() {
return;
}
crate::deprecation_warnings::note_deprecation();
let _ = writeln!(
std::io::stderr(),
"{}",
format_deprecation_warning(legacy, canonical, remove_by, issue)
);
}
pub(crate) fn detect_deprecated_flags(args: &[std::ffi::OsString]) -> Vec<&'static str> {
let mut hits: Vec<&'static str> = Vec::new();
for a in args {
let s = a.to_string_lossy();
let is_override_deny = s == "--override-deny" || s.starts_with("--override-deny=");
if is_override_deny && !hits.contains(&"--override-deny") {
hits.push("--override-deny");
}
}
hits
}
pub fn warn_for_deprecated_flags(args: &[std::ffi::OsString]) {
for flag in detect_deprecated_flags(args) {
let canonical = match flag {
"--override-deny" => "--bypass-protection",
_ => continue,
};
emit_deprecation_warning(flag, canonical, "v1.0.0", "#594");
}
}
pub(crate) fn drain_legacy_policy_into_canonical(
legacy: &LegacyPolicyPatch,
profile: &mut crate::profile::Profile,
) {
if !legacy.exclude_groups.is_empty() {
emit_deprecation_warning("policy.exclude_groups", "groups.exclude", "v1.0.0", "#594");
profile
.groups
.exclude
.extend(legacy.exclude_groups.iter().cloned());
}
if !legacy.add_allow_read.is_empty() {
emit_deprecation_warning("policy.add_allow_read", "filesystem.read", "v1.0.0", "#594");
profile
.filesystem
.read
.extend(legacy.add_allow_read.iter().cloned());
}
if !legacy.add_allow_write.is_empty() {
emit_deprecation_warning(
"policy.add_allow_write",
"filesystem.write",
"v1.0.0",
"#594",
);
profile
.filesystem
.write
.extend(legacy.add_allow_write.iter().cloned());
}
if !legacy.add_allow_readwrite.is_empty() {
emit_deprecation_warning(
"policy.add_allow_readwrite",
"filesystem.allow",
"v1.0.0",
"#594",
);
profile
.filesystem
.allow
.extend(legacy.add_allow_readwrite.iter().cloned());
}
if !legacy.add_deny_access.is_empty() {
emit_deprecation_warning(
"policy.add_deny_access",
"filesystem.deny",
"v1.0.0",
"#594",
);
profile
.filesystem
.deny
.extend(legacy.add_deny_access.iter().cloned());
}
if !legacy.add_deny_commands.is_empty() {
emit_deprecation_warning(
"policy.add_deny_commands",
"commands.deny",
"v1.0.0",
"#594",
);
profile
.commands
.deny
.extend(legacy.add_deny_commands.iter().cloned());
}
if !legacy.override_deny.is_empty() {
emit_deprecation_warning(
"policy.override_deny",
"filesystem.bypass_protection",
"v1.0.0",
"#594",
);
profile
.filesystem
.bypass_protection
.extend(legacy.override_deny.iter().cloned());
}
}
pub(crate) fn format_deprecation_warning(
legacy: &str,
canonical: &str,
remove_by: &str,
issue: &str,
) -> String {
format!(
"warning: deprecated key '{legacy}' — use '{canonical}' instead (will be removed in {remove_by}, {issue})"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deprecation_warnings::{WarningCounterGuard, WarningSuppressionGuard};
fn canonical_empty_profile() -> crate::profile::Profile {
crate::profile::Profile::default()
}
#[test]
fn test_drain_exclude_groups_goes_to_groups_exclude() {
let mut profile = canonical_empty_profile();
let legacy = LegacyPolicyPatch {
exclude_groups: vec!["x".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert_eq!(profile.groups.exclude, vec!["x"]);
}
#[test]
fn test_drain_add_allow_read_goes_to_filesystem_read() {
let mut profile = canonical_empty_profile();
let legacy = LegacyPolicyPatch {
add_allow_read: vec!["/r".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert_eq!(profile.filesystem.read, vec!["/r"]);
}
#[test]
fn test_drain_add_allow_write_goes_to_filesystem_write() {
let mut profile = canonical_empty_profile();
let legacy = LegacyPolicyPatch {
add_allow_write: vec!["/w".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert_eq!(profile.filesystem.write, vec!["/w"]);
}
#[test]
fn test_drain_add_allow_readwrite_goes_to_filesystem_allow() {
let mut profile = canonical_empty_profile();
let legacy = LegacyPolicyPatch {
add_allow_readwrite: vec!["/rw".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert_eq!(profile.filesystem.allow, vec!["/rw"]);
}
#[test]
fn test_drain_add_deny_access_goes_to_filesystem_deny() {
let mut profile = canonical_empty_profile();
let legacy = LegacyPolicyPatch {
add_deny_access: vec!["/d".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert_eq!(profile.filesystem.deny, vec!["/d"]);
}
#[test]
fn test_drain_add_deny_commands_goes_to_commands_deny() {
let mut profile = canonical_empty_profile();
let legacy = LegacyPolicyPatch {
add_deny_commands: vec!["cmd".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert_eq!(profile.commands.deny, vec!["cmd"]);
}
#[test]
fn test_drain_override_deny_goes_to_filesystem_bypass_protection() {
let mut profile = canonical_empty_profile();
let legacy = LegacyPolicyPatch {
override_deny: vec!["/o".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert_eq!(profile.filesystem.bypass_protection, vec!["/o"]);
}
#[test]
fn test_drain_preserves_existing_canonical_values() {
let mut profile = canonical_empty_profile();
profile.filesystem.read.push("/existing".into());
let legacy = LegacyPolicyPatch {
add_allow_read: vec!["/from_legacy".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert_eq!(profile.filesystem.read, vec!["/existing", "/from_legacy"]);
}
#[test]
fn test_drain_empty_legacy_is_noop() {
let mut profile = canonical_empty_profile();
let legacy = LegacyPolicyPatch::default();
drain_legacy_policy_into_canonical(&legacy, &mut profile);
assert!(profile.groups.exclude.is_empty());
assert!(profile.filesystem.read.is_empty());
assert!(profile.filesystem.write.is_empty());
assert!(profile.filesystem.allow.is_empty());
assert!(profile.filesystem.deny.is_empty());
assert!(profile.commands.deny.is_empty());
assert!(profile.filesystem.bypass_protection.is_empty());
}
#[test]
fn test_format_deprecation_warning_includes_all_fields() {
let s = format_deprecation_warning(
"policy.add_allow_read",
"filesystem.read",
"v1.0.0",
"#594",
);
assert!(s.contains("policy.add_allow_read"));
assert!(s.contains("filesystem.read"));
assert!(s.contains("v1.0.0"));
assert!(s.contains("#594"));
}
#[test]
fn test_raw_security_accepts_legacy_groups_and_allowed_commands() {
let json = r#"{
"groups": ["node_runtime"],
"allowed_commands": ["pip"],
"signal_mode": "isolated"
}"#;
let raw: RawSecurityConfig = serde_json::from_str(json).expect("parse");
assert_eq!(raw.legacy_groups, vec!["node_runtime"]);
assert_eq!(raw.legacy_allowed_commands, vec!["pip"]);
assert!(raw.canonical.signal_mode.is_some()); }
#[test]
fn test_raw_security_accepts_canonical_only() {
let json = r#"{"signal_mode": "isolated"}"#;
let raw: RawSecurityConfig = serde_json::from_str(json).expect("parse");
assert!(raw.legacy_groups.is_empty());
assert!(raw.legacy_allowed_commands.is_empty());
assert!(raw.canonical.signal_mode.is_some());
}
#[test]
fn test_drain_legacy_groups_goes_to_groups_include() {
let mut profile = crate::profile::Profile::default();
let raw = RawSecurityConfig {
legacy_groups: vec!["g1".into()],
..Default::default()
};
drain_legacy_security_into_canonical(&raw, &mut profile);
assert_eq!(profile.groups.include, vec!["g1"]);
}
#[test]
fn test_drain_legacy_allowed_commands_goes_to_commands_allow() {
let mut profile = crate::profile::Profile::default();
let raw = RawSecurityConfig {
legacy_allowed_commands: vec!["c1".into()],
..Default::default()
};
drain_legacy_security_into_canonical(&raw, &mut profile);
assert_eq!(profile.commands.allow, vec!["c1"]);
}
#[test]
fn test_drain_legacy_security_empty_is_noop() {
let mut profile = crate::profile::Profile::default();
let raw = RawSecurityConfig::default();
drain_legacy_security_into_canonical(&raw, &mut profile);
assert!(profile.groups.include.is_empty());
assert!(profile.commands.allow.is_empty());
}
#[test]
fn test_drain_preserves_existing_canonical_security_values() {
let mut profile = crate::profile::Profile::default();
profile.groups.include.push("existing".into());
let raw = RawSecurityConfig {
legacy_groups: vec!["from_legacy".into()],
..Default::default()
};
drain_legacy_security_into_canonical(&raw, &mut profile);
assert_eq!(profile.groups.include, vec!["existing", "from_legacy"]);
}
#[test]
fn test_raw_security_to_canonical_carries_all_process_fields() {
let json = r#"{"signal_mode": "isolated", "ipc_mode": "full"}"#;
let raw: RawSecurityConfig = serde_json::from_str(json).expect("parse");
let canonical = crate::profile::SecurityConfig::from(&raw);
assert!(canonical.signal_mode.is_some());
assert!(canonical.ipc_mode.is_some());
}
#[test]
fn test_raw_security_accepts_both_legacy_and_canonical_together() {
let json = r#"{
"groups": ["g"],
"signal_mode": "isolated",
"ipc_mode": "full"
}"#;
let raw: RawSecurityConfig = serde_json::from_str(json).expect("parse");
assert_eq!(raw.legacy_groups, vec!["g"]);
assert!(raw.canonical.signal_mode.is_some());
assert!(raw.canonical.ipc_mode.is_some());
let canonical = crate::profile::SecurityConfig::from(&raw);
assert!(canonical.signal_mode.is_some());
assert!(canonical.ipc_mode.is_some());
}
#[test]
fn test_raw_security_from_impl_copies_all_five_canonical_fields() {
let json = r#"{
"signal_mode": "isolated",
"process_info_mode": "isolated",
"ipc_mode": "full",
"capability_elevation": true,
"wsl2_proxy_policy": "error"
}"#;
let raw: RawSecurityConfig = serde_json::from_str(json).expect("parse");
let canonical = crate::profile::SecurityConfig::from(&raw);
assert!(canonical.signal_mode.is_some());
assert!(canonical.process_info_mode.is_some());
assert!(canonical.ipc_mode.is_some());
assert_eq!(canonical.capability_elevation, Some(true));
assert!(canonical.wsl2_proxy_policy.is_some());
}
#[test]
fn test_raw_security_rejects_typoed_canonical_field() {
let json = r#"{
"signal_modee": "isolated"
}"#;
let result: Result<RawSecurityConfig, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"typoed canonical key 'signal_modee' must be rejected, not silently dropped"
);
}
#[test]
fn test_raw_security_rejects_typoed_legacy_field() {
let json = r#"{
"groupz": ["x"]
}"#;
let result: Result<RawSecurityConfig, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"typoed legacy key 'groupz' must be rejected, not silently dropped"
);
}
#[test]
fn test_raw_security_rejects_unknown_sibling() {
let json = r#"{
"random_unknown_key": "x"
}"#;
let result: Result<RawSecurityConfig, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"unknown sibling key must be rejected; flatten + deny_unknown_fields \
must remain strict"
);
}
#[test]
fn test_raw_security_rejects_typo_alongside_valid_fields() {
let json = r#"{
"signal_mode": "isolated",
"groups": ["g1"],
"ipc_mod": "full"
}"#;
let result: Result<RawSecurityConfig, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"typo 'ipc_mod' alongside valid fields must still cause rejection"
);
}
#[test]
fn test_warn_for_deprecated_flags_detects_override_deny() {
use std::ffi::OsString;
let args: Vec<OsString> = ["nono", "run", "--override-deny", "/x"]
.iter()
.map(OsString::from)
.collect();
let detected = detect_deprecated_flags(&args);
assert_eq!(detected, vec!["--override-deny"]);
}
#[test]
fn test_warn_for_deprecated_flags_detects_override_deny_equals_form() {
use std::ffi::OsString;
let args: Vec<OsString> = ["nono", "run", "--override-deny=/x"]
.iter()
.map(OsString::from)
.collect();
let detected = detect_deprecated_flags(&args);
assert_eq!(detected, vec!["--override-deny"]);
}
#[test]
fn test_detect_deprecated_flags_deduplicates_repeated_occurrences() {
use std::ffi::OsString;
let args: Vec<OsString> = [
"nono",
"run",
"--override-deny",
"/a",
"--override-deny",
"/b",
]
.iter()
.map(OsString::from)
.collect();
let detected = detect_deprecated_flags(&args);
assert_eq!(detected, vec!["--override-deny"]);
}
#[test]
fn test_detect_deprecated_flags_returns_empty_when_no_legacy_flags() {
use std::ffi::OsString;
let args: Vec<OsString> = ["nono", "run", "--bypass-protection", "/x"]
.iter()
.map(OsString::from)
.collect();
let detected = detect_deprecated_flags(&args);
assert!(detected.is_empty());
}
#[test]
fn test_legacy_policy_patch_deserializes_all_fields() {
let json = r#"{
"exclude_groups": ["x"],
"add_allow_read": ["/r"],
"add_allow_write": ["/w"],
"add_allow_readwrite": ["/rw"],
"add_deny_access": ["/d"],
"add_deny_commands": ["cmd"],
"override_deny": ["/o"]
}"#;
let patch: LegacyPolicyPatch = serde_json::from_str(json).expect("parse");
assert_eq!(patch.exclude_groups, vec!["x"]);
assert_eq!(patch.add_allow_read, vec!["/r"]);
assert_eq!(patch.add_allow_write, vec!["/w"]);
assert_eq!(patch.add_allow_readwrite, vec!["/rw"]);
assert_eq!(patch.add_deny_access, vec!["/d"]);
assert_eq!(patch.add_deny_commands, vec!["cmd"]);
assert_eq!(patch.override_deny, vec!["/o"]);
}
#[test]
fn test_warning_counter_guard_counts_within_scope() {
let guard = WarningCounterGuard::begin();
let mut profile = crate::profile::Profile::default();
let legacy = LegacyPolicyPatch {
exclude_groups: vec!["x".into()],
add_allow_read: vec!["/r".into()],
..Default::default()
};
drain_legacy_policy_into_canonical(&legacy, &mut profile);
let n = guard.finish();
assert_eq!(n, 2, "expected 2 warnings (one per populated legacy key)");
}
#[test]
fn test_warning_counter_guard_is_noop_outside_scope() {
emit_deprecation_warning("legacy", "canonical", "v1.0.0", "#594");
let guard = WarningCounterGuard::begin();
let n = guard.finish();
assert_eq!(
n, 0,
"counter outside-scope emissions must not leak into next scope"
);
}
#[test]
fn test_warning_counter_guard_cleared_on_early_drop() {
{
let _guard = WarningCounterGuard::begin();
emit_deprecation_warning("legacy", "canonical", "v1.0.0", "#594");
}
let guard = WarningCounterGuard::begin();
emit_deprecation_warning("legacy2", "canonical2", "v1.0.0", "#594");
let n = guard.finish();
assert_eq!(n, 1, "second scope must start fresh at 0");
}
#[test]
fn test_warning_suppression_guard_blocks_emit_and_count() {
let counter = WarningCounterGuard::begin();
{
let _suppress = WarningSuppressionGuard::begin();
emit_deprecation_warning("legacy", "canonical", "v1.0.0", "#594");
}
emit_deprecation_warning("legacy2", "canonical2", "v1.0.0", "#594");
let n = counter.finish();
assert_eq!(n, 1, "only the post-suppression emit should count; got {n}");
}
#[test]
fn test_warning_suppression_guard_nests_correctly() {
let counter = WarningCounterGuard::begin();
{
let _outer = WarningSuppressionGuard::begin();
{
let _inner = WarningSuppressionGuard::begin();
emit_deprecation_warning("a", "A", "v1.0.0", "#594");
}
emit_deprecation_warning("b", "B", "v1.0.0", "#594");
}
emit_deprecation_warning("c", "C", "v1.0.0", "#594");
let n = counter.finish();
assert_eq!(n, 1, "only post-suppression emit counts");
}
#[test]
fn legacy_policy_keys_drain_into_canonical_via_full_profile_parse() {
let json = r#"{
"meta": {"name": "drain-test"},
"security": {"groups": ["g1"], "allowed_commands": ["c1"]},
"policy": {
"exclude_groups": ["g2"],
"add_allow_read": ["/r"],
"add_allow_write": ["/w"],
"add_allow_readwrite": ["/rw"],
"add_deny_access": ["/d"],
"add_deny_commands": ["c2"],
"override_deny": ["/o"]
}
}"#;
let _suppress = WarningSuppressionGuard::begin(); let profile: crate::profile::Profile =
serde_json::from_str(json).expect("parse legacy profile");
assert_eq!(profile.groups.include, vec!["g1"]);
assert_eq!(profile.groups.exclude, vec!["g2"]);
assert_eq!(profile.commands.allow, vec!["c1"]);
assert_eq!(profile.commands.deny, vec!["c2"]);
assert_eq!(profile.filesystem.read, vec!["/r"]);
assert_eq!(profile.filesystem.write, vec!["/w"]);
assert_eq!(profile.filesystem.allow, vec!["/rw"]);
assert_eq!(profile.filesystem.deny, vec!["/d"]);
assert_eq!(profile.filesystem.bypass_protection, vec!["/o"]);
}
#[test]
fn legacy_security_allowed_commands_drains_to_canonical_commands_allow() {
let json = r#"{
"meta": { "name": "rm-test" },
"filesystem": { "allow": ["/tmp"] },
"security": { "allowed_commands": ["rm", "dd"] }
}"#;
let _suppress = WarningSuppressionGuard::begin();
let profile: crate::profile::Profile =
serde_json::from_str(json).expect("parse legacy profile");
assert_eq!(profile.commands.allow, vec!["rm", "dd"]);
}
#[test]
fn legacy_policy_patch_drains_full_set_via_full_profile_parse() {
let json = r#"{
"meta": { "name": "patchy" },
"policy": {
"exclude_groups": ["deny_shell_configs"],
"add_allow_read": ["/tmp/read"],
"add_allow_write": ["/tmp/write"],
"add_allow_readwrite": ["/tmp/rw"],
"add_deny_access": ["/tmp/deny"],
"override_deny": ["~/.docker"]
}
}"#;
let _suppress = WarningSuppressionGuard::begin();
let profile: crate::profile::Profile =
serde_json::from_str(json).expect("parse legacy profile");
assert_eq!(profile.groups.exclude, vec!["deny_shell_configs"]);
assert_eq!(profile.filesystem.read, vec!["/tmp/read"]);
assert_eq!(profile.filesystem.write, vec!["/tmp/write"]);
assert_eq!(profile.filesystem.allow, vec!["/tmp/rw"]);
assert_eq!(profile.filesystem.deny, vec!["/tmp/deny"]);
assert_eq!(profile.filesystem.bypass_protection, vec!["~/.docker"]);
}
}