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