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