use crate::config::Config;
use crate::engine::hook::{HookCheckResult, check_command_for_hook_with_rules};
use crate::rules::{ActionKind, CommandInvocation, RuleConfig, match_rule};
use proptest::prelude::*;
const COVERED_DESTRUCTIVE_RULES: &[&str] = &[
"rm-recursive-to-trash",
"git-push-force-block",
"git-clean-force-block",
"chmod-777-block",
"find-delete-block",
"rsync-delete-block",
];
fn layer1_destructive(program: &str, args: &[String], rules: &[RuleConfig]) -> bool {
let invocation = CommandInvocation::new(program.to_string(), args.to_vec());
match match_rule(rules, &invocation) {
Some(rule) => matches!(
rule.action,
ActionKind::Block | ActionKind::Trash | ActionKind::MoveTo
),
None => false,
}
}
fn layer2_blocks(command: &str, rules: &[RuleConfig]) -> bool {
matches!(
check_command_for_hook_with_rules(command, rules),
HookCheckResult::BlockMeta(_)
| HookCheckResult::BlockStructural { .. }
| HookCheckResult::BlockRule { .. }
)
}
fn arb_path() -> impl Strategy<Value = String> {
prop_oneof![
Just("/tmp/foo".to_string()),
Just("/tmp/dir-x".to_string()),
Just("/tmp/scratch".to_string()),
Just("/var/log/test.log".to_string()),
Just("/etc/passwd".to_string()),
Just("/etc/hosts".to_string()),
Just("/usr/local/share/x".to_string()),
Just("/opt/data".to_string()),
Just("/private/tmp/y".to_string()),
Just("/Users/test/work".to_string()),
]
}
fn arb_rm_core() -> impl Strategy<Value = (String, Vec<String>)> {
let flag_combo = prop_oneof![
Just(vec!["-rf".to_string()]),
Just(vec!["-fr".to_string()]),
Just(vec!["-r".to_string()]),
Just(vec!["-r".to_string(), "-f".to_string()]),
Just(vec!["-f".to_string(), "-r".to_string()]),
Just(vec!["--recursive".to_string()]),
];
(flag_combo, arb_path()).prop_map(|(mut flags, path)| {
flags.push(path);
("rm".to_string(), flags)
})
}
fn arb_find_core() -> impl Strategy<Value = (String, Vec<String>)> {
let delete_flag = prop_oneof![Just("-delete".to_string()), Just("--delete".to_string())];
(arb_path(), delete_flag).prop_map(|(path, flag)| ("find".to_string(), vec![path, flag]))
}
fn arb_chmod_core() -> impl Strategy<Value = (String, Vec<String>)> {
arb_path().prop_map(|path| ("chmod".to_string(), vec!["777".to_string(), path]))
}
fn arb_rsync_core() -> impl Strategy<Value = (String, Vec<String>)> {
let delete_flag = prop_oneof![
Just("--delete".to_string()),
Just("--del".to_string()),
Just("--delete-before".to_string()),
Just("--delete-during".to_string()),
Just("--delete-after".to_string()),
Just("--delete-excluded".to_string()),
Just("--delete-delay".to_string()),
Just("--remove-source-files".to_string()),
];
(arb_path(), arb_path(), delete_flag)
.prop_map(|(src, dst, flag)| ("rsync".to_string(), vec!["-av".to_string(), src, dst, flag]))
}
fn arb_git_push_force_core() -> impl Strategy<Value = (String, Vec<String>)> {
let force_flag = prop_oneof![Just("--force".to_string()), Just("-f".to_string())];
let remote = prop_oneof![Just("origin".to_string()), Just("upstream".to_string())];
let branch = prop_oneof![Just("main".to_string()), Just("master".to_string())];
(remote, branch, force_flag)
.prop_map(|(r, b, flag)| ("git".to_string(), vec!["push".to_string(), r, b, flag]))
}
fn arb_git_clean_force_core() -> impl Strategy<Value = (String, Vec<String>)> {
let force_flag = prop_oneof![Just("-f".to_string()), Just("--force".to_string())];
let extra_flag = prop_oneof![
Just(Vec::<String>::new()),
Just(vec!["-d".to_string()]),
Just(vec!["-x".to_string()]),
];
(force_flag, extra_flag).prop_map(|(force, extras)| {
let mut args = vec!["clean".to_string(), force];
args.extend(extras);
("git".to_string(), args)
})
}
fn arb_destructive_core() -> impl Strategy<Value = (String, Vec<String>)> {
prop_oneof![
arb_rm_core(),
arb_find_core(),
arb_chmod_core(),
arb_rsync_core(),
arb_git_push_force_core(),
arb_git_clean_force_core(),
]
}
#[derive(Debug, Clone, Copy)]
enum Wrapper {
Direct,
Sudo,
Doas,
Pkexec,
EnvU,
Timeout,
Nice,
Nohup,
Command,
Exec,
BashC,
PipeBash,
PipeEnvS,
SourceDevStdin,
}
const WRAPPER_TRANSPARENT_NAMES: &[&str] = &[
"sudo", "doas", "pkexec", "env", "timeout", "nice", "nohup", "command", "exec",
];
fn arb_wrapper() -> impl Strategy<Value = Wrapper> {
prop_oneof![
Just(Wrapper::Direct),
Just(Wrapper::Sudo),
Just(Wrapper::Doas),
Just(Wrapper::Pkexec),
Just(Wrapper::EnvU),
Just(Wrapper::Timeout),
Just(Wrapper::Nice),
Just(Wrapper::Nohup),
Just(Wrapper::Command),
Just(Wrapper::Exec),
Just(Wrapper::BashC),
Just(Wrapper::PipeBash),
Just(Wrapper::PipeEnvS),
Just(Wrapper::SourceDevStdin),
]
}
fn assemble_command(program: &str, args: &[String], wrapper: Wrapper) -> String {
let inner = format!("{} {}", program, args.join(" "));
match wrapper {
Wrapper::Direct => inner,
Wrapper::Sudo => format!("sudo {inner}"),
Wrapper::Doas => format!("doas {inner}"),
Wrapper::Pkexec => format!("pkexec {inner}"),
Wrapper::EnvU => format!("env -u SHLVL {inner}"),
Wrapper::Timeout => format!("timeout 30 {inner}"),
Wrapper::Nice => format!("nice {inner}"),
Wrapper::Nohup => format!("nohup {inner}"),
Wrapper::Command => format!("command {inner}"),
Wrapper::Exec => format!("exec {inner}"),
Wrapper::BashC => format!("bash -c \"{inner}\""),
Wrapper::PipeBash => format!("echo \"{inner}\" | bash"),
Wrapper::PipeEnvS => format!("echo \"{inner}\" | env -S 'bash -e'"),
Wrapper::SourceDevStdin => {
format!("echo \"{inner}\" | bash -c 'source /dev/stdin'")
}
}
}
#[derive(Debug, Clone)]
struct Case {
program: String,
args: Vec<String>,
wrapper: Wrapper,
}
impl Case {
fn command_string(&self) -> String {
assemble_command(&self.program, &self.args, self.wrapper)
}
}
fn arb_case() -> impl Strategy<Value = Case> {
(arb_destructive_core(), arb_wrapper()).prop_map(|((program, args), wrapper)| Case {
program,
args,
wrapper,
})
}
#[test]
fn wrapper_kinds_cover_transparent_wrappers_sot() {
let mut sot: Vec<&str> = crate::unwrap::TRANSPARENT_WRAPPERS.to_vec();
sot.sort();
let mut covered: Vec<&str> = WRAPPER_TRANSPARENT_NAMES.to_vec();
covered.sort();
assert_eq!(
covered, sot,
"Wrapper enum drifted from `unwrap::TRANSPARENT_WRAPPERS`.\n\
Expected (WRAPPER_TRANSPARENT_NAMES): {covered:?}\n\
Actual (unwrap::TRANSPARENT_WRAPPERS): {sot:?}\n\
Update WRAPPER_TRANSPARENT_NAMES, the Wrapper enum, arb_wrapper, \
assemble_command, and the module-level doc when the SoT changes.",
);
}
#[test]
fn coverage_matches_default_rules_destructive_set() {
let config = Config::default();
let mut actual: Vec<&str> = config
.rules
.iter()
.filter(|r| {
matches!(
r.action,
ActionKind::Block | ActionKind::Trash | ActionKind::MoveTo
)
})
.map(|r| r.name.as_str())
.collect();
actual.sort();
let mut expected: Vec<&str> = COVERED_DESTRUCTIVE_RULES.to_vec();
expected.sort();
assert_eq!(
actual, expected,
"Generator coverage drifted from default_rules() destructive set.\n\
Expected (COVERED_DESTRUCTIVE_RULES): {expected:?}\n\
Actual (default_rules destructive): {actual:?}\n\
Update both COVERED_DESTRUCTIVE_RULES and arb_destructive_core when \
a destructive built-in rule changes.",
);
}
#[test]
fn wrapper_stack_smoke_pins_layer2_block() {
let config = Config::default();
let stacks: &[&str] = &[
"sudo env -u SHLVL rm -rf /tmp/foo",
"timeout 30 nohup rm -rf /tmp/foo",
"sudo env bash -c \"rm -rf /tmp/foo\"",
];
for cmd in stacks {
assert!(
layer2_blocks(cmd, &config.rules),
"Wrapper-stack smoke must Layer 2 Block: {cmd}",
);
}
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 256,
..ProptestConfig::default()
})]
#[test]
fn cross_layer_layer1_destructive_implies_layer2_blocks(case in arb_case()) {
let config = Config::default();
let l1 = layer1_destructive(&case.program, &case.args, &config.rules);
if l1 {
let cmd = case.command_string();
let l2 = layer2_blocks(&cmd, &config.rules);
prop_assert!(
l2,
"Layer 1 destructive but Layer 2 allowed:\n program: {}\n args: {:?}\n wrapper: {:?}\n cmd: {}",
case.program,
case.args,
case.wrapper,
cmd,
);
}
}
#[test]
fn generator_emits_only_destructive_cores(case in arb_case()) {
let config = Config::default();
prop_assert!(
layer1_destructive(&case.program, &case.args, &config.rules),
"Generator emitted a non-destructive core:\n program: {}\n args: {:?}\n cmd: {}",
case.program,
case.args,
case.command_string(),
);
}
}