use crate::bash_danger_argv::{
any_statement_is_cd, command_argv, normalise_verb, positional_operands, simple_commands,
CMDSUB_SENTINEL, ENV_MARKER, VAR_SENTINEL,
};
use regex::Regex;
use std::sync::LazyLock;
static DRIVE_ROOT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Za-z]:/?$").expect("qYr"));
static DRIVE_TOP: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z]:/[^/]+$").expect("VYr"));
static TRAILING_GLOB: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"([\\/]\*+)+[\\/]*$").expect("trailing_glob"));
static RMDIR_PARENTS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^--p|^-[a-z]*p").expect("rmdir_p"));
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum OperandVerdict {
Ask { tail: &'static str, target: String },
NeedsFs,
Clear,
}
pub(crate) const TAIL_UNRESOLVABLE: &str = "on statically-unresolvable target";
pub(crate) const TAIL_CRITICAL: &str = "on critical path";
pub(crate) fn structured(command: &str) -> OperandVerdict {
let cd_seen = any_statement_is_cd(command);
let mut needs_fs = false;
for stmt in simple_commands(command) {
let argv = command_argv(&stmt);
let Some(head) = argv.first() else { continue };
let verb = normalise_verb(head);
if verb != "rm" && verb != "rmdir" {
continue;
}
let rest: Vec<String> = argv[1..].to_vec();
let operands = positional_operands(&rest);
for b in &operands {
match removal_check(verb, b, &rest, cd_seen) {
OperandVerdict::Ask { tail, target } => {
return OperandVerdict::Ask { tail, target }
}
OperandVerdict::NeedsFs => needs_fs = true,
OperandVerdict::Clear => {}
}
}
}
if needs_fs {
OperandVerdict::NeedsFs
} else {
OperandVerdict::Clear
}
}
fn removal_check(verb: &str, b: &str, argv_rest: &[String], cd_seen: bool) -> OperandVerdict {
if b.contains(ENV_MARKER) {
return OperandVerdict::NeedsFs;
}
let absolute = is_absolute(b);
let u = if absolute {
b.to_string()
} else {
format!("{CWD}/{b}")
};
let j = trailing_glob_fixpoint(&u);
let w = j != u;
let shown = b.to_string();
if cd_seen && w && !absolute && u.ends_with("/*") {
return OperandVerdict::Ask {
tail: TAIL_UNRESOLVABLE,
target: shown,
};
}
let dotdot_after_segment = has_dotdot_after_segment(b);
let sentinel = b.contains(CMDSUB_SENTINEL) || b.contains(VAR_SENTINEL);
let unc = is_unc_or_drive(b);
let rmdir_p =
verb == "rmdir" && u.ends_with("/*") && argv_rest.iter().any(|f| RMDIR_PARENTS.is_match(f));
let star_slash = !absolute && ends_with_star_slash(b) && u.ends_with("/*");
let dotdot_glob = !absolute && has_dotdot_segment(b) && u.ends_with("/*");
if w && (dotdot_after_segment
|| sentinel
|| b.starts_with('~')
|| unc
|| dotdot_glob
|| rmdir_p
|| star_slash)
{
return OperandVerdict::Ask {
tail: TAIL_UNRESOLVABLE,
target: shown,
};
}
let z = if absolute { j.clone() } else { relative_z(&j) };
if !w || !z.contains(['*', '?', '[']) {
if b == "~" || b == "~/" || (!j.contains(CWD) && is_critical_path(&j)) {
return OperandVerdict::Ask {
tail: TAIL_CRITICAL,
target: shown,
};
}
return OperandVerdict::NeedsFs;
}
OperandVerdict::NeedsFs
}
const CWD: &str = "\u{1}cwd";
fn is_critical_path(p: &str) -> bool {
let n = collapse_slashes(p);
if n == "*" || n.ends_with("/*") {
return true;
}
let f = if n == "/" {
n.clone()
} else {
n.trim_end_matches('/').to_string()
};
if f == "/" {
return true;
}
if DRIVE_ROOT.is_match(&f) {
return true;
}
if dirname(&f) == "/" {
return true;
}
DRIVE_TOP.is_match(&f)
}
fn trailing_glob_fixpoint(u: &str) -> String {
let mut j = u.to_string();
loop {
let fe = j.clone();
let stripped = TRAILING_GLOB.replace(&j, "").into_owned();
let me = if stripped.is_empty() {
"/".to_string()
} else {
stripped
};
if me != j {
j = if me.contains('/') || me.contains('\\') {
normalize(&me)
} else {
me
};
}
if fe == j {
return j;
}
}
}
fn relative_z(j: &str) -> String {
j.strip_prefix(CWD)
.map(|s| s.trim_start_matches('/').to_string())
.unwrap_or_else(|| j.to_string())
}
pub(crate) fn normalize(p: &str) -> String {
let absolute = p.starts_with('/');
let mut out: Vec<&str> = Vec::new();
for seg in p.split('/') {
match seg {
"" | "." => {}
".." => {
if matches!(out.last(), Some(&last) if last != "..") {
out.pop();
} else if !absolute {
out.push("..");
}
}
s => out.push(s),
}
}
let joined = out.join("/");
if absolute {
format!("/{joined}")
} else if joined.is_empty() {
".".to_string()
} else {
joined
}
}
fn collapse_slashes(p: &str) -> String {
let mut out = String::with_capacity(p.len());
let mut prev_sep = false;
for c in p.chars() {
let sep = c == '/' || c == '\\';
if sep {
if !prev_sep {
out.push('/');
}
} else {
out.push(c);
}
prev_sep = sep;
}
out
}
fn dirname(p: &str) -> String {
match p.rfind('/') {
Some(0) => "/".to_string(),
Some(i) => p[..i].to_string(),
None => ".".to_string(),
}
}
fn is_absolute(b: &str) -> bool {
b.starts_with('/') || b.starts_with('\\') || is_drive_prefixed(b)
}
fn is_drive_prefixed(b: &str) -> bool {
let mut cs = b.chars();
matches!((cs.next(), cs.next()), (Some(c), Some(':')) if c.is_ascii_alphabetic())
}
fn is_unc_or_drive(b: &str) -> bool {
let mut cs = b.chars();
let two_seps = matches!((cs.next(), cs.next()), (Some('/' | '\\'), Some('/' | '\\')));
two_seps || is_drive_prefixed(b)
}
fn has_dotdot_after_segment(b: &str) -> bool {
let mut saw_real = false;
for seg in b.split(['/', '\\']) {
if seg.is_empty() || seg == "." {
continue;
}
if seg == ".." {
if saw_real {
return true;
}
} else {
saw_real = true;
}
}
false
}
fn has_dotdot_segment(b: &str) -> bool {
b.split(['/', '\\']).any(|s| s == "..")
}
fn ends_with_star_slash(b: &str) -> bool {
let t = b.trim_end_matches(['/', '\\']);
t.len() < b.len() && t.ends_with('*')
}
#[cfg(test)]
#[path = "bash_danger_removal_tests.rs"]
mod tests;