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!("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 fn cached_canonicalize(&self, path: &Path) -> Result<PathBuf> {
77 {
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 let canonical = path
87 .canonicalize()
88 .with_context(|| format!("failed to canonicalize `{}`", path.display()))?;
89
90 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 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
454struct 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 fn verb(mut self, name: &str) -> Self {
471 self.parts.push(name.to_string());
472 self
473 }
474
475 fn flag(mut self, name: &str) -> Self {
477 self.parts.push(format!("-{name}"));
478 self
479 }
480
481 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 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 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 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}