Skip to main content

vtcode_bash_runner/
runner.rs

1use crate::executor::{
2    CommandCategory, CommandExecutor, CommandInvocation, CommandOutput, ShellKind,
3};
4use crate::policy::CommandPolicy;
5use anyhow::{Context, Result, anyhow, bail};
6use lru::LruCache;
7use parking_lot::Mutex;
8use path_clean::PathClean;
9use shell_escape::escape;
10use std::fs;
11use std::num::NonZeroUsize;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use vtcode_commons::WorkspacePaths;
15
16/// LRU cache for canonicalized paths to reduce fs::canonicalize() calls
17type PathCache = Arc<Mutex<LruCache<PathBuf, PathBuf>>>;
18const PATH_CACHE_CAPACITY: usize = 256;
19
20pub struct BashRunner<E, P> {
21    executor: E,
22    policy: P,
23    workspace_root: PathBuf,
24    working_dir: PathBuf,
25    shell_kind: ShellKind,
26    /// Cache for canonicalized paths (capacity: 256)
27    path_cache: PathCache,
28}
29
30impl<E, P> BashRunner<E, P>
31where
32    E: CommandExecutor,
33    P: CommandPolicy,
34{
35    pub fn new(workspace_root: PathBuf, executor: E, policy: P) -> Result<Self> {
36        if !workspace_root.exists() {
37            bail!(
38                "workspace root `{}` does not exist",
39                workspace_root.display()
40            );
41        }
42
43        let canonical_root = workspace_root
44            .canonicalize()
45            .with_context(|| format!("failed to canonicalize `{}`", workspace_root.display()))?;
46
47        Ok(Self {
48            executor,
49            policy,
50            workspace_root: canonical_root.clone(),
51            working_dir: canonical_root,
52            shell_kind: default_shell_kind(),
53            path_cache: Arc::new(Mutex::new(LruCache::new(
54                NonZeroUsize::new(PATH_CACHE_CAPACITY).unwrap_or(NonZeroUsize::MIN),
55            ))),
56        })
57    }
58
59    pub fn from_workspace_paths<W>(paths: &W, executor: E, policy: P) -> Result<Self>
60    where
61        W: WorkspacePaths,
62    {
63        Self::new(paths.workspace_root().to_path_buf(), executor, policy)
64    }
65
66    pub fn workspace_root(&self) -> &Path {
67        &self.workspace_root
68    }
69
70    pub fn working_dir(&self) -> &Path {
71        &self.working_dir
72    }
73
74    pub fn shell_kind(&self) -> ShellKind {
75        self.shell_kind
76    }
77
78    /// Canonicalize a path with LRU caching to reduce filesystem calls
79    fn cached_canonicalize(&self, path: &Path) -> Result<PathBuf> {
80        // Check cache first
81        {
82            let mut cache = self.path_cache.lock();
83            if let Some(cached) = cache.get(path) {
84                return Ok(cached.clone());
85            }
86        }
87
88        // Cache miss - perform canonicalization
89        let canonical = path
90            .canonicalize()
91            .with_context(|| format!("failed to canonicalize `{}`", path.display()))?;
92
93        // Store in cache
94        self.path_cache
95            .lock()
96            .put(path.to_path_buf(), canonical.clone());
97
98        Ok(canonical)
99    }
100
101    pub fn cd(&mut self, path: &str) -> Result<()> {
102        let candidate = self.resolve_path(path);
103        if !candidate.exists() {
104            bail!("directory `{}` does not exist", candidate.display());
105        }
106        if !candidate.is_dir() {
107            bail!("path `{}` is not a directory", candidate.display());
108        }
109
110        let canonical = self.cached_canonicalize(&candidate)?;
111
112        self.ensure_within_workspace(&canonical)?;
113
114        let invocation = CommandInvocation::new(
115            self.shell_kind,
116            format!("cd {}", format_path(self.shell_kind, &canonical)),
117            CommandCategory::ChangeDirectory,
118            canonical.clone(),
119        )
120        .with_paths(vec![canonical.clone()]);
121
122        self.policy.check(&invocation)?;
123        self.working_dir = canonical;
124        Ok(())
125    }
126
127    pub fn ls(&self, path: Option<&str>, show_hidden: bool) -> Result<String> {
128        let target = path
129            .map(|p| self.resolve_existing_path(p))
130            .transpose()?
131            .unwrap_or_else(|| self.working_dir.clone());
132
133        let command = match self.shell_kind {
134            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
135                .verb("ls")
136                .flag(if show_hidden { "la" } else { "l" })
137                .value(format_path(ShellKind::Unix, &target))
138                .build(),
139            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
140                .verb("Get-ChildItem")
141                .flag_if(show_hidden, "Force")
142                .named("Path", format_path(ShellKind::Windows, &target))
143                .build(),
144        };
145
146        let invocation = CommandInvocation::new(
147            self.shell_kind,
148            command,
149            CommandCategory::ListDirectory,
150            self.working_dir.clone(),
151        )
152        .with_paths(vec![target]);
153
154        let output = self.expect_success(invocation)?;
155        Ok(output.stdout)
156    }
157
158    pub fn pwd(&self) -> Result<String> {
159        let command = match self.shell_kind {
160            ShellKind::Unix => ShellCommand::new(ShellKind::Unix).verb("pwd").build(),
161            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
162                .verb("Get-Location")
163                .build(),
164        };
165        let invocation = CommandInvocation::new(
166            self.shell_kind,
167            command,
168            CommandCategory::PrintDirectory,
169            self.working_dir.clone(),
170        );
171        self.policy.check(&invocation)?;
172        Ok(self.working_dir.to_string_lossy().into_owned())
173    }
174
175    pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
176        let target = self.resolve_path(path);
177        self.ensure_mutation_target_within_workspace(&target)?;
178
179        let command = match self.shell_kind {
180            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
181                .verb("mkdir")
182                .flag_if(parents, "p")
183                .value(format_path(ShellKind::Unix, &target))
184                .build(),
185            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
186                .verb("New-Item")
187                .flag("ItemType")
188                .value("Directory")
189                .flag_if(parents, "Force")
190                .named("Path", format_path(ShellKind::Windows, &target))
191                .build(),
192        };
193
194        let invocation = CommandInvocation::new(
195            self.shell_kind,
196            command,
197            CommandCategory::CreateDirectory,
198            self.working_dir.clone(),
199        )
200        .with_paths(vec![target]);
201
202        self.expect_success(invocation).map(|_| ())
203    }
204
205    pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
206        let target = self.resolve_path(path);
207        self.ensure_mutation_target_within_workspace(&target)?;
208
209        let command = match self.shell_kind {
210            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
211                .verb("rm")
212                .flag_if(recursive, "r")
213                .flag_if(force, "f")
214                .value(format_path(ShellKind::Unix, &target))
215                .build(),
216            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
217                .verb("Remove-Item")
218                .flag_if(recursive, "Recurse")
219                .flag_if(force, "Force")
220                .named("Path", format_path(ShellKind::Windows, &target))
221                .build(),
222        };
223
224        let invocation = CommandInvocation::new(
225            self.shell_kind,
226            command,
227            CommandCategory::Remove,
228            self.working_dir.clone(),
229        )
230        .with_paths(vec![target]);
231
232        self.expect_success(invocation).map(|_| ())
233    }
234
235    pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
236        let source_path = self.resolve_existing_path(source)?;
237        let dest_path = self.resolve_path(dest);
238        self.ensure_mutation_target_within_workspace(&dest_path)?;
239
240        let command = match self.shell_kind {
241            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
242                .verb("cp")
243                .flag_if(recursive, "r")
244                .value(format_path(ShellKind::Unix, &source_path))
245                .value(format_path(ShellKind::Unix, &dest_path))
246                .build(),
247            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
248                .verb("Copy-Item")
249                .named("Path", format_path(ShellKind::Windows, &source_path))
250                .named("Destination", format_path(ShellKind::Windows, &dest_path))
251                .flag_if(recursive, "Recurse")
252                .build(),
253        };
254
255        let invocation = CommandInvocation::new(
256            self.shell_kind,
257            command,
258            CommandCategory::Copy,
259            self.working_dir.clone(),
260        )
261        .with_paths(vec![source_path, dest_path]);
262
263        self.expect_success(invocation).map(|_| ())
264    }
265
266    pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
267        let source_path = self.resolve_existing_path(source)?;
268        let dest_path = self.resolve_path(dest);
269        self.ensure_mutation_target_within_workspace(&dest_path)?;
270
271        let command = match self.shell_kind {
272            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
273                .verb("mv")
274                .value(format_path(ShellKind::Unix, &source_path))
275                .value(format_path(ShellKind::Unix, &dest_path))
276                .build(),
277            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
278                .verb("Move-Item")
279                .named("Path", format_path(ShellKind::Windows, &source_path))
280                .named("Destination", format_path(ShellKind::Windows, &dest_path))
281                .build(),
282        };
283
284        let invocation = CommandInvocation::new(
285            self.shell_kind,
286            command,
287            CommandCategory::Move,
288            self.working_dir.clone(),
289        )
290        .with_paths(vec![source_path, dest_path]);
291
292        self.expect_success(invocation).map(|_| ())
293    }
294
295    pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
296        let target = path
297            .map(|p| self.resolve_existing_path(p))
298            .transpose()?
299            .unwrap_or_else(|| self.working_dir.clone());
300
301        let command = match self.shell_kind {
302            ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
303                .verb("grep")
304                .flag("n")
305                .flag_if(recursive, "r")
306                .value(format_pattern(ShellKind::Unix, pattern))
307                .value(format_path(ShellKind::Unix, &target))
308                .build(),
309            ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
310                .verb("Select-String")
311                .named("Pattern", format_pattern(ShellKind::Windows, pattern))
312                .named("Path", format_path(ShellKind::Windows, &target))
313                .value("-SimpleMatch")
314                .flag_if(recursive, "Recurse")
315                .build(),
316        };
317
318        let invocation = CommandInvocation::new(
319            self.shell_kind,
320            command,
321            CommandCategory::Search,
322            self.working_dir.clone(),
323        )
324        .with_paths(vec![target]);
325
326        let output = self.execute_invocation(invocation)?;
327        if output.status.success() {
328            return Ok(output.stdout);
329        }
330
331        if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
332            Ok(String::new())
333        } else {
334            Err(anyhow!(
335                "search command failed: {}",
336                if output.stderr.trim().is_empty() {
337                    output.stdout
338                } else {
339                    output.stderr
340                }
341            ))
342        }
343    }
344
345    fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
346        self.policy.check(&invocation)?;
347        self.executor.execute(&invocation)
348    }
349
350    fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
351        let output = self.execute_invocation(invocation.clone())?;
352        if output.status.success() {
353            Ok(output)
354        } else {
355            Err(anyhow!(
356                "command `{}` failed: {}",
357                invocation.command,
358                if output.stderr.trim().is_empty() {
359                    output.stdout
360                } else {
361                    output.stderr
362                }
363            ))
364        }
365    }
366
367    fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
368        let path = self.resolve_path(raw);
369        if !path.exists() {
370            bail!("path `{}` does not exist", path.display());
371        }
372
373        let canonical = self.cached_canonicalize(&path)?;
374
375        self.ensure_within_workspace(&canonical)?;
376        Ok(canonical)
377    }
378
379    fn resolve_path(&self, raw: &str) -> PathBuf {
380        let candidate = Path::new(raw);
381        let joined = if candidate.is_absolute() {
382            candidate.to_path_buf()
383        } else {
384            self.working_dir.join(candidate)
385        };
386        joined.clean()
387    }
388
389    fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
390        if let Ok(metadata) = fs::symlink_metadata(candidate)
391            && metadata.file_type().is_symlink()
392        {
393            let canonical = self.cached_canonicalize(candidate)?;
394            return self.ensure_within_workspace(&canonical);
395        }
396
397        if candidate.exists() {
398            let canonical = self.cached_canonicalize(candidate)?;
399            self.ensure_within_workspace(&canonical)
400        } else {
401            let parent = self.canonicalize_existing_parent(candidate)?;
402            self.ensure_within_workspace(&parent)
403        }
404    }
405
406    fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
407        let mut current = candidate.parent();
408        while let Some(path) = current {
409            if path.exists() {
410                return self.cached_canonicalize(path);
411            }
412            current = path.parent();
413        }
414
415        Ok(self.working_dir.clone())
416    }
417
418    fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
419        // `workspace_root` is canonicalized in the constructor and candidates
420        // arrive canonicalized, so the lexical check is sufficient here.
421        vtcode_commons::paths::ensure_path_within_workspace(candidate, &self.workspace_root)
422            .map_err(|error| {
423                error.context(format!(
424                    "path `{}` escapes workspace root `{}`",
425                    candidate.display(),
426                    self.workspace_root.display()
427                ))
428            })?;
429        Ok(())
430    }
431}
432
433fn default_shell_kind() -> ShellKind {
434    if cfg!(windows) {
435        ShellKind::Windows
436    } else {
437        ShellKind::Unix
438    }
439}
440
441fn join_command(parts: Vec<String>) -> String {
442    parts
443        .into_iter()
444        .filter(|part| !part.is_empty())
445        .collect::<Vec<_>>()
446        .join(" ")
447}
448
449fn format_path(shell: ShellKind, path: &Path) -> String {
450    match shell {
451        ShellKind::Unix => escape(path.to_string_lossy()).into_owned(),
452        ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
453    }
454}
455
456fn format_pattern(shell: ShellKind, pattern: &str) -> String {
457    match shell {
458        ShellKind::Unix => escape(pattern.into()).into_owned(),
459        ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
460    }
461}
462
463/// Fluent builder for shell-aware command strings.
464///
465/// `ShellKind::Unix` follows POSIX conventions (flags prefixed with `-`,
466/// arguments are positional). `ShellKind::Windows` targets PowerShell,
467/// which uses named switches in the form `-Name value`.
468struct ShellCommand {
469    shell: ShellKind,
470    parts: Vec<String>,
471}
472
473impl ShellCommand {
474    fn new(shell: ShellKind) -> Self {
475        Self {
476            shell,
477            parts: Vec::with_capacity(6),
478        }
479    }
480
481    /// Append the command verb (first token).
482    fn verb(mut self, name: &str) -> Self {
483        self.parts.push(name.to_string());
484        self
485    }
486
487    /// Append a `-Name` flag unconditionally.
488    fn flag(mut self, name: &str) -> Self {
489        self.parts.push(format!("-{name}"));
490        self
491    }
492
493    /// Append a `-Name` flag only if `condition` holds.
494    fn flag_if(mut self, condition: bool, name: &str) -> Self {
495        if condition {
496            self.parts.push(format!("-{name}"));
497        }
498        self
499    }
500
501    /// Append a named parameter with a value. On Unix, the `name` is ignored
502    /// and the value is added as a positional argument. On Windows, the
503    /// pair is rendered as `-Name value`.
504    fn named(mut self, name: &str, value: impl Into<String>) -> Self {
505        let v = value.into();
506        let token = match self.shell {
507            ShellKind::Unix => v,
508            ShellKind::Windows => format!("-{name} {v}"),
509        };
510        self.parts.push(token);
511        self
512    }
513
514    /// Append a positional value rendered the same way on both shells.
515    fn value(mut self, value: impl Into<String>) -> Self {
516        self.parts.push(value.into());
517        self
518    }
519
520    fn build(self) -> String {
521        join_command(self.parts)
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
529    use crate::policy::AllowAllPolicy;
530    use assert_fs::TempDir;
531    use std::sync::{Arc, Mutex};
532
533    #[derive(Clone, Default)]
534    struct RecordingExecutor {
535        invocations: Arc<Mutex<Vec<CommandInvocation>>>,
536    }
537
538    impl CommandExecutor for RecordingExecutor {
539        fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
540            self.invocations
541                .lock()
542                .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
543                .push(invocation.clone());
544            Ok(CommandOutput {
545                status: CommandStatus::new(true, Some(0)),
546                stdout: String::new(),
547                stderr: String::new(),
548            })
549        }
550    }
551
552    #[test]
553    fn cd_updates_working_directory() -> Result<()> {
554        let dir = TempDir::new()?;
555        let nested = dir.path().join("nested");
556        fs::create_dir(&nested)?;
557        let runner = BashRunner::new(
558            dir.path().to_path_buf(),
559            RecordingExecutor::default(),
560            AllowAllPolicy,
561        );
562        let mut runner = runner?;
563        runner.cd("nested")?;
564        // Canonicalize expected path to match runner's canonical working_dir
565        let expected = nested.canonicalize()?;
566        assert_eq!(runner.working_dir(), expected);
567        Ok(())
568    }
569
570    #[test]
571    fn mkdir_records_invocation() -> Result<()> {
572        let dir = TempDir::new()?;
573        let executor = RecordingExecutor::default();
574        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
575        runner?.mkdir("new_dir", true)?;
576        let invocations = executor
577            .invocations
578            .lock()
579            .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
580        assert_eq!(invocations.len(), 1);
581        assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
582        Ok(())
583    }
584}