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