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 => {
135                let flag = if show_hidden { "-la" } else { "-l" };
136                format!("ls {} {}", flag, format_path(self.shell_kind, &target))
137            }
138            ShellKind::Windows => {
139                let mut parts = vec!["Get-ChildItem".to_string()];
140                if show_hidden {
141                    parts.push("-Force".to_string());
142                }
143                parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
144                join_command(parts)
145            }
146        };
147
148        let invocation = CommandInvocation::new(
149            self.shell_kind,
150            command,
151            CommandCategory::ListDirectory,
152            self.working_dir.clone(),
153        )
154        .with_paths(vec![target]);
155
156        let output = self.expect_success(invocation)?;
157        Ok(output.stdout)
158    }
159
160    pub fn pwd(&self) -> Result<String> {
161        let invocation = CommandInvocation::new(
162            self.shell_kind,
163            match self.shell_kind {
164                ShellKind::Unix => "pwd".to_string(),
165                ShellKind::Windows => "Get-Location".to_string(),
166            },
167            CommandCategory::PrintDirectory,
168            self.working_dir.clone(),
169        );
170        self.policy.check(&invocation)?;
171        Ok(self.working_dir.to_string_lossy().into_owned())
172    }
173
174    pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
175        let target = self.resolve_path(path);
176        self.ensure_mutation_target_within_workspace(&target)?;
177
178        let command = match self.shell_kind {
179            ShellKind::Unix => {
180                let mut parts = vec!["mkdir".to_string()];
181                if parents {
182                    parts.push("-p".to_string());
183                }
184                parts.push(format_path(self.shell_kind, &target));
185                join_command(parts)
186            }
187            ShellKind::Windows => {
188                let mut parts = vec!["New-Item".to_string(), "-ItemType Directory".to_string()];
189                if parents {
190                    parts.push("-Force".to_string());
191                }
192                parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
193                join_command(parts)
194            }
195        };
196
197        let invocation = CommandInvocation::new(
198            self.shell_kind,
199            command,
200            CommandCategory::CreateDirectory,
201            self.working_dir.clone(),
202        )
203        .with_paths(vec![target]);
204
205        self.expect_success(invocation).map(|_| ())
206    }
207
208    pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
209        let target = self.resolve_path(path);
210        self.ensure_mutation_target_within_workspace(&target)?;
211
212        let command = match self.shell_kind {
213            ShellKind::Unix => {
214                let mut parts = vec!["rm".to_string()];
215                if recursive {
216                    parts.push("-r".to_string());
217                }
218                if force {
219                    parts.push("-f".to_string());
220                }
221                parts.push(format_path(self.shell_kind, &target));
222                join_command(parts)
223            }
224            ShellKind::Windows => {
225                let mut parts = vec!["Remove-Item".to_string()];
226                if recursive {
227                    parts.push("-Recurse".to_string());
228                }
229                if force {
230                    parts.push("-Force".to_string());
231                }
232                parts.push(format!("-Path {}", format_path(self.shell_kind, &target)));
233                join_command(parts)
234            }
235        };
236
237        let invocation = CommandInvocation::new(
238            self.shell_kind,
239            command,
240            CommandCategory::Remove,
241            self.working_dir.clone(),
242        )
243        .with_paths(vec![target]);
244
245        self.expect_success(invocation).map(|_| ())
246    }
247
248    pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
249        let source_path = self.resolve_existing_path(source)?;
250        let dest_path = self.resolve_path(dest);
251        self.ensure_mutation_target_within_workspace(&dest_path)?;
252
253        let command = match self.shell_kind {
254            ShellKind::Unix => {
255                let mut parts = vec!["cp".to_string()];
256                if recursive {
257                    parts.push("-r".to_string());
258                }
259                parts.push(format_path(self.shell_kind, &source_path));
260                parts.push(format_path(self.shell_kind, &dest_path));
261                join_command(parts)
262            }
263            ShellKind::Windows => {
264                let mut parts = vec![
265                    "Copy-Item".to_string(),
266                    format!("-Path {}", format_path(self.shell_kind, &source_path)),
267                    format!("-Destination {}", format_path(self.shell_kind, &dest_path)),
268                ];
269                if recursive {
270                    parts.push("-Recurse".to_string());
271                }
272                join_command(parts)
273            }
274        };
275
276        let invocation = CommandInvocation::new(
277            self.shell_kind,
278            command,
279            CommandCategory::Copy,
280            self.working_dir.clone(),
281        )
282        .with_paths(vec![source_path, dest_path]);
283
284        self.expect_success(invocation).map(|_| ())
285    }
286
287    pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
288        let source_path = self.resolve_existing_path(source)?;
289        let dest_path = self.resolve_path(dest);
290        self.ensure_mutation_target_within_workspace(&dest_path)?;
291
292        let command = match self.shell_kind {
293            ShellKind::Unix => format!(
294                "mv {} {}",
295                format_path(self.shell_kind, &source_path),
296                format_path(self.shell_kind, &dest_path)
297            ),
298            ShellKind::Windows => join_command(vec![
299                "Move-Item".to_string(),
300                format!("-Path {}", format_path(self.shell_kind, &source_path)),
301                format!("-Destination {}", format_path(self.shell_kind, &dest_path)),
302            ]),
303        };
304
305        let invocation = CommandInvocation::new(
306            self.shell_kind,
307            command,
308            CommandCategory::Move,
309            self.working_dir.clone(),
310        )
311        .with_paths(vec![source_path, dest_path]);
312
313        self.expect_success(invocation).map(|_| ())
314    }
315
316    pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
317        let target = path
318            .map(|p| self.resolve_existing_path(p))
319            .transpose()?
320            .unwrap_or_else(|| self.working_dir.clone());
321
322        let command = match self.shell_kind {
323            ShellKind::Unix => {
324                let mut parts = vec!["grep".to_string(), "-n".to_string()];
325                if recursive {
326                    parts.push("-r".to_string());
327                }
328                parts.push(format_pattern(self.shell_kind, pattern));
329                parts.push(format_path(self.shell_kind, &target));
330                join_command(parts)
331            }
332            ShellKind::Windows => {
333                let mut parts = vec![
334                    "Select-String".to_string(),
335                    format!("-Pattern {}", format_pattern(self.shell_kind, pattern)),
336                    format!("-Path {}", format_path(self.shell_kind, &target)),
337                    "-SimpleMatch".to_string(),
338                ];
339                if recursive {
340                    parts.push("-Recurse".to_string());
341                }
342                join_command(parts)
343            }
344        };
345
346        let invocation = CommandInvocation::new(
347            self.shell_kind,
348            command,
349            CommandCategory::Search,
350            self.working_dir.clone(),
351        )
352        .with_paths(vec![target]);
353
354        let output = self.execute_invocation(invocation)?;
355        if output.status.success() {
356            return Ok(output.stdout);
357        }
358
359        if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
360            Ok(String::new())
361        } else {
362            Err(anyhow!(
363                "search command failed: {}",
364                if output.stderr.trim().is_empty() {
365                    output.stdout
366                } else {
367                    output.stderr
368                }
369            ))
370        }
371    }
372
373    fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
374        self.policy.check(&invocation)?;
375        self.executor.execute(&invocation)
376    }
377
378    fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
379        let output = self.execute_invocation(invocation.clone())?;
380        if output.status.success() {
381            Ok(output)
382        } else {
383            Err(anyhow!(
384                "command `{}` failed: {}",
385                invocation.command,
386                if output.stderr.trim().is_empty() {
387                    output.stdout
388                } else {
389                    output.stderr
390                }
391            ))
392        }
393    }
394
395    fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
396        let path = self.resolve_path(raw);
397        if !path.exists() {
398            bail!("path `{}` does not exist", path.display());
399        }
400
401        let canonical = self.cached_canonicalize(&path)?;
402
403        self.ensure_within_workspace(&canonical)?;
404        Ok(canonical)
405    }
406
407    fn resolve_path(&self, raw: &str) -> PathBuf {
408        let candidate = Path::new(raw);
409        let joined = if candidate.is_absolute() {
410            candidate.to_path_buf()
411        } else {
412            self.working_dir.join(candidate)
413        };
414        joined.clean()
415    }
416
417    fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
418        if let Ok(metadata) = fs::symlink_metadata(candidate)
419            && metadata.file_type().is_symlink()
420        {
421            let canonical = self.cached_canonicalize(candidate)?;
422            return self.ensure_within_workspace(&canonical);
423        }
424
425        if candidate.exists() {
426            let canonical = self.cached_canonicalize(candidate)?;
427            self.ensure_within_workspace(&canonical)
428        } else {
429            let parent = self.canonicalize_existing_parent(candidate)?;
430            self.ensure_within_workspace(&parent)
431        }
432    }
433
434    fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
435        let mut current = candidate.parent();
436        while let Some(path) = current {
437            if path.exists() {
438                return self.cached_canonicalize(path);
439            }
440            current = path.parent();
441        }
442
443        Ok(self.working_dir.clone())
444    }
445
446    fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
447        if !candidate.starts_with(&self.workspace_root) {
448            bail!(
449                "path `{}` escapes workspace root `{}`",
450                candidate.display(),
451                self.workspace_root.display()
452            );
453        }
454        Ok(())
455    }
456}
457
458fn default_shell_kind() -> ShellKind {
459    if cfg!(windows) {
460        ShellKind::Windows
461    } else {
462        ShellKind::Unix
463    }
464}
465
466fn join_command(parts: Vec<String>) -> String {
467    parts
468        .into_iter()
469        .filter(|part| !part.is_empty())
470        .collect::<Vec<_>>()
471        .join(" ")
472}
473
474fn format_path(shell: ShellKind, path: &Path) -> String {
475    match shell {
476        ShellKind::Unix => escape(path.to_string_lossy()).to_string(),
477        ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
478    }
479}
480
481fn format_pattern(shell: ShellKind, pattern: &str) -> String {
482    match shell {
483        ShellKind::Unix => escape(pattern.into()).to_string(),
484        ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
492    use crate::policy::AllowAllPolicy;
493    use assert_fs::TempDir;
494    use std::sync::{Arc, Mutex};
495
496    #[derive(Clone, Default)]
497    struct RecordingExecutor {
498        invocations: Arc<Mutex<Vec<CommandInvocation>>>,
499    }
500
501    impl CommandExecutor for RecordingExecutor {
502        fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
503            self.invocations
504                .lock()
505                .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
506                .push(invocation.clone());
507            Ok(CommandOutput {
508                status: CommandStatus::new(true, Some(0)),
509                stdout: String::new(),
510                stderr: String::new(),
511            })
512        }
513    }
514
515    #[test]
516    fn cd_updates_working_directory() -> Result<()> {
517        let dir = TempDir::new()?;
518        let nested = dir.path().join("nested");
519        fs::create_dir(&nested)?;
520        let runner = BashRunner::new(
521            dir.path().to_path_buf(),
522            RecordingExecutor::default(),
523            AllowAllPolicy,
524        );
525        let mut runner = runner?;
526        runner.cd("nested")?;
527        // Canonicalize expected path to match runner's canonical working_dir
528        let expected = nested.canonicalize()?;
529        assert_eq!(runner.working_dir(), expected);
530        Ok(())
531    }
532
533    #[test]
534    fn mkdir_records_invocation() -> Result<()> {
535        let dir = TempDir::new()?;
536        let executor = RecordingExecutor::default();
537        let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
538        runner?.mkdir("new_dir", true)?;
539        let invocations = executor
540            .invocations
541            .lock()
542            .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
543        assert_eq!(invocations.len(), 1);
544        assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
545        Ok(())
546    }
547}