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