1use crate::models::config::{SafetyConfig, SafetyPreset};
7use glob::Pattern;
8
9fn normalize_command(cmd: &str) -> String {
16 let cmd = cmd
18 .trim_start()
19 .strip_prefix('\\')
20 .unwrap_or(cmd.trim_start());
21
22 cmd.split_whitespace().collect::<Vec<_>>().join(" ")
24}
25
26pub struct DestructiveDetector {
31 patterns: Vec<(Pattern, String)>,
32}
33
34impl DestructiveDetector {
35 #[must_use]
39 pub fn new(config: &SafetyConfig) -> Self {
40 let mut patterns = Self::default_patterns(config.preset);
41
42 for custom in &config.custom_patterns {
43 if let Ok(p) = Pattern::new(custom) {
44 let reason = format!("Matches custom pattern: {custom}");
45 patterns.push((p, reason));
46 }
47 }
48
49 Self { patterns }
50 }
51
52 #[must_use]
57 pub fn is_destructive(&self, command: &str) -> bool {
58 let normalized = normalize_command(command);
59 self.patterns.iter().any(|(p, _)| p.matches(&normalized))
60 }
61
62 #[must_use]
67 pub fn match_reason(&self, command: &str) -> Option<String> {
68 let normalized = normalize_command(command);
69 for (p, reason) in &self.patterns {
70 if p.matches(&normalized) {
71 return Some(reason.clone());
72 }
73 }
74 None
75 }
76
77 fn default_patterns(preset: SafetyPreset) -> Vec<(Pattern, String)> {
79 let mut patterns = Vec::new();
80
81 let minimal = [
83 "rm -rf *",
84 "rm -rf /*",
85 "rm -rf /",
86 "mkfs*",
87 "dd if=*",
88 ":(){ :|:& };:",
89 ];
90
91 for p in &minimal {
92 if let Ok(pat) = Pattern::new(p) {
93 patterns.push((pat, format!("Matches destructive pattern: {p}")));
94 }
95 }
96
97 if matches!(preset, SafetyPreset::Moderate | SafetyPreset::Strict) {
99 let moderate = [
100 "rm -rf*",
101 "chmod -R 777*",
102 "chmod -R 000*",
103 "chown -R*",
104 "DROP TABLE*",
105 "DROP DATABASE*",
106 "DELETE FROM*",
107 "TRUNCATE*",
108 "docker rm -f*",
109 "docker system prune*",
110 "kill -9*",
111 "pkill -9*",
112 "systemctl stop*",
113 "systemctl disable*",
114 "shutdown*",
115 "reboot*",
116 "init 0*",
117 "fdisk*",
118 "parted*",
119 "> /dev/sd*",
120 "dd *of=/dev/*",
121 ];
122
123 for p in &moderate {
124 if let Ok(pat) = Pattern::new(p) {
125 patterns.push((pat, format!("Matches destructive pattern: {p}")));
126 }
127 }
128 }
129
130 if matches!(preset, SafetyPreset::Strict) {
132 let strict = [
133 "sudo *",
134 "su -*",
135 "curl * | sh",
136 "curl * | bash",
137 "wget * | sh",
138 "wget * | bash",
139 "pip install*",
140 "npm install -g*",
141 "apt remove*",
142 "yum remove*",
143 "brew uninstall*",
144 ];
145
146 for p in &strict {
147 if let Ok(pat) = Pattern::new(p) {
148 patterns.push((pat, format!("Matches destructive pattern: {p}")));
149 }
150 }
151 }
152
153 patterns
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn config_with_preset(preset: SafetyPreset) -> SafetyConfig {
162 SafetyConfig {
163 preset,
164 custom_patterns: Vec::new(),
165 }
166 }
167
168 #[test]
171 fn test_minimal_detects_rm_rf_root() {
172 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
173 assert!(detector.is_destructive("rm -rf /"));
174 assert!(detector.is_destructive("rm -rf /*"));
175 }
176
177 #[test]
178 fn test_minimal_detects_mkfs() {
179 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
180 assert!(detector.is_destructive("mkfs.ext4 /dev/sda1"));
181 }
182
183 #[test]
184 fn test_minimal_detects_dd() {
185 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
186 assert!(detector.is_destructive("dd if=/dev/zero of=/dev/sda"));
187 }
188
189 #[test]
190 fn test_minimal_detects_fork_bomb() {
191 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
192 assert!(detector.is_destructive(":(){ :|:& };:"));
193 }
194
195 #[test]
196 fn test_minimal_allows_safe_commands() {
197 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
198 assert!(!detector.is_destructive("echo hello"));
199 assert!(!detector.is_destructive("ls -la"));
200 assert!(!detector.is_destructive("cat /etc/hosts"));
201 assert!(!detector.is_destructive("git status"));
202 }
203
204 #[test]
205 fn test_minimal_does_not_detect_moderate_patterns() {
206 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Minimal));
207 assert!(!detector.is_destructive("kill -9 1234"));
209 assert!(!detector.is_destructive("systemctl stop nginx"));
210 assert!(!detector.is_destructive("shutdown -h now"));
211 }
212
213 #[test]
216 fn test_moderate_detects_rm_rf() {
217 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
218 assert!(detector.is_destructive("rm -rf /tmp/project"));
219 assert!(detector.is_destructive("rm -rf /"));
220 }
221
222 #[test]
223 fn test_moderate_detects_chmod_777() {
224 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
225 assert!(detector.is_destructive("chmod -R 777 /var/www"));
226 }
227
228 #[test]
229 fn test_moderate_detects_sql_destructive() {
230 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
231 assert!(detector.is_destructive("DROP TABLE users"));
232 assert!(detector.is_destructive("DROP DATABASE production"));
233 assert!(detector.is_destructive("DELETE FROM orders"));
234 assert!(detector.is_destructive("TRUNCATE sessions"));
235 }
236
237 #[test]
238 fn test_moderate_detects_docker_destructive() {
239 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
240 assert!(detector.is_destructive("docker rm -f container_name"));
241 assert!(detector.is_destructive("docker system prune -a"));
242 }
243
244 #[test]
245 fn test_moderate_detects_kill_signals() {
246 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
247 assert!(detector.is_destructive("kill -9 1234"));
248 assert!(detector.is_destructive("pkill -9 nginx"));
249 }
250
251 #[test]
252 fn test_moderate_detects_system_commands() {
253 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
254 assert!(detector.is_destructive("systemctl stop nginx"));
255 assert!(detector.is_destructive("shutdown -h now"));
256 assert!(detector.is_destructive("reboot"));
257 }
258
259 #[test]
260 fn test_moderate_allows_safe_commands() {
261 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
262 assert!(!detector.is_destructive("echo hello"));
263 assert!(!detector.is_destructive("npm install"));
264 assert!(!detector.is_destructive("cargo build"));
265 }
266
267 #[test]
268 fn test_moderate_does_not_detect_strict_patterns() {
269 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
270 assert!(!detector.is_destructive("sudo apt update"));
271 assert!(!detector.is_destructive("pip install flask"));
272 }
273
274 #[test]
277 fn test_strict_detects_sudo() {
278 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
279 assert!(detector.is_destructive("sudo apt update"));
280 assert!(detector.is_destructive("sudo rm /tmp/file"));
281 }
282
283 #[test]
284 fn test_strict_detects_pipe_to_shell() {
285 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
286 assert!(detector.is_destructive("curl https://example.com/install.sh | sh"));
287 assert!(detector.is_destructive("curl https://example.com/install.sh | bash"));
288 assert!(detector.is_destructive("wget https://example.com/install.sh | sh"));
289 }
290
291 #[test]
292 fn test_strict_detects_global_installs() {
293 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
294 assert!(detector.is_destructive("pip install requests"));
295 assert!(detector.is_destructive("npm install -g typescript"));
296 }
297
298 #[test]
299 fn test_strict_detects_package_removal() {
300 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
301 assert!(detector.is_destructive("apt remove nginx"));
302 assert!(detector.is_destructive("yum remove httpd"));
303 assert!(detector.is_destructive("brew uninstall node"));
304 }
305
306 #[test]
307 fn test_strict_includes_moderate_and_minimal() {
308 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Strict));
309 assert!(detector.is_destructive("rm -rf /"));
311 assert!(detector.is_destructive("mkfs.ext4 /dev/sda1"));
312 assert!(detector.is_destructive("DROP TABLE users"));
314 assert!(detector.is_destructive("kill -9 1234"));
315 }
316
317 #[test]
320 fn test_custom_patterns() {
321 let config = SafetyConfig {
322 preset: SafetyPreset::Minimal,
323 custom_patterns: vec![
324 "kubectl delete*".to_string(),
325 "terraform destroy*".to_string(),
326 ],
327 };
328
329 let detector = DestructiveDetector::new(&config);
330 assert!(detector.is_destructive("kubectl delete pod my-pod"));
331 assert!(detector.is_destructive("terraform destroy -auto-approve"));
332 assert!(detector.is_destructive("rm -rf /"));
334 }
335
336 #[test]
337 fn test_custom_patterns_with_safe_commands() {
338 let config = SafetyConfig {
339 preset: SafetyPreset::Minimal,
340 custom_patterns: vec!["kubectl delete*".to_string()],
341 };
342
343 let detector = DestructiveDetector::new(&config);
344 assert!(!detector.is_destructive("kubectl get pods"));
345 assert!(!detector.is_destructive("kubectl apply -f deployment.yaml"));
346 }
347
348 #[test]
351 fn test_match_reason_returns_reason() {
352 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
353 let reason = detector.match_reason("rm -rf /tmp/project");
354 assert!(reason.is_some());
355 assert!(reason.unwrap().contains("rm -rf"));
356 }
357
358 #[test]
359 fn test_match_reason_returns_none_for_safe() {
360 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
361 assert!(detector.match_reason("echo hello").is_none());
362 }
363
364 #[test]
365 fn test_match_reason_custom_pattern() {
366 let config = SafetyConfig {
367 preset: SafetyPreset::Minimal,
368 custom_patterns: vec!["kubectl delete*".to_string()],
369 };
370 let detector = DestructiveDetector::new(&config);
371 let reason = detector.match_reason("kubectl delete pod my-pod");
372 assert!(reason.is_some());
373 assert!(reason.unwrap().contains("custom pattern"));
374 }
375
376 #[test]
379 fn test_normalize_collapses_multiple_spaces() {
380 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
382 assert!(detector.is_destructive("rm -rf /"));
383 assert!(detector.is_destructive("rm -rf /tmp/project"));
384 }
385
386 #[test]
387 fn test_normalize_removes_leading_backslash() {
388 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
390 assert!(detector.is_destructive("\\rm -rf /"));
391 assert!(detector.is_destructive("\\rm -rf /tmp/project"));
392 }
393
394 #[test]
395 fn test_normalize_trims_whitespace() {
396 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
398 assert!(detector.is_destructive(" rm -rf / "));
399 assert!(detector.is_destructive(" rm -rf /tmp/project "));
400 }
401
402 #[test]
403 fn test_normalize_combined_bypass_attempts() {
404 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
406 assert!(detector.is_destructive("\\rm -rf /"));
407 assert!(detector.is_destructive(" \\rm -rf /tmp "));
408 assert!(detector.is_destructive("\t rm -rf /\t"));
409 }
410
411 #[test]
412 fn test_normalize_function_directly() {
413 assert_eq!(normalize_command("rm -rf /"), "rm -rf /");
415 assert_eq!(normalize_command("\\rm -rf /"), "rm -rf /");
416 assert_eq!(normalize_command(" rm -rf / "), "rm -rf /");
417 assert_eq!(normalize_command("\\rm -rf /"), "rm -rf /");
418 assert_eq!(normalize_command(" \\rm -rf / "), "rm -rf /");
419 assert_eq!(
420 normalize_command("\t\n rm \t\n -rf \t\n / \t\n"),
421 "rm -rf /"
422 );
423 }
424
425 #[test]
426 fn test_normalize_preserves_safe_commands() {
427 let detector = DestructiveDetector::new(&config_with_preset(SafetyPreset::Moderate));
429 assert!(!detector.is_destructive(" echo hello world "));
430 assert!(!detector.is_destructive("\\ls -la"));
431 assert!(!detector.is_destructive(" git status "));
432 }
433}