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("Path", 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 => ShellCommand::new(ShellKind::Windows)
152                .verb("New-Item")
153                .flag("ItemType")
154                .value("Directory")
155                .flag_if(parents, "Force")
156                .named("Path", format_path(ShellKind::Windows, &target))
157                .build(),
158        };
159
160        let invocation = CommandInvocation::new(
161            self.shell_kind,
162            command,
163            CommandCategory::CreateDirectory,
164            self.working_dir.clone(),
165        )
166        .with_paths(vec![target]);
167
168        self.expect_success(invocation).map(|_| ())
169    }
170
171    pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
172        self.rm_with_options(path, RmOptions { recursive, force })
173    }
174
175    /// Remove a workspace path with named options.
176    pub fn rm_with_options(&self, path: &str, options: RmOptions) -> Result<()> {
177        let target = self.resolve_path(path)?;
178        // rm replaces its target itself, so the target must not be (or
179        // resolve through a symlink to) the workspace root. The canonical
180        // comparison closes the traversal (`rm ..` from a subdirectory),
181        // alias (`/tmp` vs `/private/tmp`) and symlink-to-root cases.
182        let target_canonical = if target.exists() {
183            Some(self.resolve_canonical_path(&target)?)
184        } else {
185            None
186        };
187        if let Some(canonical) = &target_canonical {
188            self.ensure_not_workspace_root(canonical)?;
189        }
190        self.ensure_mutation_target_within_workspace(&target)?;
191
192        let command = match self.shell_kind {
193            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
194                .verb("rm")
195                .flag_if(options.recursive, "r")
196                .flag_if(options.force, "f")
197                .value(format_path(ShellKind::Unix, &target))
198                .build(),
199            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
200                .verb("Remove-Item")
201                .flag_if(options.recursive, "Recurse")
202                .flag_if(options.force, "Force")
203                .named("Path", format_path(ShellKind::Windows, &target))
204                .build(),
205        };
206
207        let invocation =
208            CommandInvocation::new(self.shell_kind, command, CommandCategory::Remove, self.working_dir.clone())
209                .with_paths(vec![target]);
210
211        self.expect_success(invocation).map(|_| ())
212    }
213
214    pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
215        let source_path = self.resolve_existing_path(source)?;
216        let dest_path = self.resolve_path(dest)?;
217        self.ensure_mutation_target_within_workspace(&dest_path)?;
218
219        let command = match self.shell_kind {
220            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
221                .verb("cp")
222                .flag_if(recursive, "r")
223                .value(format_path(ShellKind::Unix, &source_path))
224                .value(format_path(ShellKind::Unix, &dest_path))
225                .build(),
226            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
227                .verb("Copy-Item")
228                .named("Path", format_path(ShellKind::Windows, &source_path))
229                .named("Destination", format_path(ShellKind::Windows, &dest_path))
230                .flag_if(recursive, "Recurse")
231                .build(),
232        };
233
234        let invocation =
235            CommandInvocation::new(self.shell_kind, command, CommandCategory::Copy, self.working_dir.clone())
236                .with_paths(vec![source_path, dest_path]);
237
238        self.expect_success(invocation).map(|_| ())
239    }
240
241    pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
242        let source_path = self.resolve_existing_path(source)?;
243        let dest_path = self.resolve_path(dest)?;
244        self.ensure_mutation_target_within_workspace(&dest_path)?;
245
246        let command = match self.shell_kind {
247            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
248                .verb("mv")
249                .value(format_path(ShellKind::Unix, &source_path))
250                .value(format_path(ShellKind::Unix, &dest_path))
251                .build(),
252            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
253                .verb("Move-Item")
254                .named("Path", format_path(ShellKind::Windows, &source_path))
255                .named("Destination", format_path(ShellKind::Windows, &dest_path))
256                .build(),
257        };
258
259        let invocation =
260            CommandInvocation::new(self.shell_kind, command, CommandCategory::Move, self.working_dir.clone())
261                .with_paths(vec![source_path, dest_path]);
262
263        self.expect_success(invocation).map(|_| ())
264    }
265
266    pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
267        let target = path
268            .map(|p| self.resolve_existing_path(p))
269            .transpose()?
270            .unwrap_or_else(|| self.working_dir.clone());
271
272        let command = match self.shell_kind {
273            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
274                .verb("grep")
275                .flag("n")
276                .flag_if(recursive, "r")
277                .value(format_pattern(ShellKind::Unix, pattern))
278                .value(format_path(ShellKind::Unix, &target))
279                .build(),
280            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
281                .verb("Select-String")
282                .named("Pattern", format_pattern(ShellKind::Windows, pattern))
283                .named("Path", format_path(ShellKind::Windows, &target))
284                .value("-SimpleMatch")
285                .flag_if(recursive, "Recurse")
286                .build(),
287        };
288
289        let invocation =
290            CommandInvocation::new(self.shell_kind, command, CommandCategory::Search, self.working_dir.clone())
291                .with_paths(vec![target]);
292
293        let output = self.execute_invocation(invocation)?;
294        if output.status.success() {
295            return Ok(output.stdout);
296        }
297
298        if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
299            Ok(String::new())
300        } else {
301            Err(anyhow!(
302                "search command failed: {}",
303                if output.stderr.trim().is_empty() {
304                    output.stdout
305                } else {
306                    output.stderr
307                }
308            ))
309        }
310    }
311
312    fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
313        self.policy.check(&invocation)?;
314        self.executor.execute(&invocation)
315    }
316
317    fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
318        let output = self.execute_invocation(invocation.clone())?;
319        if output.status.success() {
320            Ok(output)
321        } else {
322            Err(anyhow!(
323                "command `{}` failed: {}",
324                invocation.command,
325                if output.stderr.trim().is_empty() {
326                    output.stdout
327                } else {
328                    output.stderr
329                }
330            ))
331        }
332    }
333
334    fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
335        let path = self.resolve_path(raw)?;
336        if !path.exists() {
337            bail!("path `{}` does not exist", path.display());
338        }
339
340        let canonical = self.resolve_canonical_path(&path)?;
341
342        self.ensure_within_workspace(&canonical)?;
343        Ok(canonical)
344    }
345
346    fn resolve_path(&self, raw: &str) -> Result<PathBuf> {
347        // An empty/whitespace path joins to the working directory itself
348        // (`PathBuf::join("")` returns the base), turning `rm -r -f ""` into
349        // a recursive delete of the workspace root. Reject it at the entry
350        // point shared by cd/mkdir/rm/cp/mv.
351        if raw.trim().is_empty() {
352            bail!("path must not be empty");
353        }
354        let candidate = Path::new(raw);
355        let joined = if candidate.is_absolute() {
356            candidate.to_path_buf()
357        } else {
358            self.working_dir.join(candidate)
359        };
360        Ok(joined.clean())
361    }
362
363    fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
364        if let Ok(metadata) = fs::symlink_metadata(candidate)
365            && metadata.file_type().is_symlink()
366        {
367            let canonical = self.resolve_canonical_path(candidate)?;
368            return self.ensure_within_workspace(&canonical);
369        }
370
371        if candidate.exists() {
372            let canonical = self.resolve_canonical_path(candidate)?;
373            self.ensure_within_workspace(&canonical)
374        } else {
375            let parent = self.canonicalize_existing_parent(candidate)?;
376            self.ensure_within_workspace(&parent)
377        }
378    }
379
380    /// Refuse to operate on the workspace root itself. A recursive delete of
381    /// the root erases the entire workspace; the root can be reached
382    /// lexically (`rm -r .` from the root, `rm -r ..` from a subdirectory)
383    /// or through a symlink/absolute alias, so the check compares the
384    /// canonicalized target against the canonical workspace root.
385    fn ensure_not_workspace_root(&self, canonical_candidate: &Path) -> Result<()> {
386        if canonical_candidate == self.workspace_root {
387            bail!("refusing to operate on the workspace root itself (`{}`)", canonical_candidate.display());
388        }
389        Ok(())
390    }
391
392    fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
393        let mut current = candidate.parent();
394        while let Some(path) = current {
395            if path.exists() {
396                return self.resolve_canonical_path(path);
397            }
398            current = path.parent();
399        }
400
401        Ok(self.working_dir.clone())
402    }
403
404    fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
405        // `workspace_root` is canonicalized in the constructor and candidates
406        // arrive canonicalized, so the lexical check is sufficient here.
407        vtcode_commons::paths::ensure_path_within_workspace(candidate, &self.workspace_root).map_err(|error| {
408            error.context(format!(
409                "path `{}` escapes workspace root `{}`",
410                candidate.display(),
411                self.workspace_root.display()
412            ))
413        })?;
414        Ok(())
415    }
416}
417
418fn default_shell_kind() -> ShellKind {
419    if cfg!(windows) {
420        ShellKind::Windows
421    } else {
422        ShellKind::Unix
423    }
424}
425
426fn join_command(parts: Vec<String>) -> String {
427    parts.into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" ")
428}
429
430fn format_path(shell: ShellKind, path: &Path) -> String {
431    match shell {
432        ShellKind::Unix => escape(path.to_string_lossy()).into_owned(),
433        ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
434    }
435}
436
437fn format_pattern(shell: ShellKind, pattern: &str) -> String {
438    match shell {
439        ShellKind::Unix => escape(pattern.into()).into_owned(),
440        ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
441    }
442}
443
444/// Fluent builder for shell-aware command strings.
445///
446/// `ShellKind::Unix` follows POSIX conventions (flags prefixed with `-`,
447/// arguments are positional). `ShellKind::Windows` targets PowerShell,
448/// which uses named switches in the form `-Name value`.
449struct ShellCommand {
450    shell: ShellKind,
451    parts: Vec<String>,
452}
453
454impl ShellCommand {
455    fn new(shell: ShellKind) -> Self {
456        Self { shell, parts: Vec::new() }
457    }
458
459    /// Append the command verb (first token).
460    fn verb(mut self, name: &str) -> Self {
461        self.parts.push(name.to_string());
462        self
463    }
464
465    /// Append a `-Name` flag unconditionally.
466    fn flag(mut self, name: &str) -> Self {
467        self.parts.push(format!("-{name}"));
468        self
469    }
470
471    /// Append a `-Name` flag only if `condition` holds.
472    fn flag_if(mut self, condition: bool, name: &str) -> Self {
473        if condition {
474            self.parts.push(format!("-{name}"));
475        }
476        self
477    }
478
479    /// Append a named parameter with a value. On Unix, the `name` is ignored
480    /// and the value is added as a positional argument. On Windows, the
481    /// pair is rendered as `-Name value`.
482    fn named(mut self, name: &str, value: impl Into<String>) -> Self {
483        let v = value.into();
484        let token = match self.shell {
485            ShellKind::Unix => v,
486            ShellKind::Windows => format!("-{name} {v}"),
487        };
488        self.parts.push(token);
489        self
490    }
491
492    /// Append a positional value rendered the same way on both shells.
493    fn value(mut self, value: impl Into<String>) -> Self {
494        self.parts.push(value.into());
495        self
496    }
497
498    fn build(self) -> String {
499        join_command(self.parts)
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
507    use crate::policy::AllowAllPolicy;
508    use assert_fs::TempDir;
509    use std::sync::{Arc, Mutex};
510
511    #[derive(Clone, Default)]
512    struct RecordingExecutor {
513        invocations: Arc<Mutex<Vec<CommandInvocation>>>,
514    }
515
516    impl CommandExecutor for RecordingExecutor {
517        fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
518            self.invocations
519                .lock()
520                .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
521                .push(invocation.clone());
522            Ok(CommandOutput {
523                status: CommandStatus::new(true, Some(0)),
524                stdout: String::new(),
525                stderr: String::new(),
526            })
527        }
528    }
529
530    #[test]
531    fn cd_updates_working_directory() -> Result<()> {
532        let dir = TempDir::new()?;
533        let nested = dir.path().join("nested");
534        fs::create_dir(&nested)?;
535        let runner = BashRunner::new(dir.path().to_path_buf(), RecordingExecutor::default(), AllowAllPolicy);
536        let mut runner = runner?;
537        runner.cd("nested")?;
538        // Canonicalize expected path to match runner's canonical working_dir
539        let expected = canonicalize(&nested)?;
540        assert_eq!(runner.working_dir(), expected);
541        Ok(())
542    }
543
544    #[test]
545    fn rm_rejects_empty_path_instead_of_targeting_workspace_root() -> Result<()> {
546        let dir = TempDir::new()?;
547        let executor = RecordingExecutor::default();
548        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
549
550        for empty in ["", "   ", "."] {
551            let result = runner.rm(empty, true, true);
552            assert!(result.is_err(), "rm({empty:?}) must be rejected");
553        }
554        assert!(
555            executor.invocations.lock().expect("invocations lock").is_empty(),
556            "no command must be built for empty paths"
557        );
558        Ok(())
559    }
560
561    #[test]
562    fn rm_with_options_matches_positional_rm() -> Result<()> {
563        let dir = TempDir::new()?;
564        let executor = RecordingExecutor::default();
565        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
566        let target = dir.path().join("named.txt");
567        fs::write(&target, "named")?;
568        // Parity: positional wrapper must build the same command as named options.
569        runner.rm("named.txt", true, true)?;
570        runner.rm_with_options("named.txt", RmOptions { recursive: true, force: true })?;
571        runner.rm_with_options("named.txt", RmOptions::default())?;
572        let invocations = executor.invocations.lock().expect("invocations lock");
573        assert_eq!(invocations.len(), 3);
574        assert_eq!(invocations[0].command, invocations[1].command);
575        let (recursive_flag, force_flag) = match runner.shell_kind() {
576            ShellKind::Unix => ("-r", "-f"),
577            ShellKind::Windows => ("-Recurse", "-Force"),
578        };
579        assert!(
580            invocations[0].command.contains(recursive_flag),
581            "recursive flag missing: {}",
582            invocations[0].command
583        );
584        assert!(invocations[0].command.contains(force_flag), "force flag missing: {}", invocations[0].command);
585        assert!(!invocations[2].command.contains(recursive_flag));
586        assert!(!invocations[2].command.contains(force_flag));
587        Ok(())
588    }
589
590    #[test]
591    fn rm_rejects_workspace_root_via_parent_traversal_and_absolute_alias() -> Result<()> {
592        let dir = TempDir::new()?;
593        let executor = RecordingExecutor::default();
594        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
595        let mut runner = runner;
596        let canonical_root = runner.working_dir().to_path_buf();
597
598        // rm .. from a subdirectory resolves to the workspace root.
599        fs::create_dir_all(runner.working_dir().join("sub"))?;
600        runner.cd("sub")?;
601        assert!(runner.rm("..", true, true).is_err(), "rm('..') from sub must be rejected");
602        // Absolute path written through the non-canonical alias of the root.
603        let alias = dir.path().to_path_buf();
604        if alias != canonical_root {
605            assert!(
606                runner.rm(&alias.to_string_lossy(), true, true).is_err(),
607                "rm via non-canonical absolute alias must be rejected"
608            );
609        }
610        // The canonical root itself (rm is run from a subdirectory on purpose).
611        assert!(
612            runner.rm(&canonical_root.to_string_lossy(), true, true).is_err(),
613            "rm on the canonical root must be rejected"
614        );
615        // A symlink to the workspace root placed inside it.
616        let link = runner.working_dir().join("root-link");
617        #[cfg(unix)]
618        std::os::unix::fs::symlink(&canonical_root, &link).expect("create root symlink");
619        #[cfg(unix)]
620        assert!(
621            runner.rm(&link.to_string_lossy(), true, true).is_err(),
622            "rm through a symlink to the root must be rejected"
623        );
624        #[cfg(not(unix))]
625        let _ = &link;
626        assert!(
627            executor.invocations.lock().expect("invocations lock").is_empty(),
628            "no command must be built for root targets"
629        );
630        Ok(())
631    }
632
633    #[test]
634    fn mkdir_records_invocation() -> Result<()> {
635        let dir = TempDir::new()?;
636        let executor = RecordingExecutor::default();
637        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
638        runner?.mkdir("new_dir", true)?;
639        let invocations = executor
640            .invocations
641            .lock()
642            .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
643        assert_eq!(invocations.len(), 1);
644        assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
645        Ok(())
646    }
647    #[cfg(unix)]
648    #[test]
649    fn symlink_retargeting_cannot_reuse_an_earlier_authorization() -> Result<()> {
650        let root = TempDir::new()?;
651        let outside = TempDir::new()?;
652        let inside = root.path().join("inside");
653        fs::create_dir(&inside)?;
654        let link = root.path().join("link");
655        std::os::unix::fs::symlink(&inside, &link)?;
656        let executor = RecordingExecutor::default();
657        let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
658        runner.ls(Some("link"), false)?;
659        fs::remove_file(&link)?;
660        std::os::unix::fs::symlink(outside.path(), &link)?;
661        assert!(runner.ls(Some("link"), false).is_err());
662        assert!(runner.mkdir("link/new-directory", false).is_err());
663        assert_eq!(executor.invocations.lock().expect("invocations lock").len(), 1);
664        Ok(())
665    }
666
667    #[cfg(unix)]
668    #[test]
669    fn shell_command_keeps_metacharacters_inside_a_single_literal_argument() -> Result<()> {
670        let root = TempDir::new()?;
671        let executor = RecordingExecutor::default();
672        let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
673        let filename = "literal;$(echo injected)'file";
674        runner.mkdir(filename, false)?;
675        let invocations = executor.invocations.lock().expect("invocations lock");
676        let command = &invocations[0].command;
677        let output = std::process::Command::new("sh").arg("-c").arg(command).output()?;
678        assert!(output.status.success());
679        assert!(runner.workspace_root().join(filename).is_dir());
680        Ok(())
681    }
682}