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