use crate::config::Config;
use crate::engine::hook::{HookCheckResult, check_command_for_hook_with_rules};
use crate::rules::{ActionKind, CommandInvocation, RuleConfig, match_rule};
use crate::unwrap::{BlockReason, ParseResult, parse_command_string};
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
) && r.command != "omamori"
})
.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(),
);
}
#[test]
fn obfuscated_expansion_monotonicity(idx in 0..KNOWN_ALLOW_COMMANDS.len()) {
let cmd = KNOWN_ALLOW_COMMANDS[idx];
let result = parse_command_string(cmd);
prop_assert!(
!matches!(result, ParseResult::Block(BlockReason::ObfuscatedExpansion)),
"FP regression: known-Allow command blocked as ObfuscatedExpansion:\n cmd: {cmd}",
);
}
#[test]
fn obfuscated_expansion_completeness(case in arb_expansion_case()) {
let result = parse_command_string(&case);
prop_assert!(
matches!(result, ParseResult::Block(BlockReason::ObfuscatedExpansion)),
"Expansion at verb position was not blocked:\n cmd: {case}",
);
}
}
const KNOWN_ALLOW_COMMANDS: &[&str] = &[
"$HOME/bin/cargo build",
"$EDITOR file.txt",
"ls -la",
"git status",
"cargo test",
"make -C build",
"RUST_LOG=debug cargo test",
"npm run build",
"python3 script.py",
"grep -rn pattern src/",
"cat /etc/hosts",
"echo hello world",
"docker run -it ubuntu",
"kubectl get pods",
"terraform plan",
"ssh user@host",
"scp file.txt user@host:/tmp/",
"curl -sL https://example.com",
"wget https://example.com/file",
"tar -xzf archive.tar.gz",
"unzip archive.zip",
"command -v rm",
"sudo rm -rf /tmp/test",
"env RUST_LOG=debug cargo test 2>&1 | grep FAIL",
];
fn arb_expansion_case() -> impl Strategy<Value = String> {
let expansion_verb = prop_oneof![
Just("$'rm' -rf /tmp/x"),
Just("$\"rm\" -rf /tmp/x"),
Just("${IFS}rm -rf /tmp/x"),
Just("{rm,-rf,/tmp/x}"),
Just("r$'m' -rf /tmp/x"),
Just("r$\"m\" -rf /tmp/x"),
Just("r${m} -rf /tmp/x"),
];
let wrapper_prefix = prop_oneof![
Just(""),
Just("sudo "),
Just("env "),
Just("timeout 5 "),
Just("nice "),
Just("command "),
Just("doas "),
];
(wrapper_prefix, expansion_verb).prop_map(|(prefix, verb)| format!("{prefix}{verb}"))
}
const OMAMORI_SUBSHELL_PATTERNS: &[&str] = &[
"omamori uninstall",
"omamori init --force",
"omamori override",
"omamori doctor --fix",
"omamori explain rm-recursive",
"omamori config disable foo",
"omamori config enable bar",
];
fn arb_omamori_subshell_pattern() -> impl Strategy<Value = &'static str> {
prop::sample::select(OMAMORI_SUBSHELL_PATTERNS)
}
proptest! {
#[test]
fn prop_subshell_inner_verb_blocked(
shell in prop::sample::select(&["bash", "sh", "zsh"][..]),
pattern in arb_omamori_subshell_pattern(),
) {
let cmd = format!("{shell} -c '{pattern}'");
let config = Config::default();
let blocked = layer2_blocks(&cmd, &config.rules);
prop_assert!(
blocked,
"omamori subcommand inside subshell must be BLOCKED: {cmd}"
);
}
}