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