1pub fn command_might_be_dangerous(command: &[String]) -> bool {
15 #[cfg(windows)]
16 {
17 if crate::command_safety::windows::is_dangerous_command_windows(command) {
18 return true;
19 }
20 }
21
22 if is_dangerous_to_call_with_exec(command) {
23 return true;
24 }
25
26 if command.len() >= 3
29 && (command[0] == "bash" || command[0] == "sh" || command[0] == "zsh")
30 && (command[1] == "-c" || command[1] == "-lc" || command[1] == "-ilc")
31 {
32 let script = &command[2];
33 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
34 for sub_cmd in sub_commands {
35 if command_might_be_dangerous(&sub_cmd) {
36 return true;
37 }
38 }
39 }
40 }
41
42 false
43}
44
45fn is_git_global_option_with_value(arg: &str) -> bool {
47 matches!(
48 arg,
49 "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
50 )
51}
52
53fn is_git_global_option_with_inline_value(arg: &str) -> bool {
55 matches!(
56 arg,
57 s if s.starts_with("--config-env=")
58 || s.starts_with("--exec-path=")
59 || s.starts_with("--git-dir=")
60 || s.starts_with("--namespace=")
61 || s.starts_with("--super-prefix=")
62 || s.starts_with("--work-tree=")
63 ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
64}
65
66pub(crate) fn git_global_option_requires_prompt(arg: &str) -> bool {
69 matches!(
70 arg,
71 "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
72 ) || matches!(
73 arg,
74 s if (s.starts_with("-c") && s.len() > 2)
75 || s.starts_with("--config-env=")
76 || s.starts_with("--exec-path=")
77 || s.starts_with("--git-dir=")
78 || s.starts_with("--namespace=")
79 || s.starts_with("--super-prefix=")
80 || s.starts_with("--work-tree=")
81 )
82}
83
84pub(crate) fn find_git_subcommand<'a>(command: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
89 let cmd0 = command.first().map(String::as_str)?;
90 if !cmd0.ends_with("git") {
91 return None;
92 }
93
94 let mut skip_next = false;
95 for (idx, arg) in command.iter().enumerate().skip(1) {
96 if skip_next {
97 skip_next = false;
98 continue;
99 }
100
101 let arg = arg.as_str();
102
103 if is_git_global_option_with_inline_value(arg) {
104 continue;
105 }
106
107 if is_git_global_option_with_value(arg) {
108 skip_next = true;
109 continue;
110 }
111
112 if arg == "--" || arg.starts_with('-') {
113 continue;
114 }
115
116 if subcommands.contains(&arg) {
117 return Some((idx, arg));
118 }
119
120 return None;
124 }
125
126 None
127}
128
129fn short_flag_group_contains(arg: &str, target: char) -> bool {
131 arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
132}
133
134fn git_branch_is_delete(branch_args: &[String]) -> bool {
136 branch_args.iter().map(String::as_str).any(|arg| {
139 matches!(arg, "-d" | "-D" | "--delete")
140 || arg.starts_with("--delete=")
141 || short_flag_group_contains(arg, 'd')
142 || short_flag_group_contains(arg, 'D')
143 })
144}
145
146fn git_push_is_dangerous(push_args: &[String]) -> bool {
148 push_args.iter().map(String::as_str).any(|arg| {
149 matches!(arg, "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d")
150 || arg.starts_with("--force-with-lease=")
151 || arg.starts_with("--force-if-includes=")
152 || arg.starts_with("--delete=")
153 || short_flag_group_contains(arg, 'f')
154 || short_flag_group_contains(arg, 'd')
155 || git_push_refspec_is_dangerous(arg)
156 })
157}
158
159fn git_push_refspec_is_dangerous(arg: &str) -> bool {
161 (arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
163}
164
165fn git_clean_is_force(clean_args: &[String]) -> bool {
167 clean_args.iter().map(String::as_str).any(|arg| {
168 matches!(arg, "--force" | "-f") || arg.starts_with("--force=") || short_flag_group_contains(arg, 'f')
169 })
170}
171
172fn is_dangerous_git_subcommand(command: &[String]) -> bool {
175 if command.is_empty() {
176 return false;
177 }
178
179 let first_arg = command[0].as_str();
180
181 match first_arg {
183 "reset" | "rm" => true,
184 "branch" => git_branch_is_delete(&command[1..]),
185 "push" => git_push_is_dangerous(&command[1..]),
186 "clean" => git_clean_is_force(&command[1..]),
187 opt if opt.starts_with('-') => {
190 if let Some((idx, subcommand)) =
192 find_git_subcommand_from_args(command, &["reset", "rm", "branch", "push", "clean"])
193 {
194 match subcommand {
195 "reset" | "rm" => true,
196 "branch" => git_branch_is_delete(&command[idx + 1..]),
197 "push" => git_push_is_dangerous(&command[idx + 1..]),
198 "clean" => git_clean_is_force(&command[idx + 1..]),
199 _ => false,
200 }
201 } else {
202 false
203 }
204 }
205 _ => false,
206 }
207}
208
209fn find_git_subcommand_from_args<'a>(args: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
211 let mut skip_next = false;
212 for (idx, arg) in args.iter().enumerate() {
213 if skip_next {
214 skip_next = false;
215 continue;
216 }
217
218 let arg = arg.as_str();
219
220 if is_git_global_option_with_inline_value(arg) {
221 continue;
222 }
223
224 if is_git_global_option_with_value(arg) {
225 skip_next = true;
226 continue;
227 }
228
229 if arg == "--" || arg.starts_with('-') {
230 continue;
231 }
232
233 if subcommands.contains(&arg) {
234 return Some((idx, arg));
235 }
236
237 return None;
239 }
240
241 None
242}
243
244fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
246 if command.is_empty() {
247 return false;
248 }
249
250 let cmd0 = command.first().map(String::as_str);
251 let base_cmd = extract_command_name(cmd0.unwrap_or(""));
252
253 match base_cmd {
254 "git" => {
256 let Some((subcommand_idx, subcommand)) =
257 find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
258 else {
259 return false;
260 };
261
262 match subcommand {
263 "reset" | "rm" => true,
264 "branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
265 "push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
266 "clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
267 other => {
268 debug_assert!(false, "unexpected git subcommand from matcher: {other}");
269 false
270 }
271 }
272 }
273
274 "rm" => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf" | "-fr" | "-r")),
276
277 _ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
279 "dd" | "shutdown" | "reboot" | "init" => true,
280
281 _ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
283
284 "sudo" => {
286 if command.len() > 1 {
287 is_dangerous_to_call_with_exec(&command[1..])
288 } else {
289 false
290 }
291 }
292
293 _ => is_dangerous_git_subcommand(command),
295 }
296}
297
298fn extract_command_name(cmd: &str) -> &str {
300 std::path::Path::new(cmd)
301 .file_name()
302 .and_then(|osstr| osstr.to_str())
303 .unwrap_or(cmd)
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn vec_str(args: &[&str]) -> Vec<String> {
311 args.iter().map(|s| s.to_string()).collect()
312 }
313
314 #[test]
315 fn git_reset_is_dangerous() {
316 let cmd = vec!["git".to_string(), "reset".to_string()];
317 assert!(is_dangerous_to_call_with_exec(&cmd));
318 }
319
320 #[test]
321 fn git_reset_hard_is_dangerous() {
322 let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
323 assert!(is_dangerous_to_call_with_exec(&cmd));
324 }
325
326 #[test]
327 fn git_status_is_safe() {
328 let cmd = vec!["git".to_string(), "status".to_string()];
329 assert!(!is_dangerous_to_call_with_exec(&cmd));
330 }
331
332 #[test]
333 fn git_log_is_safe() {
334 let cmd = vec!["git".to_string(), "log".to_string()];
335 assert!(!is_dangerous_to_call_with_exec(&cmd));
336 }
337
338 #[test]
339 fn rm_f_is_dangerous() {
340 let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
341 assert!(is_dangerous_to_call_with_exec(&cmd));
342 }
343
344 #[test]
345 fn rm_rf_is_dangerous() {
346 let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
347 assert!(is_dangerous_to_call_with_exec(&cmd));
348 }
349
350 #[test]
351 fn rm_without_flags_is_safe() {
352 let cmd = vec!["rm".to_string()];
353 assert!(!is_dangerous_to_call_with_exec(&cmd));
354 }
355
356 #[test]
357 fn mkfs_is_dangerous() {
358 let cmd = vec!["mkfs".to_string()];
359 assert!(is_dangerous_to_call_with_exec(&cmd));
360 }
361
362 #[test]
363 fn mkfs_variants_are_dangerous() {
364 let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
365 assert!(is_dangerous_to_call_with_exec(&cmd));
366 }
367
368 #[test]
369 fn dd_is_dangerous() {
370 let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
371 assert!(is_dangerous_to_call_with_exec(&cmd));
372 }
373
374 #[test]
375 fn shutdown_is_dangerous() {
376 let cmd = vec!["shutdown".to_string()];
377 assert!(is_dangerous_to_call_with_exec(&cmd));
378 }
379
380 #[test]
381 fn sudo_git_reset_is_dangerous() {
382 let cmd = vec![
383 "sudo".to_string(),
384 "git".to_string(),
385 "reset".to_string(),
386 "--hard".to_string(),
387 ];
388 assert!(is_dangerous_to_call_with_exec(&cmd));
389 }
390
391 #[test]
392 fn sudo_git_status_is_safe() {
393 let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
394 assert!(!is_dangerous_to_call_with_exec(&cmd));
395 }
396
397 #[test]
398 fn absolute_path_git_reset_is_dangerous() {
399 let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string()];
400 assert!(is_dangerous_to_call_with_exec(&cmd));
401 }
402
403 #[test]
404 fn empty_command_is_safe() {
405 let cmd: Vec<String> = vec![];
406 assert!(!is_dangerous_to_call_with_exec(&cmd));
407 }
408
409 #[test]
410 fn command_might_be_dangerous_detects_git_reset() {
411 let cmd = vec!["git".to_string(), "reset".to_string()];
412 assert!(command_might_be_dangerous(&cmd));
413 }
414
415 #[test]
416 fn command_might_be_dangerous_allows_git_status() {
417 let cmd = vec!["git".to_string(), "status".to_string()];
418 assert!(!command_might_be_dangerous(&cmd));
419 }
420
421 #[test]
424 fn git_branch_delete_is_dangerous() {
425 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-d", "feature",])));
426 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-D", "feature",])));
427 let script = "git branch --delete feature";
429 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
430 for sub_cmd in sub_commands {
431 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
432 }
433 }
434 }
435
436 #[test]
437 fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
438 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-dv", "feature",])));
439 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vd", "feature",])));
440 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vD", "feature",])));
441 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-Dvv", "feature",])));
442 }
443
444 #[test]
445 fn git_branch_delete_with_global_options_is_dangerous() {
446 assert!(command_might_be_dangerous(&vec_str(&["git", "-C", ".", "branch", "-d", "feature",])));
447 assert!(command_might_be_dangerous(&vec_str(&["git", "-c", "color.ui=false", "branch", "-D", "feature",])));
448 let script = "git -C . branch -d feature";
450 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
451 for sub_cmd in sub_commands {
452 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
453 }
454 }
455 }
456
457 #[test]
458 fn git_checkout_reset_is_not_dangerous() {
459 assert!(!command_might_be_dangerous(&vec_str(&["git", "checkout", "reset",])));
462 }
463
464 #[test]
467 fn git_push_force_is_dangerous() {
468 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--force", "origin", "main",])));
469 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-f", "origin", "main",])));
470 assert!(command_might_be_dangerous(&vec_str(&[
471 "git",
472 "-C",
473 ".",
474 "push",
475 "--force-with-lease",
476 "origin",
477 "main",
478 ])));
479 }
480
481 #[test]
482 fn git_push_plus_refspec_is_dangerous() {
483 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", "+main",])));
484 assert!(command_might_be_dangerous(&vec_str(
485 &["git", "push", "origin", "+refs/heads/main:refs/heads/main",]
486 )));
487 }
488
489 #[test]
490 fn git_push_delete_flag_is_dangerous() {
491 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--delete", "origin", "feature",])));
492 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-d", "origin", "feature",])));
493 }
494
495 #[test]
496 fn git_push_delete_refspec_is_dangerous() {
497 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", ":feature",])));
498 let script = "git push origin :feature";
500 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
501 for sub_cmd in sub_commands {
502 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
503 }
504 }
505 }
506
507 #[test]
508 fn git_push_without_force_is_not_dangerous() {
509 assert!(!command_might_be_dangerous(&vec_str(&["git", "push", "origin", "main",])));
510 }
511
512 #[test]
515 fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
516 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-fdx",])));
517 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-xdf",])));
518 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "--force",])));
519 }
520}