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