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