Skip to main content

vtcode_bash_runner/
runner.rs

1use crate::executor::{CommandCategory, CommandExecutor, CommandInvocation, CommandOutput, ShellKind};
2use crate::policy::CommandPolicy;
3use anyhow::{Context, Result, anyhow, bail};
4use path_clean::PathClean;
5use shell_escape::escape;
6use std::fs;
7use std::path::{Path, PathBuf};
8use vtcode_commons::{WorkspacePaths, canonicalize};
9
10pub struct BashRunner<E, P> {
11    executor: E,
12    policy: P,
13    workspace_root: PathBuf,
14    working_dir: PathBuf,
15    shell_kind: ShellKind,
16}
17
18/// Named options for [`BashRunner::rm`].
19///
20/// Stable-Rust emulation of named/optional arguments: call sites use named
21/// fields (`RmOptions { recursive: true, ..Default::default() }`) instead
22/// of positional `rm(path, true, false)`, avoiding `recursive`/`force` swaps.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub struct RmOptions {
25    /// Pass `-r` / `Recurse` (directories).
26    pub recursive: bool,
27    /// Pass `-f` / `Force` (suppress missing-file errors).
28    pub force: bool,
29}
30
31impl<E, P> BashRunner<E, P>
32where
33    E: CommandExecutor,
34    P: CommandPolicy,
35{
36    pub fn new(workspace_root: PathBuf, executor: E, policy: P) -> Result<Self> {
37        if !workspace_root.exists() {
38            bail!("workspace root `{}` does not exist", workspace_root.display());
39        }
40
41        let canonical_root = canonicalize(&workspace_root)
42            .with_context(|| format!("failed to canonicalize `{}`", workspace_root.display()))?;
43
44        Ok(Self {
45            executor,
46            policy,
47            workspace_root: canonical_root.clone(),
48            working_dir: canonical_root,
49            shell_kind: default_shell_kind(),
50        })
51    }
52
53    pub fn from_workspace_paths<W>(paths: &W, executor: E, policy: P) -> Result<Self>
54    where
55        W: WorkspacePaths,
56    {
57        Self::new(paths.workspace_root().to_path_buf(), executor, policy)
58    }
59
60    pub fn workspace_root(&self) -> &Path {
61        &self.workspace_root
62    }
63
64    fn working_dir(&self) -> &Path {
65        &self.working_dir
66    }
67
68    pub fn shell_kind(&self) -> ShellKind {
69        self.shell_kind
70    }
71
72    /// Authorization must resolve the current filesystem, never a cached symlink target.
73    fn resolve_canonical_path(&self, path: &Path) -> Result<PathBuf> {
74        canonicalize(path).with_context(|| format!("failed to canonicalize `{}`", path.display()))
75    }
76
77    pub fn cd(&mut self, path: &str) -> Result<()> {
78        let candidate = self.resolve_path(path)?;
79        if !candidate.exists() {
80            bail!("directory `{}` does not exist", candidate.display());
81        }
82        if !candidate.is_dir() {
83            bail!("path `{}` is not a directory", candidate.display());
84        }
85
86        let canonical = self.resolve_canonical_path(&candidate)?;
87
88        self.ensure_within_workspace(&canonical)?;
89
90        let invocation = CommandInvocation::new(
91            self.shell_kind,
92            format!("cd {}", format_path(self.shell_kind, &canonical)),
93            CommandCategory::ChangeDirectory,
94            canonical.clone(),
95        )
96        .with_paths(vec![canonical.clone()]);
97
98        self.policy.check(&invocation)?;
99        self.working_dir = canonical;
100        Ok(())
101    }
102
103    pub fn ls(&self, path: Option<&str>, show_hidden: bool) -> Result<String> {
104        let target = path
105            .map(|p| self.resolve_existing_path(p))
106            .transpose()?
107            .unwrap_or_else(|| self.working_dir.clone());
108
109        let command = match self.shell_kind {
110            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
111                .verb("ls")
112                .flag(if show_hidden { "la" } else { "l" })
113                .value(format_path(ShellKind::Unix, &target))
114                .build(),
115            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
116                .verb("Get-ChildItem")
117                .flag_if(show_hidden, "Force")
118                .named("LiteralPath", format_path(ShellKind::Windows, &target))
119                .build(),
120        };
121
122        let invocation =
123            CommandInvocation::new(self.shell_kind, command, CommandCategory::ListDirectory, self.working_dir.clone())
124                .with_paths(vec![target]);
125
126        let output = self.expect_success(invocation)?;
127        Ok(output.stdout)
128    }
129
130    pub fn pwd(&self) -> Result<String> {
131        let command = match self.shell_kind {
132            ShellKind::Unix => ShellCommand::new(ShellKind::Unix).verb("pwd").build(),
133            ShellKind::Windows => ShellCommand::new(ShellKind::Windows).verb("Get-Location").build(),
134        };
135        let invocation =
136            CommandInvocation::new(self.shell_kind, command, CommandCategory::PrintDirectory, self.working_dir.clone());
137        self.policy.check(&invocation)?;
138        Ok(self.working_dir.to_string_lossy().into_owned())
139    }
140
141    pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
142        let target = self.resolve_path(path)?;
143        self.ensure_mutation_target_within_workspace(&target)?;
144
145        let command = match self.shell_kind {
146            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
147                .verb("mkdir")
148                .flag_if(parents, "p")
149                .value(format_path(ShellKind::Unix, &target))
150                .build(),
151            ShellKind::Windows => {
152                let target = format_path(ShellKind::Windows, &target);
153                let validate = if parents {
154                    ""
155                } else {
156                    "if ([System.IO.Directory]::Exists($p)) { throw 'Directory already exists' }; if (-not [System.IO.Directory]::Exists([System.IO.Path]::GetDirectoryName($p))) { throw 'Parent directory does not exist' }; "
157                };
158                format!("$p = {target}; {validate}[void][System.IO.Directory]::CreateDirectory($p)")
159            }
160        };
161
162        let invocation = CommandInvocation::new(
163            self.shell_kind,
164            command,
165            CommandCategory::CreateDirectory,
166            self.working_dir.clone(),
167        )
168        .with_paths(vec![target])
169        .with_create_parents(parents);
170
171        self.expect_success(invocation).map(|_| ())
172    }
173
174    pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
175        self.rm_with_options(path, RmOptions { recursive, force })
176    }
177
178    /// Remove a workspace path with named options.
179    pub fn rm_with_options(&self, path: &str, options: RmOptions) -> Result<()> {
180        let target = self.resolve_path(path)?;
181        // rm replaces its target itself, so the target must not be (or
182        // resolve through a symlink to) the workspace root. The canonical
183        // comparison closes the traversal (`rm ..` from a subdirectory),
184        // alias (`/tmp` vs `/private/tmp`) and symlink-to-root cases.
185        let target_canonical = if target.exists() {
186            Some(self.resolve_canonical_path(&target)?)
187        } else {
188            None
189        };
190        if let Some(canonical) = &target_canonical {
191            self.ensure_not_workspace_root(canonical)?;
192        }
193        self.ensure_mutation_target_within_workspace(&target)?;
194
195        let command = match self.shell_kind {
196            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
197                .verb("rm")
198                .flag_if(options.recursive, "r")
199                .flag_if(options.force, "f")
200                .value(format_path(ShellKind::Unix, &target))
201                .build(),
202            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
203                .verb("Remove-Item")
204                .flag_if(options.recursive, "Recurse")
205                .flag_if(options.force, "Force")
206                .named("LiteralPath", format_path(ShellKind::Windows, &target))
207                .build(),
208        };
209
210        let invocation =
211            CommandInvocation::new(self.shell_kind, command, CommandCategory::Remove, self.working_dir.clone())
212                .with_paths(vec![target]);
213
214        self.expect_success(invocation).map(|_| ())
215    }
216
217    pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
218        let source_path = self.resolve_existing_path(source)?;
219        let dest_path = self.resolve_path(dest)?;
220        self.ensure_mutation_target_within_workspace(&dest_path)?;
221
222        let command = match self.shell_kind {
223            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
224                .verb("cp")
225                .flag_if(recursive, "r")
226                .value(format_path(ShellKind::Unix, &source_path))
227                .value(format_path(ShellKind::Unix, &dest_path))
228                .build(),
229            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
230                .verb("Copy-Item")
231                .named("LiteralPath", format_path(ShellKind::Windows, &source_path))
232                .named("Destination", format_path(ShellKind::Windows, &dest_path))
233                .flag_if(recursive, "Recurse")
234                .build(),
235        };
236
237        let invocation =
238            CommandInvocation::new(self.shell_kind, command, CommandCategory::Copy, self.working_dir.clone())
239                .with_paths(vec![source_path, dest_path]);
240
241        self.expect_success(invocation).map(|_| ())
242    }
243
244    pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
245        let source_path = self.resolve_existing_path(source)?;
246        let dest_path = self.resolve_path(dest)?;
247        self.ensure_mutation_target_within_workspace(&dest_path)?;
248
249        let command = match self.shell_kind {
250            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
251                .verb("mv")
252                .value(format_path(ShellKind::Unix, &source_path))
253                .value(format_path(ShellKind::Unix, &dest_path))
254                .build(),
255            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
256                .verb("Move-Item")
257                .named("LiteralPath", format_path(ShellKind::Windows, &source_path))
258                .named("Destination", format_path(ShellKind::Windows, &dest_path))
259                .build(),
260        };
261
262        let invocation =
263            CommandInvocation::new(self.shell_kind, command, CommandCategory::Move, self.working_dir.clone())
264                .with_paths(vec![source_path, dest_path]);
265
266        self.expect_success(invocation).map(|_| ())
267    }
268
269    pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
270        let target = path
271            .map(|p| self.resolve_existing_path(p))
272            .transpose()?
273            .unwrap_or_else(|| self.working_dir.clone());
274
275        let command = match self.shell_kind {
276            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
277                .verb("grep")
278                .flag("n")
279                .flag_if(recursive, "r")
280                .value(format_pattern(ShellKind::Unix, pattern))
281                .value(format_path(ShellKind::Unix, &target))
282                .build(),
283            ShellKind::Windows if recursive => format!(
284                "Get-ChildItem -LiteralPath {} -File -Recurse | Select-String -Pattern {} -SimpleMatch",
285                format_path(ShellKind::Windows, &target),
286                format_pattern(ShellKind::Windows, pattern)
287            ),
288            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
289                .verb("Select-String")
290                .named("Pattern", format_pattern(ShellKind::Windows, pattern))
291                .named("LiteralPath", format_path(ShellKind::Windows, &target))
292                .value("-SimpleMatch")
293                .build(),
294        };
295
296        let invocation =
297            CommandInvocation::new(self.shell_kind, command, CommandCategory::Search, self.working_dir.clone())
298                .with_paths(vec![target]);
299
300        let output = self.execute_invocation(invocation)?;
301        if output.status.success() {
302            return Ok(output.stdout);
303        }
304
305        if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
306            Ok(String::new())
307        } else {
308            Err(anyhow!(
309                "search command failed: {}",
310                if output.stderr.trim().is_empty() {
311                    output.stdout
312                } else {
313                    output.stderr
314                }
315            ))
316        }
317    }
318
319    fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
320        self.policy.check(&invocation)?;
321        self.executor.execute(&invocation)
322    }
323
324    fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
325        let output = self.execute_invocation(invocation.clone())?;
326        if output.status.success() {
327            Ok(output)
328        } else {
329            Err(anyhow!(
330                "command `{}` failed: {}",
331                invocation.command,
332                if output.stderr.trim().is_empty() {
333                    output.stdout
334                } else {
335                    output.stderr
336                }
337            ))
338        }
339    }
340
341    fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
342        let path = self.resolve_path(raw)?;
343        if !path.exists() {
344            bail!("path `{}` does not exist", path.display());
345        }
346
347        let canonical = self.resolve_canonical_path(&path)?;
348
349        self.ensure_within_workspace(&canonical)?;
350        Ok(canonical)
351    }
352
353    fn resolve_path(&self, raw: &str) -> Result<PathBuf> {
354        // An empty/whitespace path joins to the working directory itself
355        // (`PathBuf::join("")` returns the base), turning `rm -r -f ""` into
356        // a recursive delete of the workspace root. Reject it at the entry
357        // point shared by cd/mkdir/rm/cp/mv.
358        if raw.trim().is_empty() {
359            bail!("path must not be empty");
360        }
361        let candidate = Path::new(raw);
362        let joined = if candidate.is_absolute() {
363            candidate.to_path_buf()
364        } else {
365            self.working_dir.join(candidate)
366        };
367        Ok(joined.clean())
368    }
369
370    fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
371        if let Ok(metadata) = fs::symlink_metadata(candidate)
372            && metadata.file_type().is_symlink()
373        {
374            let canonical = self.resolve_canonical_path(candidate)?;
375            return self.ensure_within_workspace(&canonical);
376        }
377
378        if candidate.exists() {
379            let canonical = self.resolve_canonical_path(candidate)?;
380            self.ensure_within_workspace(&canonical)
381        } else {
382            let parent = self.canonicalize_existing_parent(candidate)?;
383            self.ensure_within_workspace(&parent)
384        }
385    }
386
387    /// Refuse to operate on the workspace root itself. A recursive delete of
388    /// the root erases the entire workspace; the root can be reached
389    /// lexically (`rm -r .` from the root, `rm -r ..` from a subdirectory)
390    /// or through a symlink/absolute alias, so the check compares the
391    /// canonicalized target against the canonical workspace root.
392    fn ensure_not_workspace_root(&self, canonical_candidate: &Path) -> Result<()> {
393        if canonical_candidate == self.workspace_root {
394            bail!("refusing to operate on the workspace root itself (`{}`)", canonical_candidate.display());
395        }
396        Ok(())
397    }
398
399    fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
400        let mut current = candidate.parent();
401        while let Some(path) = current {
402            if path.exists() {
403                return self.resolve_canonical_path(path);
404            }
405            current = path.parent();
406        }
407
408        Ok(self.working_dir.clone())
409    }
410
411    fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
412        // `workspace_root` is canonicalized in the constructor and candidates
413        // arrive canonicalized, so the lexical check is sufficient here.
414        vtcode_commons::paths::ensure_path_within_workspace(candidate, &self.workspace_root).map_err(|error| {
415            error.context(format!(
416                "path `{}` escapes workspace root `{}`",
417                candidate.display(),
418                self.workspace_root.display()
419            ))
420        })?;
421        Ok(())
422    }
423}
424
425fn default_shell_kind() -> ShellKind {
426    if cfg!(windows) {
427        ShellKind::Windows
428    } else {
429        ShellKind::Unix
430    }
431}
432
433fn join_command(parts: Vec<String>) -> String {
434    parts.into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" ")
435}
436
437fn format_path(shell: ShellKind, path: &Path) -> String {
438    match shell {
439        ShellKind::Unix => escape(path.to_string_lossy()).into_owned(),
440        ShellKind::Windows => powershell_data(&path.to_string_lossy()),
441    }
442}
443
444fn format_pattern(shell: ShellKind, pattern: &str) -> String {
445    match shell {
446        ShellKind::Unix => escape(pattern.into()).into_owned(),
447        ShellKind::Windows => powershell_data(pattern),
448    }
449}
450
451// PowerShell recognizes Unicode quotation marks as delimiters too. Encode
452// values as byte literals so no input character is interpreted as script.
453fn powershell_data(value: &str) -> String {
454    let bytes = value.as_bytes().iter().map(u8::to_string).collect::<Vec<_>>().join(",");
455    format!("([System.Text.Encoding]::UTF8.GetString([byte[]]@({bytes})))")
456}
457
458/// Fluent builder for shell-aware command strings.
459///
460/// `ShellKind::Unix` follows POSIX conventions (flags prefixed with `-`,
461/// arguments are positional). `ShellKind::Windows` targets PowerShell,
462/// which uses named switches in the form `-Name value`.
463struct ShellCommand {
464    shell: ShellKind,
465    parts: Vec<String>,
466}
467
468impl ShellCommand {
469    fn new(shell: ShellKind) -> Self {
470        Self { shell, parts: Vec::new() }
471    }
472
473    /// Append the command verb (first token).
474    fn verb(mut self, name: &str) -> Self {
475        self.parts.push(name.to_string());
476        self
477    }
478
479    /// Append a `-Name` flag unconditionally.
480    fn flag(mut self, name: &str) -> Self {
481        self.parts.push(format!("-{name}"));
482        self
483    }
484
485    /// Append a `-Name` flag only if `condition` holds.
486    fn flag_if(mut self, condition: bool, name: &str) -> Self {
487        if condition {
488            self.parts.push(format!("-{name}"));
489        }
490        self
491    }
492
493    /// Append a named parameter with a value. On Unix, the `name` is ignored
494    /// and the value is added as a positional argument. On Windows, the
495    /// pair is rendered as `-Name value`.
496    fn named(mut self, name: &str, value: impl Into<String>) -> Self {
497        let v = value.into();
498        let token = match self.shell {
499            ShellKind::Unix => v,
500            ShellKind::Windows => format!("-{name} {v}"),
501        };
502        self.parts.push(token);
503        self
504    }
505
506    /// Append a positional value rendered the same way on both shells.
507    fn value(mut self, value: impl Into<String>) -> Self {
508        self.parts.push(value.into());
509        self
510    }
511
512    fn build(self) -> String {
513        join_command(self.parts)
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
521    use crate::policy::AllowAllPolicy;
522    use assert_fs::TempDir;
523    use std::sync::{Arc, Mutex};
524
525    #[derive(Clone, Default)]
526    struct RecordingExecutor {
527        invocations: Arc<Mutex<Vec<CommandInvocation>>>,
528    }
529
530    impl CommandExecutor for RecordingExecutor {
531        fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
532            self.invocations
533                .lock()
534                .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
535                .push(invocation.clone());
536            Ok(CommandOutput {
537                status: CommandStatus::new(true, Some(0)),
538                stdout: String::new(),
539                stderr: String::new(),
540            })
541        }
542    }
543
544    #[test]
545    fn powershell_arguments_are_data_and_paths_are_literal() -> Result<()> {
546        let dir = TempDir::new()?;
547        let source = "item[1]’ ; Write-Output INJECTED #.txt";
548        fs::write(dir.path().join(source), "content")?;
549        let executor = RecordingExecutor::default();
550        let mut runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
551        runner.shell_kind = ShellKind::Windows;
552        runner.ls(Some(source), false)?;
553        runner.cp(source, "copy[2].txt", false)?;
554        runner.mv(source, "move[2].txt")?;
555        runner.rm(source, false, false)?;
556        runner.mkdir("new[3]", true)?;
557        runner.grep("a’ ; Write-Output INJECTED #", Some(source), false)?;
558        runner.grep("needle", Some(source), true)?;
559
560        let invocations = executor.invocations.lock().expect("invocations");
561        assert_eq!(invocations.len(), 7);
562        for invocation in invocations.iter() {
563            assert!(!invocation.command.contains("INJECTED"), "{}", invocation.command);
564        }
565        for index in [0, 1, 2, 3, 5, 6] {
566            assert!(invocations[index].command.contains("-LiteralPath"), "{}", invocations[index].command);
567        }
568        assert!(invocations[4].command.contains("[System.IO.Directory]::CreateDirectory($p)"));
569        assert!(invocations[4].create_parents);
570        assert!(invocations[6].command.contains("-File -Recurse | Select-String"));
571        assert_eq!(powershell_data("A’'"), "([System.Text.Encoding]::UTF8.GetString([byte[]]@(65,226,128,153,39)))");
572        Ok(())
573    }
574
575    #[cfg(feature = "pure-rust")]
576    #[test]
577    fn powershell_mkdir_parent_option_reaches_pure_rust_executor() -> Result<()> {
578        let dir = TempDir::new()?;
579        let mut runner =
580            BashRunner::new(dir.path().to_path_buf(), crate::executor::PureRustCommandExecutor, AllowAllPolicy)?;
581        runner.shell_kind = ShellKind::Windows;
582        assert!(runner.mkdir("missing/child", false).is_err());
583        runner.mkdir("missing/child", true)?;
584        assert!(dir.path().join("missing/child").is_dir());
585        Ok(())
586    }
587
588    #[cfg(all(feature = "pure-rust", unix))]
589    #[test]
590    fn recursive_copy_rejects_escaping_symlinks_on_both_sides() -> Result<()> {
591        use std::os::unix::fs::symlink;
592
593        let dir = TempDir::new()?;
594        let outside = TempDir::new()?;
595        let secret = outside.path().join("secret.txt");
596        fs::write(&secret, "classified")?;
597        let victim = outside.path().join("victim.txt");
598        fs::write(&victim, "untouched")?;
599
600        let source = dir.path().join("source");
601        fs::create_dir_all(source.join("real"))?;
602        fs::write(source.join("real").join("file.txt"), "data")?;
603        symlink(&secret, source.join("escape"))?;
604
605        let runner =
606            BashRunner::new(dir.path().to_path_buf(), crate::executor::PureRustCommandExecutor, AllowAllPolicy)?;
607        let error = runner.cp("source", "copy", true).expect_err("escaping source link must fail");
608        assert!(format!("{error:#}").contains("escaping"), "{error:#}");
609        assert!(!dir.path().join("copy").join("escape").exists());
610
611        // Links that stay inside the source tree keep copy-through behavior.
612        fs::remove_file(source.join("escape"))?;
613        symlink(source.join("real").join("file.txt"), source.join("inner"))?;
614        runner.cp("source", "copy", true)?;
615        assert_eq!(fs::read_to_string(dir.path().join("copy").join("inner"))?, "data");
616
617        // Planted links inside a pre-existing destination tree must not
618        // redirect writes out: one shadows a copied file, one a copied dir.
619        let dest = dir.path().join("planted");
620        fs::create_dir_all(&dest)?;
621        symlink(&victim, dest.join("inner"))?;
622        symlink(outside.path(), dest.join("real"))?;
623        let error = runner
624            .cp("source", "planted", true)
625            .expect_err("escaping destination link must fail");
626        assert!(format!("{error:#}").contains("escaping"), "{error:#}");
627        assert_eq!(fs::read_to_string(&victim)?, "untouched");
628        assert!(!outside.path().join("file.txt").exists());
629        Ok(())
630    }
631
632    #[test]
633    fn cd_updates_working_directory() -> Result<()> {
634        let dir = TempDir::new()?;
635        let nested = dir.path().join("nested");
636        fs::create_dir(&nested)?;
637        let runner = BashRunner::new(dir.path().to_path_buf(), RecordingExecutor::default(), AllowAllPolicy);
638        let mut runner = runner?;
639        runner.cd("nested")?;
640        // Canonicalize expected path to match runner's canonical working_dir
641        let expected = canonicalize(&nested)?;
642        assert_eq!(runner.working_dir(), expected);
643        Ok(())
644    }
645
646    #[test]
647    fn rm_rejects_empty_path_instead_of_targeting_workspace_root() -> Result<()> {
648        let dir = TempDir::new()?;
649        let executor = RecordingExecutor::default();
650        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
651
652        for empty in ["", "   ", "."] {
653            let result = runner.rm(empty, true, true);
654            assert!(result.is_err(), "rm({empty:?}) must be rejected");
655        }
656        assert!(
657            executor.invocations.lock().expect("invocations lock").is_empty(),
658            "no command must be built for empty paths"
659        );
660        Ok(())
661    }
662
663    #[test]
664    fn rm_with_options_matches_positional_rm() -> Result<()> {
665        let dir = TempDir::new()?;
666        let executor = RecordingExecutor::default();
667        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
668        let target = dir.path().join("named.txt");
669        fs::write(&target, "named")?;
670        // Parity: positional wrapper must build the same command as named options.
671        runner.rm("named.txt", true, true)?;
672        runner.rm_with_options("named.txt", RmOptions { recursive: true, force: true })?;
673        runner.rm_with_options("named.txt", RmOptions::default())?;
674        let invocations = executor.invocations.lock().expect("invocations lock");
675        assert_eq!(invocations.len(), 3);
676        assert_eq!(invocations[0].command, invocations[1].command);
677        let (recursive_flag, force_flag) = match runner.shell_kind() {
678            ShellKind::Unix => ("-r", "-f"),
679            ShellKind::Windows => ("-Recurse", "-Force"),
680        };
681        assert!(
682            invocations[0].command.contains(recursive_flag),
683            "recursive flag missing: {}",
684            invocations[0].command
685        );
686        assert!(invocations[0].command.contains(force_flag), "force flag missing: {}", invocations[0].command);
687        assert!(!invocations[2].command.contains(recursive_flag));
688        assert!(!invocations[2].command.contains(force_flag));
689        Ok(())
690    }
691
692    #[test]
693    fn rm_rejects_workspace_root_via_parent_traversal_and_absolute_alias() -> Result<()> {
694        let dir = TempDir::new()?;
695        let executor = RecordingExecutor::default();
696        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
697        let mut runner = runner;
698        let canonical_root = runner.working_dir().to_path_buf();
699
700        // rm .. from a subdirectory resolves to the workspace root.
701        fs::create_dir_all(runner.working_dir().join("sub"))?;
702        runner.cd("sub")?;
703        assert!(runner.rm("..", true, true).is_err(), "rm('..') from sub must be rejected");
704        // Absolute path written through the non-canonical alias of the root.
705        let alias = dir.path().to_path_buf();
706        if alias != canonical_root {
707            assert!(
708                runner.rm(&alias.to_string_lossy(), true, true).is_err(),
709                "rm via non-canonical absolute alias must be rejected"
710            );
711        }
712        // The canonical root itself (rm is run from a subdirectory on purpose).
713        assert!(
714            runner.rm(&canonical_root.to_string_lossy(), true, true).is_err(),
715            "rm on the canonical root must be rejected"
716        );
717        // A symlink to the workspace root placed inside it.
718        let link = runner.working_dir().join("root-link");
719        #[cfg(unix)]
720        std::os::unix::fs::symlink(&canonical_root, &link).expect("create root symlink");
721        #[cfg(unix)]
722        assert!(
723            runner.rm(&link.to_string_lossy(), true, true).is_err(),
724            "rm through a symlink to the root must be rejected"
725        );
726        #[cfg(not(unix))]
727        let _ = &link;
728        assert!(
729            executor.invocations.lock().expect("invocations lock").is_empty(),
730            "no command must be built for root targets"
731        );
732        Ok(())
733    }
734
735    #[test]
736    fn mkdir_records_invocation() -> Result<()> {
737        let dir = TempDir::new()?;
738        let executor = RecordingExecutor::default();
739        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
740        runner?.mkdir("new_dir", true)?;
741        let invocations = executor
742            .invocations
743            .lock()
744            .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
745        assert_eq!(invocations.len(), 1);
746        assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
747        Ok(())
748    }
749    #[cfg(unix)]
750    #[test]
751    fn symlink_retargeting_cannot_reuse_an_earlier_authorization() -> Result<()> {
752        let root = TempDir::new()?;
753        let outside = TempDir::new()?;
754        let inside = root.path().join("inside");
755        fs::create_dir(&inside)?;
756        let link = root.path().join("link");
757        std::os::unix::fs::symlink(&inside, &link)?;
758        let executor = RecordingExecutor::default();
759        let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
760        runner.ls(Some("link"), false)?;
761        fs::remove_file(&link)?;
762        std::os::unix::fs::symlink(outside.path(), &link)?;
763        assert!(runner.ls(Some("link"), false).is_err());
764        assert!(runner.mkdir("link/new-directory", false).is_err());
765        assert_eq!(executor.invocations.lock().expect("invocations lock").len(), 1);
766        Ok(())
767    }
768
769    #[cfg(unix)]
770    #[test]
771    fn shell_command_keeps_metacharacters_inside_a_single_literal_argument() -> Result<()> {
772        let root = TempDir::new()?;
773        let executor = RecordingExecutor::default();
774        let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
775        let filename = "literal;$(echo injected)'file";
776        runner.mkdir(filename, false)?;
777        let invocations = executor.invocations.lock().expect("invocations lock");
778        let command = &invocations[0].command;
779        let output = std::process::Command::new("sh").arg("-c").arg(command).output()?;
780        assert!(output.status.success());
781        assert!(runner.workspace_root().join(filename).is_dir());
782        Ok(())
783    }
784}