#![allow(clippy::unwrap_used)]
use super::*;
fn taint_of(source: &str, line_idx: usize, var: &str) -> TaintKind {
analyze(source).var_taint(line_idx, var)
}
#[test]
fn test_GH227_intrinsic_positional_is_external() {
assert_eq!(intrinsic_taint("1"), TaintKind::External);
assert_eq!(intrinsic_taint("12"), TaintKind::External);
assert_eq!(intrinsic_taint("@"), TaintKind::External);
assert_eq!(intrinsic_taint("*"), TaintKind::External);
}
#[test]
fn test_GH227_intrinsic_argv_zero_is_clean() {
assert_eq!(intrinsic_taint("0"), TaintKind::Clean);
}
#[test]
fn test_GH227_intrinsic_shell_meta_is_clean() {
assert_eq!(intrinsic_taint("#"), TaintKind::Clean);
assert_eq!(intrinsic_taint("?"), TaintKind::Clean);
assert_eq!(intrinsic_taint("$"), TaintKind::Clean);
}
#[test]
fn test_GH227_intrinsic_getopts_sinks_are_external() {
assert_eq!(intrinsic_taint("OPTARG"), TaintKind::External);
assert_eq!(intrinsic_taint("REPLY"), TaintKind::External);
}
#[test]
fn test_GH227_intrinsic_indirect_expansion_is_external() {
assert_eq!(intrinsic_taint("!x"), TaintKind::External);
}
#[test]
fn test_GH227_intrinsic_safe_env_vars_are_clean() {
assert_eq!(intrinsic_taint("HOME"), TaintKind::Clean);
assert_eq!(intrinsic_taint("PWD"), TaintKind::Clean);
assert_eq!(intrinsic_taint("XDG_CACHE_HOME"), TaintKind::Clean);
}
#[test]
fn test_GH227_intrinsic_unknown_is_ambient() {
assert_eq!(intrinsic_taint("SOME_UNSEEN_VAR"), TaintKind::Ambient);
}
#[test]
fn test_GH227_taint_kind_is_ordered() {
assert!(TaintKind::Clean < TaintKind::Ambient);
assert!(TaintKind::Ambient < TaintKind::External);
}
#[test]
fn test_GH227_var_names_plain_and_braced() {
assert_eq!(var_names("$FOO ${BAR}"), vec!["FOO", "BAR"]);
}
#[test]
fn test_GH227_var_names_skips_command_substitution_marker() {
assert_eq!(var_names(r#"x=$(basename "$1")"#), vec!["1"]);
}
#[test]
fn test_GH227_var_names_strips_array_index_and_modifiers() {
assert_eq!(var_names("${ARGS[0]}"), vec!["ARGS"]);
assert_eq!(var_names("${VAR:-fallback}"), vec!["VAR"]);
assert_eq!(var_names("${#LIST}"), vec!["LIST"]);
}
#[test]
fn test_GH227_var_names_marks_indirect_expansion() {
assert_eq!(var_names("${!ref}"), vec!["!ref"]);
}
#[test]
fn test_GH227_var_names_handles_arithmetic_and_dangling_dollar() {
assert_eq!(var_names("$(( 1 + 2 ))"), Vec::<String>::new());
assert_eq!(var_names("costs 5$"), Vec::<String>::new());
assert_eq!(var_names("${unterminated"), Vec::<String>::new());
}
#[test]
fn test_GH227_split_assignment_accepts_modifiers() {
assert_eq!(
split_assignment("export X=1").map(|(n, _)| n),
Some("X".to_string())
);
assert_eq!(
split_assignment(" local X=1").map(|(n, _)| n),
Some("X".to_string())
);
assert_eq!(
split_assignment("readonly X=1").map(|(n, _)| n),
Some("X".to_string())
);
assert_eq!(
split_assignment(r#" d) OUT_DIR="$OPTARG" ;;"#).map(|(n, _)| n),
Some("OUT_DIR".to_string())
);
}
#[test]
fn test_GH227_split_assignment_rejects_non_assignments() {
assert!(split_assignment("X[0]=1").is_none());
assert!(split_assignment(r#"if [ "$a" == "b" ]; then"#).is_none());
assert!(split_assignment(r#"if [ "$a" = "b" ]; then"#).is_none());
assert!(split_assignment("X+=1").is_none());
assert!(split_assignment("./configure --prefix=/usr").is_none());
assert!(split_assignment("awk -F= '{print $1}'").is_none());
assert!(split_assignment("mkdir -p out").is_none());
}
#[test]
fn test_GH227_literal_assignment_is_clean() {
let src = "OUT_DIR=\"build/results\"\nmkdir -p \"$OUT_DIR\"\n";
assert_eq!(taint_of(src, 1, "OUT_DIR"), TaintKind::Clean);
}
#[test]
fn test_GH227_transitive_literal_assignment_is_clean() {
let src = "BASE=\"/srv\"\nSUB=\"$BASE/data\"\nmkdir -p \"$SUB\"\n";
assert_eq!(taint_of(src, 2, "SUB"), TaintKind::Clean);
}
#[test]
fn test_GH227_positional_assignment_is_external() {
let src = "P=\"$1\"\nmkdir -p \"$P\"\n";
assert_eq!(taint_of(src, 1, "P"), TaintKind::External);
}
#[test]
fn test_GH227_assignment_is_not_visible_before_its_line() {
let src = "mkdir -p \"$P\"\nP=\"/lit\"\n";
assert_eq!(taint_of(src, 0, "P"), TaintKind::Ambient);
}
#[test]
fn test_GH227_is_sanitizer_rhs() {
assert!(is_sanitizer_rhs("$(realpath -m \"$1\")"));
assert!(is_sanitizer_rhs("$(readlink -f \"$1\")"));
assert!(!is_sanitizer_rhs("$(basename \"$1\")"));
}
#[test]
fn test_GH227_cmd_sub_taint() {
assert_eq!(cmd_sub_taint("$(curl -s http://x)"), TaintKind::External);
assert_eq!(cmd_sub_taint("$(echo hi)"), TaintKind::Clean);
assert_eq!(cmd_sub_taint("`basename \"$1\"`"), TaintKind::Clean);
assert_eq!(cmd_sub_taint("plain text"), TaintKind::Clean);
assert_eq!(cmd_sub_taint("$(curler -s http://x)"), TaintKind::Clean);
}
#[test]
fn test_GH227_read_marks_variables_external() {
let src = "read -r name\nmkdir -p \"$name\"\n";
assert_eq!(taint_of(src, 1, "name"), TaintKind::External);
}
#[test]
fn test_GH227_read_skips_option_arguments() {
let src = "read -r -p \"prompt: \" answer\necho \"$answer\"\n";
assert_eq!(taint_of(src, 1, "answer"), TaintKind::External);
}
#[test]
fn test_GH227_for_loop_over_argv_is_external() {
let src = "for f in \"$@\"; do\n cat \"$f\"\ndone\n";
assert_eq!(taint_of(src, 1, "f"), TaintKind::External);
}
#[test]
fn test_GH227_for_loop_over_glob_is_clean() {
let src = "for f in *.txt; do\n cat \"$f\"\ndone\n";
assert_eq!(taint_of(src, 1, "f"), TaintKind::Clean);
}
#[test]
fn test_GH227_line_tests_traversal() {
assert!(line_tests_traversal(r#"if [[ "$V" == *".."* ]]; then"#));
assert!(line_tests_traversal(r#"if [[ "$V" == /* ]]; then"#));
assert!(line_tests_traversal(r#"grep -qE '(^|/)\.\.(/|$)'"#));
assert!(!line_tests_traversal("echo hello"));
assert!(!line_tests_traversal("rm -rf ../old"));
}
#[test]
fn test_GH227_line_hard_fails() {
assert!(line_hard_fails("exit 1"));
assert!(line_hard_fails(" return 1"));
assert!(line_hard_fails("die \"nope\""));
assert!(!line_hard_fails("echo bad >&2"));
}
#[test]
fn test_GH227_hard_fail_must_be_a_command_not_a_word() {
assert!(!line_hard_fails(
r#"echo "WARNING: $P contains '..' (set STRICT=1 to exit on this)" >&2"#
));
assert!(!line_hard_fails(" # TODO: should we exit here?"));
assert!(!line_hard_fails(r#"printf 'abort the run?\n'"#));
assert!(!line_hard_fails(r#"trap 'exit 1' TERM"#));
assert!(!line_hard_fails("echo continue"));
assert!(line_hard_fails(" [ -n \"$1\" ] || exit 1"));
assert!(line_hard_fails("{ echo bad >&2; exit 3; }"));
assert!(line_hard_fails("then exit 1"));
}
#[test]
fn test_GH227_exit_zero_is_not_a_rejection() {
assert!(!line_hard_fails("exit 0"));
assert!(!line_hard_fails(" return 0"));
assert!(!line_hard_fails(" return"));
assert!(line_hard_fails("exit"));
assert!(line_hard_fails("exit 2"));
}
#[test]
fn test_GH227_warn_only_block_does_not_untaint() {
let src = concat!(
"P=\"$1\"\n",
"if [[ \"$P\" == *..* ]]; then\n",
" echo \"WARNING: $P has '..' (set STRICT=1 to exit on this)\" >&2\n",
"fi\n",
"cat \"/data/$P\"\n",
);
assert_eq!(taint_of(src, 4, "P"), TaintKind::External);
}
#[test]
fn test_GH227_case_arms_must_pair_pattern_and_abort() {
let src = concat!(
"CMD=\"$1\"\n",
"case \"$CMD\" in\n",
" install) shift ;;\n",
" */*) echo \"path form\" ;;\n",
" *) echo \"unknown\"; exit 1 ;;\n",
"esac\n",
"cat \"/data/$CMD\"\n",
);
assert_eq!(taint_of(src, 6, "CMD"), TaintKind::External);
let guarded = concat!(
"CMD=\"$1\"\n",
"case \"$CMD\" in\n",
" */*) exit 1 ;;\n",
" *) shift ;;\n",
"esac\n",
"cat \"/data/$CMD\"\n",
);
assert_eq!(taint_of(guarded, 5, "CMD"), TaintKind::Clean);
}
#[test]
fn test_GH227_inline_case_arms_must_pair_pattern_and_abort() {
let src = "case \"$1\" in */*) echo path ;; *) exit 1 ;; esac\nD=\"in/$1\"\n";
assert_eq!(taint_of(src, 1, "1"), TaintKind::External);
}
#[test]
fn test_GH227_else_branch_abort_is_not_a_traversal_guard() {
let src = concat!(
"P=\"$1\"\n",
"if [[ \"$P\" == *..* ]]; then\n",
" echo warn >&2\n",
"else\n",
" exit 1\n",
"fi\n",
"cat \"/data/$P\"\n",
);
assert_eq!(taint_of(src, 6, "P"), TaintKind::External);
}
#[test]
fn test_GH227_validator_must_pair_pattern_and_abort() {
assert!(!body_is_path_validator(&[
"validate_path() {",
" if [[ \"$1\" == *..* ]]; then",
" echo \"warning: suspicious path\" >&2",
" fi",
" if [ -z \"$1\" ]; then",
" exit 1",
" fi",
"}",
]));
assert!(body_is_path_validator(&[
"validate_path() {",
" if [[ \"$1\" == *..* ]]; then",
" exit 1",
" fi",
"}",
]));
}
#[test]
fn test_GH227_branch_assignment_merges_taint() {
let src = concat!(
"if [ -n \"$1\" ]; then\n",
" P=\"$1\"\n",
"else\n",
" P=/default\n",
"fi\n",
"cat \"/data/$P\"\n",
);
assert_eq!(taint_of(src, 5, "P"), TaintKind::External);
}
#[test]
fn test_GH227_branch_assignment_order_does_not_matter() {
let swapped = concat!(
"if [ -z \"$1\" ]; then\n",
" P=/default\n",
"else\n",
" P=\"$1\"\n",
"fi\n",
"cat \"/data/$P\"\n",
);
assert_eq!(taint_of(swapped, 5, "P"), TaintKind::External);
}
#[test]
fn test_GH227_first_conditional_assignment_is_judged_on_its_own_value() {
let src = concat!(
"if [ -z \"$OUT\" ]; then\n",
" OUT_DIR=\"build/results\"\n",
"fi\n",
"mkdir -p \"$OUT_DIR\"\n",
);
assert_eq!(taint_of(src, 3, "OUT_DIR"), TaintKind::Clean);
}
#[test]
fn test_GH227_merge_does_not_cross_constructs() {
let src = concat!(
"helper() {\n",
" if [ -n \"$V\" ]; then\n",
" ARGS=\"$V\"\n",
" fi\n",
"}\n",
"for spec in a b; do\n",
" ARGS=\"\"\n",
" cat \"/data/$ARGS\"\n",
"done\n",
);
assert_eq!(taint_of(src, 7, "ARGS"), TaintKind::Clean);
}
#[test]
fn test_GH227_guarded_assignment_merges_taint() {
let src = "P=\"$1\"\n[ -n \"$P\" ] || P=/default\ncat \"/data/$P\"\n";
assert_eq!(taint_of(src, 2, "P"), TaintKind::External);
}
#[test]
fn test_GH227_guard_untaints_reports_the_closing_line() {
let lines = ["case \"$1\" in", " *..*) exit 1 ;;", "esac", "mkdir -p x"];
let untaints = guard_untaints(&lines, &HashSet::new());
assert_eq!(
untaints.get(&2).map(Vec::as_slice),
Some(&["1".to_string()][..])
);
assert!(!untaints.contains_key(&0));
}
#[test]
fn test_GH227_inline_case_guard_untaints_positional() {
let src = "case \"$1\" in *..*|/*) exit 2 ;; esac\nD=\"in/$1\"\n";
assert_eq!(taint_of(src, 1, "1"), TaintKind::Clean);
}
#[test]
fn test_GH227_guard_without_hard_failure_does_not_untaint() {
let src = "case \"$1\" in *..*) echo bad >&2 ;; esac\nD=\"in/$1\"\n";
assert_eq!(taint_of(src, 1, "1"), TaintKind::External);
}
#[test]
fn test_GH227_positional_inside_function_is_ambient_not_external() {
let src = "f() {\n local p=\"$1\"\n mkdir -p \"$p\"\n}\n";
assert_eq!(taint_of(src, 2, "p"), TaintKind::Ambient);
assert_eq!(
analyze(src).line_taint(2, "mkdir -p \"$1\""),
TaintKind::Ambient
);
}
#[test]
fn test_GH227_positional_at_top_level_stays_external() {
let src = "p=\"$1\"\nmkdir -p \"$p\"\n";
assert_eq!(taint_of(src, 1, "p"), TaintKind::External);
}
#[test]
fn test_GH227_guard_inside_function_body_is_ignored() {
let src = "check() {\n case \"$1\" in *..*) return 1 ;; esac\n}\nD=\"in/$1\"\n";
assert_eq!(taint_of(src, 3, "1"), TaintKind::External);
}
#[test]
fn test_GH227_body_is_path_validator() {
assert!(!body_is_path_validator(&["validate_path() {", " :", "}"]));
assert!(!body_is_path_validator(&[
"check_path() {",
" echo \"$1\"",
"}"
]));
assert!(body_is_path_validator(&[
"validate_path() {",
" case \"$1\" in *..*) return 1 ;; esac",
"}"
]));
assert!(body_is_path_validator(&[
"validate_path() {",
" if [[ \"$1\" == *\"..\"* ]]; then",
" exit 1",
" fi",
"}"
]));
}
#[test]
fn test_GH227_collect_validator_functions_requires_body_evidence() {
let noop = ["validate_path() {", " :", "}"];
assert!(collect_validator_functions(&noop).is_empty());
let real = [
"validate_path() {",
" case \"$1\" in *..*) return 1 ;; esac",
"}",
];
assert!(collect_validator_functions(&real).contains("validate_path"));
}
#[test]
fn test_GH227_validator_call_var_extracts_argument() {
let mut validators = HashSet::new();
validators.insert("validate_path".to_string());
assert_eq!(
validator_call_var(r#"validate_path "$RAID_PATH""#, &validators),
Some("RAID_PATH".to_string())
);
assert_eq!(
validator_call_var(r#"validate_path "${RAID_PATH}" || exit 1"#, &validators),
Some("RAID_PATH".to_string())
);
assert_eq!(validator_call_var("validate_path() {", &validators), None);
assert_eq!(validator_call_var(r#"check_path "$X""#, &validators), None);
}
#[test]
fn test_GH227_function_header_recognition() {
assert_eq!(function_header("foo() {"), Some("foo".to_string()));
assert_eq!(function_header("function foo() {"), Some("foo".to_string()));
assert_eq!(function_header("arr=()"), None);
assert_eq!(function_header("mkdir -p x"), None);
}
#[test]
fn test_GH227_function_body_lines_covers_one_liner_and_block() {
let lines = ["f() { :; }", "g() {", " echo hi", "}", "echo out"];
let bodies = function_body_lines(&lines);
assert!(bodies.contains(&0));
assert!(bodies.contains(&1) && bodies.contains(&2) && bodies.contains(&3));
assert!(!bodies.contains(&4));
}
#[test]
fn test_GH227_line_taint_takes_the_maximum() {
let src = "A=\"lit\"\nB=\"$1\"\ncp \"$A\" \"$B\"\n";
let map = analyze(src);
assert_eq!(map.line_taint(2, r#"cp "$A" "$B""#), TaintKind::External);
assert_eq!(map.line_taint(2, r#"cp "$A" /dest/"#), TaintKind::Clean);
}
#[test]
fn test_GH227_path_taint_only_looks_at_path_components() {
let src = "X=\"a\"\ncat \"/data/$X\"\n";
let map = analyze(src);
assert_eq!(map.path_taint(1, r#" "/data/$X""#), TaintKind::Clean);
assert_eq!(map.path_taint(1, r#" "/data/$1""#), TaintKind::External);
}
#[test]
fn test_GH227_analyze_handles_empty_source() {
let map = analyze("");
assert_eq!(map.var_taint(0, "X"), TaintKind::Ambient);
}
#[test]
fn test_GH227_quoted_heredoc_body_is_not_analysed() {
let src = "P=\"$1\"\ncat <<'EOF'\nP=\"/literal\"\nEOF\nmkdir -p \"$P\"\n";
assert_eq!(taint_of(src, 4, "P"), TaintKind::External);
}
#[test]
fn test_GH227_comments_are_not_analysed() {
let src = "P=\"$1\"\n# P=\"/literal\"\nmkdir -p \"$P\"\n";
assert_eq!(taint_of(src, 2, "P"), TaintKind::External);
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
#[test]
fn prop_GH227_analyze_is_total(s in ".*") {
let map = analyze(&s);
let _ = map.line_taint(0, &s);
let _ = map.path_taint(0, &s);
}
#[test]
fn prop_GH227_literal_only_assignment_is_clean(
name in "[A-Z][A-Z_]{0,8}",
value in "[a-z/][a-z/]{0,20}",
) {
let src = format!("{name}=\"{value}\"\nmkdir -p \"${name}\"\n");
prop_assert_eq!(analyze(&src).var_taint(1, &name), TaintKind::Clean);
}
#[test]
fn prop_GH227_positional_assignment_is_external(name in "[A-Z][A-Z_]{0,8}") {
let src = format!("{name}=\"$1\"\nmkdir -p \"${name}\"\n");
prop_assert_eq!(analyze(&src).var_taint(1, &name), TaintKind::External);
}
}
}