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
16type 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 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 fn cached_canonicalize(&self, path: &Path) -> Result<PathBuf> {
80 {
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 let canonical = path
90 .canonicalize()
91 .with_context(|| format!("failed to canonicalize `{}`", path.display()))?;
92
93 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 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}