Skip to main content

intelli_shell/utils/
destructive.rs

1use crate::config::RegexWrapper;
2
3/// Checks whether a command string is destructive based on its tags and regex patterns.
4pub fn is_destructive(command: &str, tags: &[String], patterns: &[RegexWrapper]) -> bool {
5    // 1. Tag-Based Detection (Always-On)
6    if tags.iter().any(|t| t == "#destructive") {
7        return true;
8    }
9
10    // 2. Config-Based Regex Detection
11    if patterns.is_empty() {
12        return false;
13    }
14
15    let segments = split_shell_segments(command);
16    for pattern in patterns {
17        for segment in &segments {
18            let trimmed = segment.trim();
19            if !trimmed.is_empty() && pattern.is_match(trimmed) {
20                return true;
21            }
22        }
23    }
24
25    false
26}
27
28fn split_shell_segments(command: &str) -> Vec<&str> {
29    let bytes = command.as_bytes();
30    let mut segments = Vec::new();
31    let mut start = 0;
32    let mut index = 0;
33    let mut quote: Option<u8> = None;
34    let mut escaped = false;
35
36    while index < bytes.len() {
37        let byte = bytes[index];
38
39        if escaped {
40            escaped = false;
41            index += 1;
42            continue;
43        }
44
45        if let Some(active_quote) = quote {
46            if byte == b'\\' && active_quote == b'"' {
47                escaped = true;
48            } else if byte == active_quote {
49                quote = None;
50            }
51            index += 1;
52            continue;
53        }
54
55        match byte {
56            b'\\' => {
57                escaped = true;
58                index += 1;
59            }
60            b'\'' | b'"' => {
61                quote = Some(byte);
62                index += 1;
63            }
64            b';' | b'\n' => {
65                segments.push(&command[start..index]);
66                start = index + 1;
67                index += 1;
68            }
69            b'&' if bytes.get(index + 1) == Some(&b'&') => {
70                segments.push(&command[start..index]);
71                start = index + 2;
72                index += 2;
73            }
74            b'&' if bytes.get(index + 1) == Some(&b'>') || (index > 0 && bytes[index - 1] == b'>') => {
75                index += 1;
76            }
77            b'&' => {
78                segments.push(&command[start..index]);
79                start = index + 1;
80                index += 1;
81            }
82            b'|' if bytes.get(index + 1) == Some(&b'|') => {
83                segments.push(&command[start..index]);
84                start = index + 2;
85                index += 2;
86            }
87            b'|' => {
88                segments.push(&command[start..index]);
89                start = index + 1;
90                index += 1;
91            }
92            _ => index += 1,
93        }
94    }
95
96    segments.push(&command[start..]);
97    segments
98}
99
100#[cfg(test)]
101mod tests {
102    use regex::Regex;
103
104    use super::{is_destructive, split_shell_segments};
105    use crate::config::RegexWrapper;
106
107    fn make_patterns(pats: &[&str]) -> Vec<RegexWrapper> {
108        pats.iter().map(|p| RegexWrapper::new(Regex::new(p).unwrap())).collect()
109    }
110
111    #[test]
112    fn test_tag_based_detection() {
113        // Tag-based check is always-on and triggers if '#destructive' tag is present
114        assert!(is_destructive("echo safe", &["#destructive".to_string()], &[]));
115        assert!(is_destructive("rm -rf /", &["#destructive".to_string()], &[]));
116        assert!(is_destructive(
117            "some-command",
118            &["#other".to_string(), "#destructive".to_string()],
119            &[]
120        ));
121
122        // If '#destructive' is not present, it should not trigger without patterns
123        assert!(!is_destructive("rm -rf /", &["#safe".to_string()], &[]));
124    }
125
126    #[test]
127    fn redirection_ampersands_do_not_split_segments() {
128        for command in ["rm -rf / 2>&1", "rm -rf / &>output", "rm -rf / >&output"] {
129            assert_eq!(split_shell_segments(command), vec![command]);
130        }
131        assert_eq!(split_shell_segments("echo ok & rm -rf / 2>&1"), vec!["echo ok ", " rm -rf / 2>&1"]);
132    }
133
134    #[test]
135    fn test_regex_patterns_detection() {
136        let patterns = make_patterns(&["^rm\\b", "^del\\b"]);
137
138        // Matches segment starting with rm or del
139        assert!(is_destructive("rm -rf /", &[], &patterns));
140        assert!(is_destructive("del file.txt", &[], &patterns));
141        assert!(is_destructive("echo ok && rm -rf /", &[], &patterns));
142        assert!(is_destructive("echo ok & rm -rf /", &[], &patterns));
143        assert!(is_destructive("rm -rf / | echo", &[], &patterns));
144
145        // Negative cases that should not match
146        assert!(!is_destructive("echo rm file", &[], &patterns));
147        assert!(!is_destructive("docker run --rm image", &[], &patterns));
148        assert!(!is_destructive("rmdir_backup", &[], &patterns));
149    }
150}