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 {
35 for sub_cmd in sub_commands {
36 if command_might_be_dangerous(&sub_cmd) {
37 return true;
38 }
39 }
40 }
41 }
42
43 false
44}
45
46fn is_git_global_option_with_value(arg: &str) -> bool {
48 matches!(
49 arg,
50 "-C" | "-c"
51 | "--config-env"
52 | "--exec-path"
53 | "--git-dir"
54 | "--namespace"
55 | "--super-prefix"
56 | "--work-tree"
57 )
58}
59
60fn is_git_global_option_with_inline_value(arg: &str) -> bool {
62 matches!(
63 arg,
64 s if s.starts_with("--config-env=")
65 || s.starts_with("--exec-path=")
66 || s.starts_with("--git-dir=")
67 || s.starts_with("--namespace=")
68 || s.starts_with("--super-prefix=")
69 || s.starts_with("--work-tree=")
70 ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
71}
72
73pub(crate) fn git_global_option_requires_prompt(arg: &str) -> bool {
76 matches!(
77 arg,
78 "-c" | "--config-env"
79 | "--exec-path"
80 | "--git-dir"
81 | "--namespace"
82 | "--super-prefix"
83 | "--work-tree"
84 ) || matches!(
85 arg,
86 s if (s.starts_with("-c") && s.len() > 2)
87 || s.starts_with("--config-env=")
88 || s.starts_with("--exec-path=")
89 || s.starts_with("--git-dir=")
90 || s.starts_with("--namespace=")
91 || s.starts_with("--super-prefix=")
92 || s.starts_with("--work-tree=")
93 )
94}
95
96pub(crate) fn find_git_subcommand<'a>(
101 command: &'a [String],
102 subcommands: &[&str],
103) -> Option<(usize, &'a str)> {
104 let cmd0 = command.first().map(String::as_str)?;
105 if !cmd0.ends_with("git") {
106 return None;
107 }
108
109 let mut skip_next = false;
110 for (idx, arg) in command.iter().enumerate().skip(1) {
111 if skip_next {
112 skip_next = false;
113 continue;
114 }
115
116 let arg = arg.as_str();
117
118 if is_git_global_option_with_inline_value(arg) {
119 continue;
120 }
121
122 if is_git_global_option_with_value(arg) {
123 skip_next = true;
124 continue;
125 }
126
127 if arg == "--" || arg.starts_with('-') {
128 continue;
129 }
130
131 if subcommands.contains(&arg) {
132 return Some((idx, arg));
133 }
134
135 return None;
139 }
140
141 None
142}
143
144fn short_flag_group_contains(arg: &str, target: char) -> bool {
146 arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
147}
148
149fn git_branch_is_delete(branch_args: &[String]) -> bool {
151 branch_args.iter().map(String::as_str).any(|arg| {
154 matches!(arg, "-d" | "-D" | "--delete")
155 || arg.starts_with("--delete=")
156 || short_flag_group_contains(arg, 'd')
157 || short_flag_group_contains(arg, 'D')
158 })
159}
160
161fn git_push_is_dangerous(push_args: &[String]) -> bool {
163 push_args.iter().map(String::as_str).any(|arg| {
164 matches!(
165 arg,
166 "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d"
167 ) || arg.starts_with("--force-with-lease=")
168 || arg.starts_with("--force-if-includes=")
169 || arg.starts_with("--delete=")
170 || short_flag_group_contains(arg, 'f')
171 || short_flag_group_contains(arg, 'd')
172 || git_push_refspec_is_dangerous(arg)
173 })
174}
175
176fn git_push_refspec_is_dangerous(arg: &str) -> bool {
178 (arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
180}
181
182fn git_clean_is_force(clean_args: &[String]) -> bool {
184 clean_args.iter().map(String::as_str).any(|arg| {
185 matches!(arg, "--force" | "-f")
186 || arg.starts_with("--force=")
187 || short_flag_group_contains(arg, 'f')
188 })
189}
190
191fn is_dangerous_git_subcommand(command: &[String]) -> bool {
194 if command.is_empty() {
195 return false;
196 }
197
198 let first_arg = command[0].as_str();
199
200 match first_arg {
202 "reset" | "rm" => true,
203 "branch" => git_branch_is_delete(&command[1..]),
204 "push" => git_push_is_dangerous(&command[1..]),
205 "clean" => git_clean_is_force(&command[1..]),
206 opt if opt.starts_with('-') => {
209 if let Some((idx, subcommand)) =
211 find_git_subcommand_from_args(command, &["reset", "rm", "branch", "push", "clean"])
212 {
213 match subcommand {
214 "reset" | "rm" => true,
215 "branch" => git_branch_is_delete(&command[idx + 1..]),
216 "push" => git_push_is_dangerous(&command[idx + 1..]),
217 "clean" => git_clean_is_force(&command[idx + 1..]),
218 _ => false,
219 }
220 } else {
221 false
222 }
223 }
224 _ => false,
225 }
226}
227
228fn find_git_subcommand_from_args<'a>(
230 args: &'a [String],
231 subcommands: &[&str],
232) -> Option<(usize, &'a str)> {
233 let mut skip_next = false;
234 for (idx, arg) in args.iter().enumerate() {
235 if skip_next {
236 skip_next = false;
237 continue;
238 }
239
240 let arg = arg.as_str();
241
242 if is_git_global_option_with_inline_value(arg) {
243 continue;
244 }
245
246 if is_git_global_option_with_value(arg) {
247 skip_next = true;
248 continue;
249 }
250
251 if arg == "--" || arg.starts_with('-') {
252 continue;
253 }
254
255 if subcommands.contains(&arg) {
256 return Some((idx, arg));
257 }
258
259 return None;
261 }
262
263 None
264}
265
266fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
268 if command.is_empty() {
269 return false;
270 }
271
272 let cmd0 = command.first().map(String::as_str);
273 let base_cmd = extract_command_name(cmd0.unwrap_or(""));
274
275 match base_cmd {
276 "git" => {
278 let Some((subcommand_idx, subcommand)) =
279 find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
280 else {
281 return false;
282 };
283
284 match subcommand {
285 "reset" | "rm" => true,
286 "branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
287 "push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
288 "clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
289 other => {
290 debug_assert!(false, "unexpected git subcommand from matcher: {other}");
291 false
292 }
293 }
294 }
295
296 "rm" => matches!(
298 command.get(1).map(String::as_str),
299 Some("-f" | "-rf" | "-fr" | "-r")
300 ),
301
302 _ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
304 "dd" | "shutdown" | "reboot" | "init" => true,
305
306 _ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
308
309 "sudo" => {
311 if command.len() > 1 {
312 is_dangerous_to_call_with_exec(&command[1..])
313 } else {
314 false
315 }
316 }
317
318 _ => is_dangerous_git_subcommand(command),
320 }
321}
322
323fn extract_command_name(cmd: &str) -> &str {
325 std::path::Path::new(cmd)
326 .file_name()
327 .and_then(|osstr| osstr.to_str())
328 .unwrap_or(cmd)
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn vec_str(args: &[&str]) -> Vec<String> {
336 args.iter().map(|s| s.to_string()).collect()
337 }
338
339 #[test]
340 fn git_reset_is_dangerous() {
341 let cmd = vec!["git".to_string(), "reset".to_string()];
342 assert!(is_dangerous_to_call_with_exec(&cmd));
343 }
344
345 #[test]
346 fn git_reset_hard_is_dangerous() {
347 let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
348 assert!(is_dangerous_to_call_with_exec(&cmd));
349 }
350
351 #[test]
352 fn git_status_is_safe() {
353 let cmd = vec!["git".to_string(), "status".to_string()];
354 assert!(!is_dangerous_to_call_with_exec(&cmd));
355 }
356
357 #[test]
358 fn git_log_is_safe() {
359 let cmd = vec!["git".to_string(), "log".to_string()];
360 assert!(!is_dangerous_to_call_with_exec(&cmd));
361 }
362
363 #[test]
364 fn rm_f_is_dangerous() {
365 let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
366 assert!(is_dangerous_to_call_with_exec(&cmd));
367 }
368
369 #[test]
370 fn rm_rf_is_dangerous() {
371 let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
372 assert!(is_dangerous_to_call_with_exec(&cmd));
373 }
374
375 #[test]
376 fn rm_without_flags_is_safe() {
377 let cmd = vec!["rm".to_string()];
378 assert!(!is_dangerous_to_call_with_exec(&cmd));
379 }
380
381 #[test]
382 fn mkfs_is_dangerous() {
383 let cmd = vec!["mkfs".to_string()];
384 assert!(is_dangerous_to_call_with_exec(&cmd));
385 }
386
387 #[test]
388 fn mkfs_variants_are_dangerous() {
389 let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
390 assert!(is_dangerous_to_call_with_exec(&cmd));
391 }
392
393 #[test]
394 fn dd_is_dangerous() {
395 let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
396 assert!(is_dangerous_to_call_with_exec(&cmd));
397 }
398
399 #[test]
400 fn shutdown_is_dangerous() {
401 let cmd = vec!["shutdown".to_string()];
402 assert!(is_dangerous_to_call_with_exec(&cmd));
403 }
404
405 #[test]
406 fn sudo_git_reset_is_dangerous() {
407 let cmd = vec![
408 "sudo".to_string(),
409 "git".to_string(),
410 "reset".to_string(),
411 "--hard".to_string(),
412 ];
413 assert!(is_dangerous_to_call_with_exec(&cmd));
414 }
415
416 #[test]
417 fn sudo_git_status_is_safe() {
418 let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
419 assert!(!is_dangerous_to_call_with_exec(&cmd));
420 }
421
422 #[test]
423 fn absolute_path_git_reset_is_dangerous() {
424 let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string()];
425 assert!(is_dangerous_to_call_with_exec(&cmd));
426 }
427
428 #[test]
429 fn empty_command_is_safe() {
430 let cmd: Vec<String> = vec![];
431 assert!(!is_dangerous_to_call_with_exec(&cmd));
432 }
433
434 #[test]
435 fn command_might_be_dangerous_detects_git_reset() {
436 let cmd = vec!["git".to_string(), "reset".to_string()];
437 assert!(command_might_be_dangerous(&cmd));
438 }
439
440 #[test]
441 fn command_might_be_dangerous_allows_git_status() {
442 let cmd = vec!["git".to_string(), "status".to_string()];
443 assert!(!command_might_be_dangerous(&cmd));
444 }
445
446 #[test]
449 fn git_branch_delete_is_dangerous() {
450 assert!(command_might_be_dangerous(&vec_str(&[
451 "git", "branch", "-d", "feature",
452 ])));
453 assert!(command_might_be_dangerous(&vec_str(&[
454 "git", "branch", "-D", "feature",
455 ])));
456 let script = "git branch --delete feature";
458 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script)
459 {
460 for sub_cmd in sub_commands {
461 assert!(
462 command_might_be_dangerous(&sub_cmd),
463 "sub-command should be dangerous: {:?}",
464 sub_cmd
465 );
466 }
467 }
468 }
469
470 #[test]
471 fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
472 assert!(command_might_be_dangerous(&vec_str(&[
473 "git", "branch", "-dv", "feature",
474 ])));
475 assert!(command_might_be_dangerous(&vec_str(&[
476 "git", "branch", "-vd", "feature",
477 ])));
478 assert!(command_might_be_dangerous(&vec_str(&[
479 "git", "branch", "-vD", "feature",
480 ])));
481 assert!(command_might_be_dangerous(&vec_str(&[
482 "git", "branch", "-Dvv", "feature",
483 ])));
484 }
485
486 #[test]
487 fn git_branch_delete_with_global_options_is_dangerous() {
488 assert!(command_might_be_dangerous(&vec_str(&[
489 "git", "-C", ".", "branch", "-d", "feature",
490 ])));
491 assert!(command_might_be_dangerous(&vec_str(&[
492 "git",
493 "-c",
494 "color.ui=false",
495 "branch",
496 "-D",
497 "feature",
498 ])));
499 let script = "git -C . branch -d feature";
501 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script)
502 {
503 for sub_cmd in sub_commands {
504 assert!(
505 command_might_be_dangerous(&sub_cmd),
506 "sub-command should be dangerous: {:?}",
507 sub_cmd
508 );
509 }
510 }
511 }
512
513 #[test]
514 fn git_checkout_reset_is_not_dangerous() {
515 assert!(!command_might_be_dangerous(&vec_str(&[
518 "git", "checkout", "reset",
519 ])));
520 }
521
522 #[test]
525 fn git_push_force_is_dangerous() {
526 assert!(command_might_be_dangerous(&vec_str(&[
527 "git", "push", "--force", "origin", "main",
528 ])));
529 assert!(command_might_be_dangerous(&vec_str(&[
530 "git", "push", "-f", "origin", "main",
531 ])));
532 assert!(command_might_be_dangerous(&vec_str(&[
533 "git",
534 "-C",
535 ".",
536 "push",
537 "--force-with-lease",
538 "origin",
539 "main",
540 ])));
541 }
542
543 #[test]
544 fn git_push_plus_refspec_is_dangerous() {
545 assert!(command_might_be_dangerous(&vec_str(&[
546 "git", "push", "origin", "+main",
547 ])));
548 assert!(command_might_be_dangerous(&vec_str(&[
549 "git",
550 "push",
551 "origin",
552 "+refs/heads/main:refs/heads/main",
553 ])));
554 }
555
556 #[test]
557 fn git_push_delete_flag_is_dangerous() {
558 assert!(command_might_be_dangerous(&vec_str(&[
559 "git", "push", "--delete", "origin", "feature",
560 ])));
561 assert!(command_might_be_dangerous(&vec_str(&[
562 "git", "push", "-d", "origin", "feature",
563 ])));
564 }
565
566 #[test]
567 fn git_push_delete_refspec_is_dangerous() {
568 assert!(command_might_be_dangerous(&vec_str(&[
569 "git", "push", "origin", ":feature",
570 ])));
571 let script = "git push origin :feature";
573 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script)
574 {
575 for sub_cmd in sub_commands {
576 assert!(
577 command_might_be_dangerous(&sub_cmd),
578 "sub-command should be dangerous: {:?}",
579 sub_cmd
580 );
581 }
582 }
583 }
584
585 #[test]
586 fn git_push_without_force_is_not_dangerous() {
587 assert!(!command_might_be_dangerous(&vec_str(&[
588 "git", "push", "origin", "main",
589 ])));
590 }
591
592 #[test]
595 fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
596 assert!(command_might_be_dangerous(&vec_str(&[
597 "git", "clean", "-fdx",
598 ])));
599 assert!(command_might_be_dangerous(&vec_str(&[
600 "git", "clean", "-xdf",
601 ])));
602 assert!(command_might_be_dangerous(&vec_str(&[
603 "git", "clean", "--force",
604 ])));
605 }
606}