use regex::Regex;
pub const FILE_OPS: &[&str] = &["cp", "mv", "cat", "tar", "unzip", "rm", "mkdir", "cd", "ln"];
pub const TRAVERSAL_PATTERNS: &[&str] = &["..", "../", "/.."];
pub const SAFE_VAR_PATTERNS: &[&str] = &[
"$PWD",
"${PWD}",
"$HOME",
"${HOME}",
"$TMPDIR",
"${TMPDIR}",
"BASH_SOURCE",
"dirname",
"XDG_",
];
pub const USER_INPUT_PATTERNS: &[&str] = &[
"USER",
"INPUT",
"UPLOAD",
"ARCHIVE",
"UNTRUSTED",
"EXTERNAL",
"REMOTE",
"ARG",
"NAME",
"FILE",
"PATH",
"DIR",
];
pub fn is_comment(line: &str) -> bool {
line.trim().starts_with('#')
}
pub fn is_heredoc_pattern(line: &str) -> bool {
line.contains("<<")
&& (line.contains("EOF") || line.contains("'EOF'") || line.contains("\"EOF\""))
}
pub fn is_path_validation_check(line: &str) -> bool {
(line.contains("[[") || line.contains("[ "))
&& (line.contains("\"..\"") || line.contains("*\"..*") || line.contains("/*"))
}
pub fn extract_validated_variable(line: &str) -> Option<String> {
#[allow(clippy::unwrap_used)] static VAR_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?").unwrap());
VAR_PATTERN.captures(line).map(|cap| cap[1].to_string())
}
pub fn extract_assigned_variable(line: &str) -> Option<String> {
#[allow(clippy::unwrap_used)] static ASSIGN_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"([A-Za-z_][A-Za-z0-9_]*)=").unwrap());
ASSIGN_PATTERN.captures(line).map(|cap| cap[1].to_string())
}
pub fn is_variable_validated(line: &str, validated_vars: &[String]) -> bool {
for var in validated_vars {
if line.contains(&format!("${}", var)) || line.contains(&format!("${{{}}}", var)) {
return true;
}
}
false
}
pub fn find_command(line: &str, cmd: &str) -> Option<usize> {
if let Some(pos) = line.find(cmd) {
let before_ok = if pos == 0 {
true
} else {
line.chars()
.nth(pos - 1)
.is_some_and(|c| matches!(c, ' ' | '\t' | ';' | '&' | '|' | '(' | '\n'))
};
let after_idx = pos + cmd.len();
let after_ok = if after_idx >= line.len() {
true
} else {
line.chars()
.nth(after_idx)
.is_some_and(|c| matches!(c, ' ' | '\t' | ';' | '&' | '|' | ')'))
};
if before_ok && after_ok {
return Some(pos);
}
}
None
}
pub fn contains_unvalidated_variable(line: &str) -> bool {
if !line.contains('$') {
return false;
}
for safe_pattern in SAFE_VAR_PATTERNS {
if line.contains(safe_pattern) {
return false;
}
}
if line.contains("dirname") && line.contains("..") {
return false;
}
for pattern in USER_INPUT_PATTERNS {
if line.contains(pattern) && (line.contains('$') || line.contains("${")) {
if *pattern == "PATH" && (line.contains("$PATH") || line.contains("${PATH}")) {
continue;
}
return true;
}
}
false
}
pub fn contains_file_operation(line: &str) -> bool {
FILE_OPS.iter().any(|cmd| find_command(line, cmd).is_some())
}
pub fn is_validation_context(line: &str) -> bool {
line.contains("==") || line.contains("!=") || line.contains("-n") || line.contains("-z")
}
pub fn is_validation_block_end(line: &str) -> bool {
let trimmed = line.trim();
trimmed == "fi" || trimmed.starts_with("fi ") || trimmed.starts_with("fi;")
}
pub fn is_validation_guard(line: &str) -> bool {
let trimmed = line.trim();
trimmed.contains("exit") || trimmed.contains("return")
}
pub fn is_realpath_validation(line: &str) -> bool {
line.contains("realpath") || line.contains("readlink -f")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_comment_true() {
assert!(is_comment("# comment"));
assert!(is_comment(" # indented"));
}
#[test]
fn test_is_comment_false() {
assert!(!is_comment("echo hello # inline"));
assert!(!is_comment("cp file dest"));
}
#[test]
fn test_is_heredoc_pattern_true() {
assert!(is_heredoc_pattern("cat <<EOF"));
assert!(is_heredoc_pattern("cat <<'EOF'"));
}
#[test]
fn test_is_heredoc_pattern_false() {
assert!(!is_heredoc_pattern("cat file.txt"));
}
#[test]
fn test_is_path_validation_check_true() {
assert!(is_path_validation_check(
r#"if [[ "$VAR" == *".."* ]]; then"#
));
}
#[test]
fn test_is_path_validation_check_false() {
assert!(!is_path_validation_check("cp $FILE dest"));
}
#[test]
fn test_extract_validated_variable() {
let result = extract_validated_variable(r#"if [[ "$USER_PATH" == *".."* ]]; then"#);
assert_eq!(result, Some("USER_PATH".into()));
}
#[test]
fn test_extract_validated_variable_none() {
let result = extract_validated_variable("echo hello");
assert!(result.is_none());
}
#[test]
fn test_extract_assigned_variable() {
let result = extract_assigned_variable("SAFE_PATH=$(realpath -m \"$FILE\")");
assert_eq!(result, Some("SAFE_PATH".into()));
}
#[test]
fn test_is_variable_validated_true() {
let validated = vec!["SAFE_PATH".into()];
assert!(is_variable_validated("cp $SAFE_PATH dest", &validated));
assert!(is_variable_validated("cp ${SAFE_PATH} dest", &validated));
}
#[test]
fn test_is_variable_validated_false() {
let validated = vec!["SAFE_PATH".into()];
assert!(!is_variable_validated("cp $OTHER_PATH dest", &validated));
}
#[test]
fn test_find_command_found() {
assert_eq!(find_command("cp file dest", "cp"), Some(0));
assert_eq!(find_command(" cp file dest", "cp"), Some(2));
}
#[test]
fn test_find_command_not_word() {
assert_eq!(find_command("cpr file", "cp"), None);
}
#[test]
fn test_contains_unvalidated_variable_user_input() {
assert!(contains_unvalidated_variable("cp $USER_FILE dest"));
assert!(contains_unvalidated_variable("cat $INPUT_PATH"));
}
#[test]
fn test_contains_unvalidated_variable_safe() {
assert!(!contains_unvalidated_variable("cp $PWD/file dest"));
assert!(!contains_unvalidated_variable("cd $HOME"));
}
#[test]
fn test_contains_unvalidated_variable_no_var() {
assert!(!contains_unvalidated_variable("cp file dest"));
}
#[test]
fn test_contains_file_operation_true() {
assert!(contains_file_operation("cp file dest"));
assert!(contains_file_operation("cat file.txt"));
}
#[test]
fn test_contains_file_operation_false() {
assert!(!contains_file_operation("echo hello"));
}
#[test]
fn test_is_validation_context_true() {
assert!(is_validation_context(r#"if [[ "$x" == "y" ]]; then"#));
assert!(is_validation_context("if [ -n \"$x\" ]; then"));
}
#[test]
fn test_is_validation_context_false() {
assert!(!is_validation_context("cp file dest"));
}
#[test]
fn test_is_validation_block_end_true() {
assert!(is_validation_block_end("fi"));
assert!(is_validation_block_end("fi;"));
assert!(is_validation_block_end(" fi "));
}
#[test]
fn test_is_validation_block_end_false() {
assert!(!is_validation_block_end("if"));
}
#[test]
fn test_is_validation_guard_true() {
assert!(is_validation_guard("exit 1"));
assert!(is_validation_guard("return 1"));
}
#[test]
fn test_is_validation_guard_false() {
assert!(!is_validation_guard("echo error"));
}
#[test]
fn test_is_realpath_validation_true() {
assert!(is_realpath_validation("SAFE=$(realpath -m \"$x\")"));
assert!(is_realpath_validation("SAFE=$(readlink -f \"$x\")"));
}
#[test]
fn test_is_realpath_validation_false() {
assert!(!is_realpath_validation("echo $PATH"));
}
}