Skip to main content

vtcode_safety/command_safety/
rm_approval.rs

1//! Context-aware approval checks for recursive `rm` commands.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5#[cfg(any(
6    target_os = "macos",
7    target_os = "freebsd",
8    target_os = "openbsd",
9    target_os = "netbsd",
10    target_os = "dragonfly"
11))]
12use std::process::Command;
13
14use super::dangerous_commands::{unwrap_command_prefix, unwrap_command_prefix_with_context};
15
16/// One recursive-`rm` operand and the directory it resolves against.
17///
18/// `base` is `None` when the directory context is unknown. An unknown base
19/// keeps the operand unresolved, so it stays reviewable instead of silently
20/// resolving against the wrong directory.
21#[derive(Debug, PartialEq, Eq)]
22struct RmTarget {
23    operand: String,
24    base: Option<PathBuf>,
25}
26
27/// Returns whether a recursive `rm` command requires approval based on its
28/// target and the command working directory.
29///
30/// Relative targets are resolved from `working_directory`. A mounted root,
31/// its immediate children, and known mount-layout roots require approval. If
32/// mount data or target resolution is unavailable, the function fails closed.
33/// Shell or wrapper directory changes make relative target resolution uncertain and
34/// therefore require approval for recursive deletes.
35///
36/// `command` is the requested argv. `script` is the shell script the command
37/// executes: a compound command such as `cd /Volumes/Backup && rm --recursive
38/// Photos` keeps its structure only in the script text, so both views are
39/// analyzed and a non-`rm` first token cannot hide a recursive delete.
40pub fn recursive_rm_targets_require_approval_in_directory(
41    command: &[String],
42    script: Option<&str>,
43    working_directory: &Path,
44) -> bool {
45    let targets = recursive_rm_targets(command, script, Some(working_directory));
46    if targets.is_empty() {
47        return false;
48    }
49    let Some(mount_points) = mounted_filesystem_mount_points() else {
50        // Without a mount table, recursive deletes cannot be proven to avoid
51        // mounted filesystems. Keep the decision on the approval path.
52        return true;
53    };
54
55    rm_targets_are_sensitive(&targets, &normalized_mount_points(&mount_points))
56}
57
58/// Collect every recursive-`rm` operand in a command and in the shell script
59/// the command executes.
60fn recursive_rm_targets(command: &[String], script: Option<&str>, base: Option<&Path>) -> Vec<RmTarget> {
61    let mut targets = Vec::new();
62    collect_argv_rm_targets(command, base, &mut targets);
63    if let Some(script) = script
64        && script_mentions_rm(script)
65    {
66        collect_script_rm_targets(script, base, &mut targets);
67    }
68    targets
69}
70
71/// Cheap pre-filter: a script without the `rm` token cannot delete through
72/// `rm`, so it never needs the grammar pass.
73fn script_mentions_rm(script: &str) -> bool {
74    script.contains("rm")
75}
76
77/// Collect recursive-`rm` operands from one argv vector.
78fn collect_argv_rm_targets(command: &[String], base: Option<&Path>, targets: &mut Vec<RmTarget>) {
79    let Some(unwrapped) = unwrap_command_prefix_with_context(command) else {
80        return;
81    };
82    // A wrapper that changes the directory invalidates the relative base.
83    let base = base.filter(|_| !unwrapped.changes_directory);
84    let command = unwrapped.command;
85
86    if let Some(script) = shell_script_argument(command) {
87        collect_script_rm_targets(script, base, targets);
88        return;
89    }
90
91    let Some(executable) = command.first() else {
92        return;
93    };
94    let executable = Path::new(executable)
95        .file_name()
96        .and_then(|name| name.to_str())
97        .unwrap_or(executable);
98    if executable != "rm" {
99        return;
100    }
101
102    let Some(arguments) = command.get(1..) else {
103        return;
104    };
105    let parsed = parse_rm_arguments(arguments);
106    if !parsed.recursive {
107        return;
108    }
109    for operand in parsed.targets {
110        targets.push(RmTarget {
111            operand: operand.to_owned(),
112            base: base.map(Path::to_path_buf),
113        });
114    }
115}
116
117/// Extract the script argument of an explicit shell invocation.
118fn shell_script_argument(command: &[String]) -> Option<&str> {
119    let executable = command.first()?;
120    let executable = Path::new(executable)
121        .file_name()
122        .and_then(|name| name.to_str())
123        .unwrap_or(executable);
124    if !matches!(executable, "bash" | "sh" | "zsh") {
125        return None;
126    }
127    let flag = command.get(1)?;
128    if !matches!(flag.as_str(), "-c" | "-lc" | "-ilc") {
129        return None;
130    }
131    command.get(2).map(String::as_str)
132}
133
134/// Collect recursive-`rm` operands from a shell script.
135fn collect_script_rm_targets(script: &str, base: Option<&Path>, targets: &mut Vec<RmTarget>) {
136    let Some(commands) = parse_rm_shell_commands(script) else {
137        // A malformed script cannot be proven to delete only ordinary paths.
138        // Keep the delete reviewable instead of failing open.
139        targets.push(RmTarget { operand: String::new(), base: None });
140        return;
141    };
142    // Flattened commands do not retain control flow or subshell scope, so a
143    // directory change anywhere in the script invalidates the shared base.
144    let base = base.filter(|_| !commands.iter().any(|nested| changes_shell_directory(nested)));
145    for nested in &commands {
146        collect_argv_rm_targets(nested, base, targets);
147    }
148}
149
150/// Whether any collected operand can reach a mounted filesystem.
151fn rm_targets_are_sensitive(targets: &[RmTarget], mount_points: &[PathBuf]) -> bool {
152    targets.iter().any(|target| rm_target_is_sensitive(target, mount_points))
153}
154
155fn rm_target_is_sensitive(target: &RmTarget, mount_points: &[PathBuf]) -> bool {
156    let Some(resolved) = resolve_target_path(&target.operand, target.base.as_deref()) else {
157        // A shell expansion or inaccessible working directory can hide a
158        // mounted target, so unresolved recursive targets require review.
159        return true;
160    };
161
162    is_known_mount_layout_target(&resolved)
163        || mount_points
164            .iter()
165            .any(|mount_point| is_mount_root_or_top_level_target(&resolved, mount_point))
166}
167
168/// Canonicalize mount points so target comparison ignores symlinked paths.
169fn normalized_mount_points(mount_points: &[PathBuf]) -> Vec<PathBuf> {
170    mount_points
171        .iter()
172        .map(|mount_point| {
173            vtcode_commons::paths::canonicalize(mount_point)
174                .unwrap_or_else(|_| vtcode_commons::paths::normalize_path(mount_point))
175        })
176        .collect()
177}
178
179#[derive(Debug, Default, PartialEq, Eq)]
180struct RmArguments<'a> {
181    recursive: bool,
182    targets: Vec<&'a str>,
183}
184
185fn parse_rm_arguments(arguments: &[String]) -> RmArguments<'_> {
186    let mut parsed = RmArguments::default();
187    let mut end_of_options = false;
188
189    for argument in arguments {
190        if end_of_options {
191            parsed.targets.push(argument);
192            continue;
193        }
194
195        // Treat `--` as the end of option parsing, matching Codex's pinned
196        // `rm_args_include_force_option` convention in
197        // `codex-rs/shell-command/src/command_safety/is_dangerous_command.rs`
198        // (commit `f29d55ff0697a88226e8a9811b3c007e5f719cba`).
199        if argument == "--" {
200            end_of_options = true;
201        } else if argument == "--recursive" {
202            parsed.recursive = true;
203        } else if argument.starts_with("--") {
204            // `rm` has no long option that consumes a separate argument.
205        } else if let Some(flags) = argument
206            .strip_prefix('-')
207            .filter(|flags| !flags.is_empty() && !flags.starts_with('-'))
208        {
209            parsed.recursive |= flags.chars().any(|flag| matches!(flag, 'r' | 'R'));
210        } else {
211            parsed.targets.push(argument);
212        }
213    }
214
215    parsed
216}
217
218fn parse_rm_shell_commands(script: &str) -> Option<Vec<Vec<String>>> {
219    // AST extraction preserves source quotes. Decode each word once at this
220    // boundary so nested shell scripts and quoted options retain argv semantics.
221    super::shell_parser::parse_shell_commands_tree_sitter(script)
222        .ok()?
223        .into_iter()
224        .map(|command| {
225            command
226                .into_iter()
227                .map(|word| {
228                    // shell_words does not interpret Bash ANSI-C quoting.
229                    if word.contains("$'") {
230                        return None;
231                    }
232                    let mut words = shell_words::split(&word).ok()?;
233                    if words.len() == 1 { words.pop() } else { None }
234                })
235                .collect()
236        })
237        .collect()
238}
239
240/// Whether a command changes the shell's working directory.
241///
242/// The shared prefix unwrapping already resolves runner and builtin prefixes,
243/// so the executable that decides the directory change is the first word.
244fn changes_shell_directory(command: &[String]) -> bool {
245    let Some(command) = unwrap_command_prefix(command) else {
246        return false;
247    };
248    matches!(command.first().map(String::as_str), Some("cd" | "pushd" | "popd"))
249}
250
251fn resolve_target_path(target: &str, working_directory: Option<&Path>) -> Option<PathBuf> {
252    if target
253        .chars()
254        .any(|character| matches!(character, '$' | '`' | '*' | '?' | '[' | ']' | '{' | '}' | '~'))
255    {
256        return None;
257    }
258
259    let target_path = Path::new(target);
260    let candidate = if target_path.is_absolute() {
261        target_path.to_path_buf()
262    } else {
263        let working_directory = working_directory?;
264        let working_directory = if working_directory.is_absolute() {
265            working_directory.to_path_buf()
266        } else {
267            std::env::current_dir().ok()?.join(working_directory)
268        };
269        working_directory.join(target_path)
270    };
271
272    canonicalize_allowing_missing(&candidate)
273}
274
275fn canonicalize_allowing_missing(path: &Path) -> Option<PathBuf> {
276    let mut ancestor = path;
277    let mut missing_components = Vec::new();
278
279    loop {
280        match vtcode_commons::paths::canonicalize(ancestor) {
281            Ok(mut resolved) => {
282                for component in missing_components.iter().rev() {
283                    resolved.push(component);
284                }
285                return Some(vtcode_commons::paths::normalize_path(&resolved));
286            }
287            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
288                missing_components.push(ancestor.file_name()?.to_os_string());
289                ancestor = ancestor.parent()?;
290            }
291            Err(_) => return None,
292        }
293    }
294}
295
296fn is_known_mount_layout_target(target: &Path) -> bool {
297    path_is_within_depth(target, Path::new("/Volumes"), 2)
298        || path_is_within_depth(target, Path::new("/media"), 3)
299        || is_wsl_drive_layout_target(target)
300}
301
302fn path_is_within_depth(target: &Path, root: &Path, max_relative_depth: usize) -> bool {
303    target
304        .strip_prefix(root)
305        .is_ok_and(|relative| relative.components().count() <= max_relative_depth)
306}
307
308fn is_wsl_drive_layout_target(target: &Path) -> bool {
309    let root = Path::new("/mnt");
310    if target == root {
311        return true;
312    }
313
314    let Ok(relative) = target.strip_prefix(root) else {
315        return false;
316    };
317    let mut components = relative.components();
318    let Some(std::path::Component::Normal(drive)) = components.next() else {
319        return false;
320    };
321    let Some(drive) = drive.to_str() else {
322        return false;
323    };
324    drive.len() == 1 && drive.bytes().next().is_some_and(|byte| byte.is_ascii_alphabetic()) && components.count() <= 1
325}
326
327fn is_mount_root_or_top_level_target(target: &Path, mount_point: &Path) -> bool {
328    target == mount_point
329        || target.parent() == Some(mount_point)
330        // Removing an ancestor recursively would traverse into the mount.
331        || mount_point.starts_with(target)
332}
333
334#[cfg(target_os = "linux")]
335fn mounted_filesystem_mount_points() -> Option<Vec<PathBuf>> {
336    parse_linux_mount_info(&fs::read_to_string("/proc/self/mountinfo").ok()?)
337}
338
339#[cfg(any(
340    target_os = "macos",
341    target_os = "freebsd",
342    target_os = "openbsd",
343    target_os = "netbsd",
344    target_os = "dragonfly"
345))]
346fn mounted_filesystem_mount_points() -> Option<Vec<PathBuf>> {
347    let output = Command::new("/sbin/mount").output().ok()?;
348    if !output.status.success() {
349        return None;
350    }
351    parse_bsd_mount_output(&String::from_utf8(output.stdout).ok()?)
352}
353
354#[cfg(not(any(
355    target_os = "linux",
356    target_os = "macos",
357    target_os = "freebsd",
358    target_os = "openbsd",
359    target_os = "netbsd",
360    target_os = "dragonfly"
361)))]
362fn mounted_filesystem_mount_points() -> Option<Vec<PathBuf>> {
363    None
364}
365
366#[cfg(any(target_os = "linux", test))]
367fn parse_linux_mount_info(contents: &str) -> Option<Vec<PathBuf>> {
368    parse_mount_points(contents.lines().map(|line| line.split_whitespace().nth(4)))
369}
370
371#[cfg(any(
372    target_os = "macos",
373    target_os = "freebsd",
374    target_os = "openbsd",
375    target_os = "netbsd",
376    target_os = "dragonfly",
377    test
378))]
379fn parse_bsd_mount_output(contents: &str) -> Option<Vec<PathBuf>> {
380    parse_mount_points(contents.lines().map(|line| {
381        let (_, mount_description) = line.split_once(" on ")?;
382        let (mount_point, _) = mount_description.rsplit_once(" (")?;
383        Some(mount_point)
384    }))
385}
386
387#[cfg(any(
388    target_os = "linux",
389    target_os = "macos",
390    target_os = "freebsd",
391    target_os = "openbsd",
392    target_os = "netbsd",
393    target_os = "dragonfly",
394    test
395))]
396fn parse_mount_points<'a>(fields: impl Iterator<Item = Option<&'a str>>) -> Option<Vec<PathBuf>> {
397    let mount_points: Option<Vec<PathBuf>> = fields
398        .map(|field| {
399            let decoded = decode_mount_path(field?)?;
400            let path = PathBuf::from(decoded);
401            path.is_absolute().then_some(path)
402        })
403        .collect();
404    let mount_points = mount_points?;
405    (!mount_points.is_empty()).then_some(mount_points)
406}
407
408#[cfg(any(
409    target_os = "linux",
410    target_os = "macos",
411    target_os = "freebsd",
412    target_os = "openbsd",
413    target_os = "netbsd",
414    target_os = "dragonfly",
415    test
416))]
417fn decode_mount_path(encoded: &str) -> Option<String> {
418    let decoded = encoded
419        .replace("\\040", " ")
420        .replace("\\011", "\t")
421        .replace("\\012", "\n")
422        .replace("\\134", "\\");
423    if decoded.is_empty() {
424        return None;
425    }
426    Some(decoded)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn command(args: &[&str]) -> Vec<String> {
434        args.iter().map(|argument| (*argument).to_owned()).collect()
435    }
436
437    /// Approval decision with injected mount points, mirroring the production
438    /// path without touching the host mount table.
439    fn approval_with_mount_points(
440        command: &[String],
441        script: Option<&str>,
442        working_directory: &Path,
443        mount_points: Option<&[PathBuf]>,
444    ) -> bool {
445        let targets = recursive_rm_targets(command, script, Some(working_directory));
446        if targets.is_empty() {
447            return false;
448        }
449        let Some(mount_points) = mount_points else {
450            return true;
451        };
452        rm_targets_are_sensitive(&targets, &normalized_mount_points(mount_points))
453    }
454
455    /// Sensitivity of a command's recursive-`rm` targets against injected
456    /// mount points.
457    fn recursive_rm_targets_are_sensitive(
458        command: &[String],
459        working_directory: &Path,
460        mount_points: &[PathBuf],
461    ) -> bool {
462        let targets = recursive_rm_targets(command, None, Some(working_directory));
463        rm_targets_are_sensitive(&targets, &normalized_mount_points(mount_points))
464    }
465
466    #[test]
467    fn rm_parser_reads_recursive_options_and_end_of_options_targets() {
468        for args in [
469            command(&["-r", "/mnt/c"]),
470            command(&["-rf", "/mnt/c"]),
471            command(&["-fiR", "/mnt/c"]),
472            command(&["--recursive", "/mnt/c"]),
473        ] {
474            let parsed = parse_rm_arguments(&args);
475            assert!(parsed.recursive, "expected recursive option: {args:?}");
476            assert_eq!(parsed.targets, ["/mnt/c"]);
477        }
478
479        let args = command(&["--", "-r", "/mnt/c"]);
480        let after_separator = parse_rm_arguments(&args);
481        assert!(!after_separator.recursive);
482        assert_eq!(after_separator.targets, ["-r", "/mnt/c"]);
483
484        let args = command(&["-fiR", "--", "-target", "/mnt/c"]);
485        let parsed = parse_rm_arguments(&args);
486        assert!(parsed.recursive);
487        assert_eq!(parsed.targets, ["-target", "/mnt/c"]);
488    }
489
490    #[test]
491    fn recursive_rm_prompts_for_mount_roots_and_immediate_children() {
492        let mounts = [PathBuf::from("/"), PathBuf::from("/custom/archive")];
493        for target in [
494            "/Applications",
495            "/Volumes",
496            "/Volumes/Backup",
497            "/Volumes/Backup/Photos",
498            "/media",
499            "/media/user/USB/Photos",
500            "/mnt/c",
501            "/mnt/c/Users",
502            "/custom/archive",
503            "/custom/archive/Photos",
504        ] {
505            assert!(
506                recursive_rm_targets_are_sensitive(
507                    &command(&["rm", "--recursive", target]),
508                    Path::new("/tmp"),
509                    &mounts
510                ),
511                "expected prompt for {target}"
512            );
513        }
514
515        for invocation in [
516            command(&["rm", "-r", "/Volumes"]),
517            command(&["rm", "-rf", "/Volumes/Backup"]),
518            command(&["rm", "-fiR", "/media/user/USB/Photos"]),
519            command(&["rm", "--recursive", "/mnt/c/Users"]),
520            command(&["rm", "-r", "--", "/custom/archive/Photos"]),
521            command(&["bash", "-lc", "rm --recursive /custom/archive/Photos"]),
522        ] {
523            assert!(
524                recursive_rm_targets_are_sensitive(&invocation, Path::new("/tmp"), &mounts),
525                "expected prompt for {invocation:?}"
526            );
527        }
528
529        for target in [
530            "/Volumes/Backup/Photos/2026",
531            "/custom/archive/Photos/2026",
532            "/tmp/project/target",
533        ] {
534            assert!(
535                !recursive_rm_targets_are_sensitive(&command(&["rm", "-r", target]), Path::new("/tmp"), &mounts),
536                "ordinary nested target should not be newly classified: {target}"
537            );
538        }
539    }
540
541    #[test]
542    fn recursive_rm_resolves_relative_targets_from_working_directory() {
543        let temp_dir = tempfile::tempdir().expect("temporary directory");
544        let mount_point = temp_dir.path().join("drive");
545        fs::create_dir_all(mount_point.join("Projects")).expect("create mount fixture");
546        let mounts = [mount_point.clone()];
547
548        assert!(recursive_rm_targets_are_sensitive(&command(&["rm", "-rf", "Projects"]), &mount_point, &mounts));
549        assert!(recursive_rm_targets_are_sensitive(&command(&["rm", "-r", "--", "Projects"]), &mount_point, &mounts));
550        assert!(!recursive_rm_targets_are_sensitive(
551            &command(&["rm", "--", "-r", "Projects"]),
552            &mount_point,
553            &mounts
554        ));
555    }
556
557    #[test]
558    fn recursive_rm_after_shell_directory_changes_requires_approval() {
559        let project = tempfile::tempdir().expect("project directory");
560        let mounts = [PathBuf::from("/Volumes/Backup")];
561
562        for script in [
563            "cd /Volumes/Backup && rm --recursive Photos",
564            "cd /Volumes/Backup && rm --recursive Photos &&",
565            "'cd' /Volumes/Backup && 'rm' '--recursive' 'Photos'",
566            "$'cd' /Volumes/Backup && rm --recursive Photos",
567            "builtin cd /Volumes/Backup; rm -r Photos",
568            "command -- cd /Volumes/Backup && rm -R Photos",
569            "pushd /Volumes/Backup && rm --recursive Photos",
570            "popd; rm --recursive Photos",
571            "if cd /Volumes/Backup; then rm --recursive Photos; fi",
572            "(cd /Volumes/Backup && rm --recursive Photos)",
573            "cd /Volumes/Backup && sh -c 'rm --recursive Photos'",
574            "sh -c 'cd /Volumes/Backup && rm --recursive Photos'",
575            // Flattening cannot establish directory scope or ordering safely.
576            "rm --recursive Photos; cd /Volumes/Backup",
577        ] {
578            assert!(
579                approval_with_mount_points(&command(&["bash", "-lc", script]), None, project.path(), Some(&mounts),),
580                "relative recursive target needs approval with uncertain directory: {script}"
581            );
582        }
583
584        let absolute_cleanup =
585            format!("cd /Volumes/Backup && rm --recursive '{}'", project.path().join("build/cache").display());
586        for script in [
587            "rm --recursive Photos",
588            "echo cd /Volumes/Backup && rm --recursive Photos",
589            "cd /Volumes/Backup && rm -- Photos",
590            absolute_cleanup.as_str(),
591        ] {
592            assert!(
593                !approval_with_mount_points(&command(&["bash", "-lc", script]), None, project.path(), Some(&mounts),),
594                "known ordinary targets or nonrecursive deletes should retain their classification: {script}"
595            );
596        }
597    }
598
599    #[test]
600    fn recursive_rm_preserves_directory_uncertainty_from_command_wrappers() {
601        let project = tempfile::tempdir().expect("project directory");
602        let mounts = [PathBuf::from("/Volumes/Backup")];
603        for invocation in [
604            command(&["sudo", "--chdir", "/Volumes/Backup", "rm", "--recursive", "Photos"]),
605            command(&["sudo", "--chdir=/Volumes/Backup", "rm", "--recursive", "Photos"]),
606            command(&[
607                "env",
608                "MODE=test",
609                "sudo",
610                "--chdir=/Volumes/Backup",
611                "rm",
612                "--recursive",
613                "Photos",
614            ]),
615            command(&["sudo", "--chdir=/Volumes/Backup", "sh", "-c", "rm --recursive Photos"]),
616        ] {
617            assert!(!super::super::command_might_be_dangerous(&invocation));
618            assert!(
619                approval_with_mount_points(&invocation, None, project.path(), Some(&mounts)),
620                "a directory-changing wrapper must invalidate relative target context: {invocation:?}"
621            );
622        }
623
624        let absolute_cleanup = project.path().join("build/cache");
625        for invocation in [
626            command(&["sudo", "-u", "root", "rm", "--recursive", "Photos"]),
627            command(&["sudo", "-C", "3", "rm", "--recursive", "Photos"]),
628            command(&[
629                "sudo",
630                "--chdir=/Volumes/Backup",
631                "rm",
632                "--recursive",
633                absolute_cleanup.to_str().unwrap(),
634            ]),
635        ] {
636            assert!(
637                !approval_with_mount_points(&invocation, None, project.path(), Some(&mounts)),
638                "ordinary wrappers and absolute project targets retain their classification: {invocation:?}"
639            );
640        }
641    }
642
643    #[test]
644    fn recursive_rm_with_unresolved_targets_fails_closed() {
645        let command = command(&["rm", "--recursive", "$TARGET"]);
646        assert!(recursive_rm_targets_are_sensitive(&command, Path::new("/tmp"), &[]));
647        assert!(approval_with_mount_points(&command, None, Path::new("/tmp"), Some(&[])));
648    }
649
650    #[test]
651    fn ordinary_recursive_project_cleanup_stays_unchanged_with_mount_data() {
652        let temp_dir = tempfile::tempdir().expect("temporary directory");
653        let target = temp_dir.path().join("build/cache");
654        fs::create_dir_all(&target).expect("create project fixture");
655        let command = command(&["rm", "--recursive", "build/cache"]);
656        let mount_points = [PathBuf::from("/"), PathBuf::from("/custom/archive")];
657
658        assert!(!approval_with_mount_points(&command, None, temp_dir.path(), Some(&mount_points)));
659    }
660
661    #[test]
662    fn recursive_rm_requires_approval_when_mount_data_is_unavailable() {
663        let command = command(&["rm", "--recursive", "build/cache"]);
664        assert!(approval_with_mount_points(&command, None, Path::new("/tmp/project"), None));
665    }
666
667    #[test]
668    fn mount_tables_decode_paths_and_reject_incomplete_data() {
669        let linux =
670            parse_linux_mount_info("36 25 0:32 / /mnt/My\\040Drive rw - ext4 /dev/sda1 rw\n").expect("valid mountinfo");
671        assert_eq!(linux, [PathBuf::from("/mnt/My Drive")]);
672        assert!(parse_bsd_mount_output("/dev/disk3 on /Volumes/My\\040Drive (apfs, local)\n").is_some());
673        assert!(parse_linux_mount_info("malformed\n").is_none());
674        assert!(parse_bsd_mount_output("malformed\n").is_none());
675    }
676
677    /// A compound command string is tokenized before it reaches the safety
678    /// layer, so the shell structure only survives in the script text. The
679    /// script view must still find the recursive delete behind a non-`rm`
680    /// first token.
681    #[test]
682    fn compound_command_scripts_cannot_hide_a_recursive_delete() {
683        let project = tempfile::tempdir().expect("project directory");
684        let mounts = [PathBuf::from("/Volumes/Backup")];
685        let compound = command(&["cd", "/Volumes/Backup", "&&", "rm", "--recursive", "Photos"]);
686
687        // The tokenized argv alone carries no shell structure.
688        assert!(
689            !approval_with_mount_points(&compound, None, project.path(), Some(&mounts)),
690            "the argv view alone cannot classify a compound command"
691        );
692        // The script view recovers the delete and keeps it reviewable.
693        assert!(
694            approval_with_mount_points(
695                &compound,
696                Some("cd /Volumes/Backup && rm --recursive Photos"),
697                project.path(),
698                Some(&mounts)
699            ),
700            "a compound command must not hide a mounted recursive delete"
701        );
702
703        // Asymmetric boundary: the same compound shape without a recursive
704        // delete stays unclassified, and a relative delete in an ordinary
705        // project directory stays unclassified.
706        assert!(!approval_with_mount_points(
707            &command(&["cd", "/Volumes/Backup", "&&", "rm", "Photos"]),
708            Some("cd /Volumes/Backup && rm Photos"),
709            project.path(),
710            Some(&mounts)
711        ));
712        assert!(!approval_with_mount_points(
713            &command(&["rm", "--recursive", "build"]),
714            Some("rm --recursive build"),
715            project.path(),
716            Some(&mounts)
717        ));
718    }
719
720    /// A shell builtin that runs the next word must not hide the delete.
721    #[test]
722    fn builtin_prefix_cannot_hide_a_recursive_delete() {
723        let project = tempfile::tempdir().expect("project directory");
724        let mounts = [PathBuf::from("/Volumes/Backup")];
725        for invocation in [
726            command(&["command", "rm", "--recursive", "/Volumes/Backup/Photos"]),
727            command(&["builtin", "rm", "--recursive", "/Volumes/Backup/Photos"]),
728            command(&["command", "--", "rm", "--recursive", "/Volumes/Backup/Photos"]),
729        ] {
730            assert!(
731                approval_with_mount_points(&invocation, None, project.path(), Some(&mounts)),
732                "a builtin prefix must not hide a mounted recursive delete: {invocation:?}"
733            );
734        }
735
736        // A lookup is not a delete.
737        assert!(!approval_with_mount_points(&command(&["command", "-v", "rm"]), None, project.path(), Some(&mounts)));
738    }
739
740    /// The script view must not turn ordinary commands into approvals.
741    #[test]
742    fn script_view_keeps_ordinary_commands_unclassified() {
743        let project = tempfile::tempdir().expect("project directory");
744        let mounts = [PathBuf::from("/Volumes/Backup"), PathBuf::from("/")];
745
746        for script in [
747            "ls -la",
748            "cargo fmt --all",
749            "echo 'rm -rf /'",
750            "grep -r rm src",
751            "rm build/cache",
752            "rm -f build/cache",
753        ] {
754            assert!(
755                !approval_with_mount_points(
756                    &command(&["sh", "-c", script]),
757                    Some(script),
758                    project.path(),
759                    Some(&mounts),
760                ),
761                "ordinary command must not require approval: {script}"
762            );
763        }
764    }
765
766    /// A malformed script cannot be proven to delete only ordinary paths, so
767    /// it stays on the approval path instead of failing open.
768    #[test]
769    fn malformed_recursive_script_fails_closed() {
770        let project = tempfile::tempdir().expect("project directory");
771        let mounts = [PathBuf::from("/Volumes/Backup")];
772        let malformed = "rm --recursive 'unterminated";
773
774        assert!(approval_with_mount_points(
775            &command(&["sh", "-c", malformed]),
776            Some(malformed),
777            project.path(),
778            Some(&mounts),
779        ));
780    }
781}