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 std::num::NonZeroUsize::new(256).unwrap(), ))),
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 fn cached_canonicalize(&self, path: &Path) -> Result<PathBuf> {
78 {
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 let canonical = path
88 .canonicalize()
89 .with_context(|| format!("failed to canonicalize `{}`", path.display()))?;
90
91 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 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}