use super::*;
struct Example {
command: &'static str,
arm: (&'static str, &'static str),
gen1: Decision,
gen2: Decision,
gen3: Decision,
}
const A_CRITICAL: (&str, &str) = ("_9 critical path", "162771910");
const A_UNRESOLVABLE: (&str, &str) = ("_9 unresolvable target", "162771609");
const A_CD_GLOB: (&str, &str) = ("_9 cd + relative glob", "162770807");
const A_NEEDS_FS: (&str, &str) = ("_9 workspace/ancestor and glob arms", "162772295");
const A_LEXICAL: (&str, &str) = ("hnt / out target test", "162774254");
const A_CENSUS_BAIL: (&str, &str) = ("Bno >64 bail", "162863359");
const A_NONE: (&str, &str) = ("no removal reaches a checker", "162773256");
const EXAMPLES: &[Example] = &[
Example {
command: "rm file.txt",
arm: A_NEEDS_FS,
gen1: Decision::Unresolved,
gen2: Decision::Unresolved,
gen3: Decision::Unresolved,
},
Example {
command: "rm -rf /tmp/build",
arm: A_NEEDS_FS,
gen1: Decision::Unresolved,
gen2: Decision::Unresolved,
gen3: Decision::Unresolved,
},
Example {
command: "rm -rf /tmp",
arm: A_CRITICAL,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "rm -rf $TMP/*",
arm: A_UNRESOLVABLE,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "rm $TMP/build",
arm: A_NEEDS_FS,
gen1: Decision::Unresolved,
gen2: Decision::Unresolved,
gen3: Decision::Unresolved,
},
Example {
command: "rm $D/",
arm: A_NEEDS_FS,
gen1: Decision::Unresolved,
gen2: Decision::Unresolved,
gen3: Decision::Unresolved,
},
Example {
command: "rm -rf \"$DIR\"/*",
arm: A_UNRESOLVABLE,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "(rm -rf $TMP/*)",
arm: A_LEXICAL,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "for f in a b; do rm -rf $TMP/$f; done",
arm: A_LEXICAL,
gen1: Decision::Passthrough,
gen2: Decision::Passthrough,
gen3: Decision::Ask,
},
Example {
command: "for f in a; do [ -f \"$S/$f\" ] && rm -f \"$S/$f\"; done",
arm: A_LEXICAL,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "rm $TMP/literal",
arm: A_NEEDS_FS,
gen1: Decision::Unresolved,
gen2: Decision::Unresolved,
gen3: Decision::Unresolved,
},
Example {
command: "rm -r $X > $TMP/",
arm: A_NEEDS_FS,
gen1: Decision::Unresolved,
gen2: Decision::Unresolved,
gen3: Decision::Unresolved,
},
Example {
command: "rm -f 2>/dev/null $TMP/*",
arm: A_UNRESOLVABLE,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "echo $(rm $TMP/*)",
arm: A_UNRESOLVABLE,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "echo $(echo $(rm -rf $TMP/*))",
arm: A_UNRESOLVABLE,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "echo $(echo $(echo $(rm -rf $TMP/*)))",
arm: A_UNRESOLVABLE,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "X=1 rm -rf $D/*",
arm: A_UNRESOLVABLE,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
Example {
command: "ls | xargs rm -rf \"$DIR\"/*",
arm: A_NONE,
gen1: Decision::Passthrough,
gen2: Decision::Passthrough,
gen3: Decision::Passthrough,
},
Example {
command: "find . -exec rm -rf $TMP/{} \\;",
arm: A_NONE,
gen1: Decision::Passthrough,
gen2: Decision::Passthrough,
gen3: Decision::Passthrough,
},
Example {
command: "rm ${DIR}/$x",
arm: A_NEEDS_FS,
gen1: Decision::Unresolved,
gen2: Decision::Unresolved,
gen3: Decision::Unresolved,
},
Example {
command: "cd /tmp && rm -rf build/*",
arm: A_CD_GLOB,
gen1: Decision::Ask,
gen2: Decision::Ask,
gen3: Decision::Ask,
},
];
fn versions() -> [(&'static str, Generation); 3] {
[
("2.1.193", Generation::Gen1),
("2.1.258", Generation::Gen2),
("2.1.268", Generation::Gen3),
]
}
#[test]
fn the_traced_examples_decide_the_same_way_per_generation() {
for ex in EXAMPLES {
for (version, gen) in versions() {
let want = match gen {
Generation::Gen1 => ex.gen1,
Generation::Gen2 => ex.gen2,
Generation::Gen3 => ex.gen3,
};
let got = classify(ex.command, Some(version));
assert_eq!(
got.decision,
want,
"{:?} at {version} ({}): arm {} @{} - {}",
ex.command,
gen.label(),
ex.arm.0,
ex.arm.1,
got.disclosure()
);
assert_eq!(
got.generation,
gen,
"{:?} must run under {}",
ex.command,
gen.label()
);
}
}
}
fn with_substitutions(n: usize, body: &str) -> String {
let subs = "$(true)".repeat(n);
format!("if true; then {body}; fi {subs}")
}
#[test]
fn the_census_bails_above_sixty_four_substitutions_with_an_rm_word() {
let cmd = with_substitutions(65, "rm x");
assert_eq!(
classify(&cmd, Some("2.1.193")).decision,
Decision::Passthrough,
"the census did not exist before 2.1.208"
);
let v = classify(&cmd, Some("2.1.258"));
assert_eq!(
v.decision,
Decision::Ask,
"arm {} @{} - {}",
A_CENSUS_BAIL.0,
A_CENSUS_BAIL.1,
v.disclosure()
);
assert_eq!(v.checker, Checker::Census);
assert!(
v.reason
.contains("too many command substitutions to analyze"),
"reason: {}",
v.reason
);
assert!(v.reason.contains("(65)"), "reason: {}", v.reason);
}
#[test]
fn the_census_does_not_bail_at_exactly_sixty_four() {
let cmd = with_substitutions(64, "rm x");
let v = classify(&cmd, Some("2.1.258"));
assert_ne!(
v.decision,
Decision::Ask,
"64 is not more than 64: {}",
v.disclosure()
);
assert!(!v.reason.contains("too many"), "reason: {}", v.reason);
}
#[test]
fn the_census_without_an_rm_word_asks_nothing() {
let cmd = with_substitutions(65, "echo x");
let v = classify(&cmd, Some("2.1.258"));
assert_eq!(v.decision, Decision::Passthrough, "{}", v.disclosure());
}
#[test]
fn the_generation_is_read_off_the_version_and_assumed_when_absent() {
assert_eq!(generation_of(Some("2.1.207")), (Generation::Gen1, false));
assert_eq!(generation_of(Some("2.1.208")), (Generation::Gen2, false));
assert_eq!(generation_of(Some("2.1.260")), (Generation::Gen2, false));
assert_eq!(generation_of(Some("2.1.261")), (Generation::Gen3, false));
assert_eq!(generation_of(Some("2.2.0")), (Generation::Gen3, false));
assert_eq!(generation_of(Some("1.0.60")), (Generation::Gen1, false));
assert_eq!(generation_of(None), (Generation::Gen2, true));
assert_eq!(generation_of(Some("nightly")), (Generation::Gen2, true));
assert!(classify("rm -rf $TMP/*", None).generation_assumed);
}
#[test]
fn the_branch_disclosure_names_the_shape_that_forced_the_lexical_path() {
let v = classify("for f in a; do rm -rf $TMP/$f; done", Some("2.1.258"));
assert_eq!(v.branch, Branch::Lexical("for_statement"), "{v:?}");
assert!(
v.disclosure()
.contains("lexical (too-complex: for_statement)"),
"{}",
v.disclosure()
);
let s = classify("rm -rf /tmp", Some("2.1.258"));
assert_eq!(s.branch, Branch::Structured);
assert!(
s.disclosure().contains("path: structured"),
"{}",
s.disclosure()
);
}
#[test]
fn the_reason_tails_are_the_harness_strings() {
let critical = classify("rm -rf /tmp", Some("2.1.258"));
assert_eq!(critical.reason, "on critical path: /tmp");
let lexical = classify("(rm -rf $TMP/*)", Some("2.1.258"));
assert_eq!(
lexical.reason,
"on possibly-empty variable path: $TMP/*",
"{}",
lexical.disclosure()
);
let unresolved = classify("rm file.txt", Some("2.1.258"));
assert_eq!(unresolved.reason, NOTE_NEEDS_FS);
assert!(classify("echo hello", Some("2.1.258")).reason.is_empty());
}
#[test]
fn every_ask_is_bypass_immune_and_nothing_else_is() {
for ex in EXAMPLES {
for (version, _) in versions() {
let v = classify(ex.command, Some(version));
assert_eq!(
v.immune,
v.decision == Decision::Ask,
"{:?} at {version}",
ex.command
);
assert_eq!(v.blocks(), v.decision == Decision::Ask);
}
}
}
#[test]
fn the_generation_three_token_walk_reaches_a_prefixed_removal() {
let cmd = "if true; then sudo rm -rf $D/*; fi";
assert_eq!(
classify(cmd, Some("2.1.258")).decision,
Decision::Passthrough,
"the generation 2 clause head cannot match `then sudo rm`"
);
let v = classify(cmd, Some("2.1.268"));
assert_eq!(v.decision, Decision::Ask, "{}", v.disclosure());
assert_eq!(v.checker, Checker::Lexical);
}
#[test]
fn a_keyword_attached_removal_is_reachable_only_from_generation_three() {
for cmd in [
"for f in a; do rm -rf $TMP/$f; done",
"if true; then rm $TMP/*; fi",
] {
for version in ["2.1.193", "2.1.258"] {
let v = classify(cmd, Some(version));
assert_eq!(
v.decision,
Decision::Passthrough,
"{cmd} at {version}: {}",
v.disclosure()
);
assert!(!v.blocks(), "{cmd} at {version}");
}
assert_eq!(
classify(cmd, Some("2.1.268")).decision,
Decision::Ask,
"{cmd}"
);
}
}
#[test]
fn the_generation_three_find_scan_reaches_an_exec_removal() {
let cmd = "if true; then find . -exec rm -rf $TMP/* \\; ; fi";
assert_eq!(
classify(cmd, Some("2.1.258")).decision,
Decision::Passthrough
);
assert_eq!(classify(cmd, Some("2.1.268")).decision, Decision::Ask);
}
#[test]
fn the_generation_three_rm_guard_is_case_insensitive() {
let cmd = "if true; then RM -rf $D/*; fi";
assert_eq!(
classify(cmd, Some("2.1.258")).decision,
Decision::Passthrough
);
assert_eq!(classify(cmd, Some("2.1.268")).decision, Decision::Ask);
}
#[test]
fn a_command_with_no_removal_word_never_reaches_a_checker_arm() {
for cmd in [
"echo $HOME/x",
"git status",
"ls -la /tmp",
"cargo test --quiet",
] {
let v = classify(cmd, Some("2.1.258"));
assert_eq!(
v.decision,
Decision::Passthrough,
"{cmd}: {}",
v.disclosure()
);
assert!(!v.blocks(), "{cmd}");
}
}
#[test]
fn the_over_length_command_takes_the_parse_abort_branch() {
let cmd = format!("rm -rf $TMP/* {}", "x".repeat(11_000));
let v = classify(&cmd, Some("2.1.258"));
assert_eq!(v.branch, Branch::Lexical("PARSE_ABORT"));
assert_eq!(v.decision, Decision::Ask);
}
#[test]
fn the_lone_ampersand_rule_holds_at_both_string_edges() {
use crate::bash_danger_lexical::amp_to_semicolon;
assert_eq!(amp_to_semicolon("a & b"), "a ; b");
assert_eq!(amp_to_semicolon("a&"), "a;");
assert_eq!(amp_to_semicolon("&b"), ";b");
assert_eq!(amp_to_semicolon("&"), ";");
assert_eq!(amp_to_semicolon("a&&b"), "a&&b");
assert_eq!(amp_to_semicolon("x 2>&1"), "x 2>&1");
assert_eq!(amp_to_semicolon("<&0"), "<&0");
assert_eq!(amp_to_semicolon("a&>x"), "a&>x");
assert_eq!(amp_to_semicolon("a&<x"), "a&<x");
}
#[test]
fn a_group_at_the_string_start_is_a_plain_group() {
use crate::bash_danger_lexical::{remove_plain_groups, strip_paren_groups};
assert_eq!(remove_plain_groups("(a b) x"), " x");
assert_eq!(remove_plain_groups("a$(b) (c) d"), "a$(b) d");
assert_eq!(strip_paren_groups("a$(b) (c) d"), "a d");
assert_eq!(strip_paren_groups("x $(a $(b) c) y"), "x y");
}
#[test]
fn the_operand_walk_skips_a_redirect_and_its_separated_target() {
use crate::bash_danger_lexical::{hnt, scan_operands};
assert!(scan_operands(&[">", "$TMP/", "-f"]).is_none());
assert!(hnt("rm -r $X > $TMP/").is_none());
assert!(hnt("rm -r $X 2>$TMP/").is_none());
assert_eq!(
hnt("rm -f 2>/dev/null $TMP/*").map(|h| h.target),
Some("$TMP/*".to_string())
);
assert_eq!(
scan_operands(&["", "-rf", "'lit'", "$D/*"]),
Some("$D/*".to_string())
);
assert_eq!(scan_operands(&["first", "$D/*"]), Some("$D/*".to_string()));
}
#[test]
fn the_lexical_guard_needs_both_a_dollar_and_a_removal_word() {
use crate::bash_danger_lexical::hnt;
assert!(hnt("rm -rf /tmp/build").is_none(), "no dollar");
assert!(hnt("echo $HOME/x").is_none(), "no removal word");
assert!(hnt("rm -rf $T/*").is_some(), "both present");
assert_eq!(hnt("rmdir $D/").map(|h| h.command), Some("rmdir"));
assert_eq!(hnt("rm $D/").map(|h| h.command), Some("rm"));
}
#[test]
fn the_generation_label_names_each_generation() {
assert_eq!(Generation::Gen1.label(), "gen1");
assert_eq!(Generation::Gen2.label(), "gen2");
assert_eq!(Generation::Gen3.label(), "gen3");
assert_eq!(Checker::Lexical.label(), "lexical");
assert_eq!(Checker::Structured.label(), "structured");
assert_eq!(Checker::Census.label(), "census");
}
#[test]
fn each_census_verdict_reaches_the_disclosure_as_the_census() {
let inner = classify("if true; then echo $(rm -rf $D/*); fi", Some("2.1.258"));
assert_eq!(inner.decision, Decision::Ask, "{}", inner.disclosure());
assert_eq!(inner.checker, Checker::Census);
assert_eq!(
inner.reason,
"on possibly-empty variable path inside command substitution: $D/*"
);
let structured = classify("[[ -f x ]] && rm -rf /tmp", Some("2.1.258"));
assert_eq!(structured.decision, Decision::Ask);
assert_eq!(structured.checker, Checker::Census);
assert_eq!(structured.reason, "on critical path: /tmp");
let needs_fs = classify("[[ -f x ]] && rm -rf /tmp/build", Some("2.1.258"));
assert_eq!(needs_fs.decision, Decision::Unresolved);
assert_eq!(needs_fs.checker, Checker::Census);
assert_eq!(needs_fs.reason, NOTE_NEEDS_FS);
assert!(!needs_fs.blocks());
}
#[test]
fn an_operand_the_decomposer_resolves_from_the_environment_is_unresolved() {
let v = classify("rm -rf $HOME/*", Some("2.1.258"));
assert_eq!(v.decision, Decision::Unresolved, "{}", v.disclosure());
assert_eq!(v.checker, Checker::Structured);
assert_eq!(v.branch, Branch::Structured);
assert_eq!(v.reason, NOTE_NEEDS_FS);
}