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