use regex::Regex;
use std::sync::LazyLock;
static CLAUSE_HEAD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:[A-Za-z_][A-Za-z0-9_]*\+?=[^\s]*\s+)*\\?(?:[^\s=]*/)?(rm|rmdir)(?:\s|$)")
.expect("_to")
});
static TARGET: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"^"?\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)"?/(?:\*|\$|/|["']|$)"#)
.expect("yto")
});
pub(crate) static RM_WORD: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\brm(?:dir)?\b").expect("rm_word"));
pub(crate) static REDIR_START: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\d&]*[<>]").expect("redir"));
pub(crate) static REDIR_OP: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:[0-9]+|&)?(?:>>?[|&]?|<<?<?|<>)$").expect("redir_op"));
static LINE_CONT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\\r?\n").expect("line_cont"));
pub(crate) static BACKTICKS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"`[^`]*`").expect("backticks"));
pub(crate) static DOLLAR_PAREN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\$\([^()]*\)").expect("dpar"));
static PLAIN_PAREN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\([^()]*\)").expect("ppar"));
pub(crate) static CLAUSE_SPLIT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[;|\n\r]|&&").expect("clause"));
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LexicalHit {
pub command: &'static str,
pub target: String,
}
pub(crate) fn hnt(command: &str) -> Option<LexicalHit> {
if !command.contains('$') || !RM_WORD.is_match(command) {
return None;
}
let n = LINE_CONT.replace_all(command, " ");
let n = BACKTICKS.replace_all(&n, " ");
let mut n = n.trim_start().to_string();
while n.starts_with('(') || n.starts_with('{') {
n = n[1..].trim_start().to_string();
}
n = strip_paren_groups(&n);
n = amp_to_semicolon(&n);
for clause in CLAUSE_SPLIT.split(&n) {
let o = clause.trim_start();
let Some(caps) = CLAUSE_HEAD.captures(o) else {
continue;
};
let verb: &'static str = if &caps[1] == "rmdir" { "rmdir" } else { "rm" };
let rest = &o[caps.get(0).map_or(0, |m| m.end())..];
let args: Vec<&str> = rest.split_whitespace().collect();
if let Some(target) = scan_operands(&args) {
return Some(LexicalHit {
command: verb,
target,
});
}
}
None
}
pub(crate) fn scan_operands(args: &[&str]) -> Option<String> {
let mut l = 0usize;
while l < args.len() {
let c = args[l].trim_end_matches([')', ']', '}']);
if c.is_empty() || c.starts_with('-') || c.starts_with('\'') {
l += 1;
continue;
}
if REDIR_START.is_match(c) {
if REDIR_OP.is_match(c) {
l += 1;
}
l += 1;
continue;
}
if TARGET.is_match(c) {
return Some(c.to_string());
}
l += 1;
}
None
}
pub(crate) fn strip_paren_groups(s: &str) -> String {
let mut n = s.to_string();
loop {
let prev = n.clone();
n = DOLLAR_PAREN.replace_all(&n, " ").into_owned();
n = remove_plain_groups(&n);
if n == prev {
break;
}
}
n
}
pub(crate) fn remove_plain_groups(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut last = 0;
for m in PLAIN_PAREN.find_iter(s) {
let start = m.start();
let preceded_by_dollar = start > 0 && bytes[start - 1] == b'$';
out.push_str(&s[last..start]);
if preceded_by_dollar {
out.push_str(m.as_str());
} else {
out.push(' ');
}
last = m.end();
}
out.push_str(&s[last..]);
out
}
pub(crate) const VCT: char = '\u{E020}';
pub(crate) const KCT: char = '\u{E010}';
pub(crate) const ESCAPED_SPACE: &str = "\u{E022}";
const SPECIALS: &[char] = &[';', '|', '&', '\n', '\r', '(', ')', '`', ' ', '\t'];
const SHIELD_BASE: u32 = 57345;
pub(crate) fn escapes_next(chars: &[char], i: usize, quote: Option<char>) -> bool {
match quote {
Some('\'') => false,
None => true,
Some(_) => matches!(chars.get(i + 1), Some('"' | '\\' | '$' | '`')),
}
}
pub(crate) fn open_quote(chars: &[char], n: usize) -> Option<char> {
let mut quote: Option<char> = None;
let mut i = 0usize;
while i < n {
let c = chars[i];
if c == '\\' && escapes_next(chars, i, quote) {
i += 2;
continue;
}
match quote {
None if c == '"' || c == '\'' => quote = Some(c),
Some(q) if c == q => quote = None,
_ => {}
}
i += 1;
}
quote
}
fn has_escaped_blank(s: &str) -> bool {
s.as_bytes()
.windows(2)
.any(|w| w[0] == b'\\' && (w[1] == b' ' || w[1] == b'\t'))
}
pub(crate) fn mask_specials(s: &str) -> String {
if !s.contains('"') && !s.contains('\'') && !has_escaped_blank(s) {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
if open_quote(&chars, chars.len()).is_some() {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut quote: Option<char> = None;
let mut i = 0usize;
while i < chars.len() {
let c = chars[i];
if c == '\\' && escapes_next(&chars, i, quote) {
out.push(c);
if let Some(n) = chars.get(i + 1).copied() {
match SPECIALS.iter().position(|s| *s == n) {
Some(k) if quote.is_none() && (n == ' ' || n == '\t') => out.push(shield(k)),
_ => out.push(n),
}
}
i += 2;
continue;
}
match quote {
None => {
if c == '"' || c == '\'' {
quote = Some(c);
}
out.push(c);
}
Some(q) if c == q => {
quote = None;
out.push(c);
}
Some(_) => match SPECIALS.iter().position(|s| *s == c) {
Some(k) => out.push(shield(k)),
None => out.push(c),
},
}
i += 1;
}
out
}
pub(crate) fn shield(k: usize) -> char {
char::from_u32(SHIELD_BASE + k as u32).unwrap_or('\u{E001}')
}
pub(crate) fn unmask(s: &str) -> String {
s.chars()
.map(|c| {
let v = c as u32;
if v >= SHIELD_BASE && v < SHIELD_BASE + SPECIALS.len() as u32 {
SPECIALS[(v - SHIELD_BASE) as usize]
} else {
c
}
})
.collect()
}
pub(crate) fn resolve_dquote_escapes(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0usize;
while i < chars.len() {
if chars[i] == '\\' {
match chars.get(i + 1) {
Some('$') => {
let named = names_a_variable(&chars, i + 2);
out.push(if named { '$' } else { KCT });
i += 2;
continue;
}
Some(c @ ('"' | '\\' | '`')) => {
out.push(*c);
i += 2;
continue;
}
_ => {}
}
}
out.push(chars[i]);
i += 1;
}
drop_backslashes_before_dollar(&out)
}
fn names_a_variable(chars: &[char], i: usize) -> bool {
let ident = |c: Option<&char>| matches!(c, Some(c) if c.is_ascii_alphabetic() || *c == '_');
ident(chars.get(i)) || (chars.get(i) == Some(&'{') && ident(chars.get(i + 1)))
}
fn drop_backslashes_before_dollar(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0usize;
while i < chars.len() {
if chars[i] == '\\' {
let mut j = i;
while chars.get(j) == Some(&'\\') {
j += 1;
}
if chars.get(j) != Some(&'$') {
out.extend(chars[i..j].iter());
}
i = j;
continue;
}
out.push(chars[i]);
i += 1;
}
out
}
pub(crate) fn mask(s: &str, blank: bool) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut quote: Option<char> = None;
let mut comment = false;
let mut i = 0usize;
while i < chars.len() {
let c = chars[i];
if comment {
if c == '\n' {
comment = false;
out.push(c);
} else {
out.push(' ');
}
i += 1;
continue;
}
if c == '\\' && escapes_next(&chars, i, quote) {
if blank && quote.is_some() {
out.push_str(" ");
} else {
out.push(c);
out.extend(chars.get(i + 1));
}
i += 2;
continue;
}
if quote.is_none() {
if c == '#'
&& (i == 0
|| matches!(
chars[i - 1],
' ' | '\t' | '\n' | '\r' | ';' | '&' | '|' | '(' | ')'
))
{
comment = true;
out.push(' ');
i += 1;
continue;
}
if c == '"' || c == '\'' {
quote = Some(c);
}
out.push(c);
i += 1;
continue;
}
if Some(c) == quote {
quote = None;
out.push(c);
i += 1;
continue;
}
out.push(if blank { ' ' } else { c });
i += 1;
}
out
}
pub(crate) fn strip_backticks(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0usize;
while i < chars.len() {
if chars[i] == '\\' && i + 1 < chars.len() {
out.push(chars[i]);
out.push(chars[i + 1]);
i += 2;
continue;
}
if chars[i] == '`' {
if let Some(end) = chars[i + 1..].iter().position(|c| *c == '`') {
out.push(VCT);
i += end + 2;
continue;
}
}
out.push(chars[i]);
i += 1;
}
out
}
pub(crate) fn paren_fixpoint(s: &str) -> String {
let mut cur = s.to_string();
loop {
let prev = cur.clone();
cur = DOLLAR_PAREN
.replace_all(&cur, VCT.to_string().as_str())
.into_owned();
cur = replace_plain_parens(&cur);
if cur == prev {
return cur;
}
}
}
pub(crate) fn replace_plain_parens(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0usize;
while i < chars.len() {
if chars[i] == '(' && !(i > 0 && (chars[i - 1] == '$' || chars[i - 1] == '\\')) {
let mut j = i + 1;
while j < chars.len() && chars[j] != '(' && chars[j] != ')' {
j += 1;
}
if j < chars.len() && chars[j] == ')' && chars[j - 1] != '\\' {
out.push(VCT);
i = j + 1;
continue;
}
}
out.push(chars[i]);
i += 1;
}
out
}
pub(crate) fn amp_to_semicolon(s: &str) -> String {
let b = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
for i in 0..b.len() {
if b[i] == b'&' {
let prev_bad = i > 0 && matches!(b[i - 1], b'<' | b'>' | b'&');
let next_bad = i + 1 < b.len() && matches!(b[i + 1], b'<' | b'>' | b'&');
if !prev_bad && !next_bad {
out.push(b';');
continue;
}
}
out.push(b[i]);
}
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_backslash_escape_steps_the_quote_scan_over_both_characters() {
let escaped: Vec<char> = "x\\\"y".chars().collect();
assert_eq!(open_quote(&escaped, escaped.len()), None);
let bare: Vec<char> = "x\"y".chars().collect();
assert_eq!(open_quote(&bare, bare.len()), Some('"'));
}
#[test]
fn the_escaped_blank_probe_wants_a_backslash_before_the_blank() {
assert!(has_escaped_blank("a\\ b"));
assert!(has_escaped_blank("a\\\tb"));
assert!(!has_escaped_blank("rm -rf $D/*"));
assert!(!has_escaped_blank("a b"));
}
#[test]
fn an_escaped_blank_admits_a_quoteless_command_to_the_shield() {
assert_eq!(mask_specials("a\\ b"), format!("a\\{}b", shield(8)));
assert_eq!(mask_specials("rm -rf $D/*"), "rm -rf $D/*");
}
#[test]
fn a_special_is_shielded_only_inside_a_quoted_run() {
assert_eq!(
mask_specials("echo \"a;b\""),
format!("echo \"a{}b\"", shield(0))
);
assert_eq!(mask_specials("echo a;b"), "echo a;b");
}
#[test]
fn an_escaped_special_that_is_not_a_blank_keeps_its_character() {
assert_eq!(mask_specials("a\\ b\\;c"), format!("a\\{}b\\;c", shield(8)));
}
#[test]
fn the_mask_escape_arm_keeps_every_refusal_rne_makes() {
assert_eq!(mask("'a\\'b", true), "' 'b");
assert_eq!(mask("\"a\\", true), "\" ");
}
#[test]
fn the_unshield_leaves_the_code_point_past_the_alphabet_alone() {
assert_eq!(unmask(&format!("a{}b", shield(9))), "a\tb");
assert_eq!(unmask("a\u{E00B}b"), "a\u{E00B}b");
}
}