use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
if !has_rm_force_recursive(trimmed) {
continue;
}
if has_unguarded_variable(trimmed) {
let span = Span::new(line_num + 1, 1, line_num + 1, line.len());
let var_name = extract_first_variable(trimmed).unwrap_or("VAR");
let mut diag = Diagnostic::new(
"BASH004",
Severity::Warning,
format!(
"Dangerous rm -rf with unguarded variable ${} - use ${{{}:?}} to fail if unset/empty",
var_name, var_name
),
span,
);
diag.fix = Some(Fix::new(format!("${{{}:?\"Variable not set\"}}", var_name)));
result.add(diag);
}
}
result
}
fn is_rf_flag(word: &str) -> bool {
word.starts_with('-') && word.contains('r') && word.contains('f')
}
fn has_rm_force_recursive(line: &str) -> bool {
if !line.contains("rm ") {
return false;
}
let words: Vec<&str> = line.split_whitespace().collect();
let rm_pos = words.iter().position(|w| *w == "rm");
let Some(pos) = rm_pos else { return false };
words[pos + 1..]
.iter()
.take_while(|w| w.starts_with('-'))
.any(|w| is_rf_flag(w))
}
fn has_unguarded_variable(line: &str) -> bool {
let parts: Vec<&str> = line.splitn(2, "rm").collect();
if parts.len() < 2 {
return false;
}
let after_rm = parts[1];
let words: Vec<&str> = after_rm.split_whitespace().collect();
let target_start = words.iter().position(|w| !w.starts_with('-'));
if let Some(start) = target_start {
let targets = &words[start..];
for target in targets {
let t = target.trim_matches('"').trim_matches('\'');
if (t.contains('$') && !t.contains("\\$")) && !t.contains(":?") && !t.contains(":-") {
return true;
}
}
}
false
}
fn extract_first_variable(line: &str) -> Option<&str> {
if let Some(pos) = line.find('$') {
let rest = &line[pos + 1..];
if rest.starts_with('{') {
if let Some(end) = rest.find('}') {
let inner = &rest[1..end];
let name = inner.split(':').next().unwrap_or(inner);
return Some(name);
}
} else {
let end = rest
.find(|c: char| !c.is_alphanumeric() && c != '_')
.unwrap_or(rest.len());
if end > 0 {
return Some(&rest[..end]);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bash004_detects_rm_rf_variable() {
let script = r#"rm -rf "$DIR""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "BASH004");
}
#[test]
fn test_bash004_detects_rm_rf_unquoted() {
let script = "rm -rf $BUILD_DIR/";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_bash004_detects_rm_fr() {
let script = r#"rm -fr "$DIR""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_bash004_safe_with_guard() {
let script = r#"rm -rf "${DIR:?Variable not set}""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_bash004_safe_with_default() {
let script = r#"rm -rf "${DIR:-/tmp/safe}""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_bash004_safe_literal_path() {
let script = "rm -rf /tmp/build";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_bash004_ignores_comments() {
let script = r#"# rm -rf "$DIR""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_bash004_has_autofix() {
let script = r#"rm -rf "$DIR""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].fix.is_some());
}
#[test]
fn test_bash004_empty() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_bash004_rm_without_rf() {
let script = r#"rm "$FILE""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_bash004_braced_variable() {
let script = r#"rm -rf "${INSTALL_PREFIX}"/lib"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(10))]
#[test]
fn prop_bash004_never_panics(s in ".*") {
let _ = check(&s);
}
#[test]
fn prop_bash004_guarded_is_safe(
var in "[A-Z_]{1,10}",
msg in "[a-z ]{1,15}",
) {
let script = format!("rm -rf \"${{{}:?{}}}\"", var, msg);
let result = check(&script);
prop_assert_eq!(result.diagnostics.len(), 0);
}
}
}