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 => ShellCommand::new(ShellKind::Unix)
135 .verb("ls")
136 .flag(if show_hidden { "la" } else { "l" })
137 .value(format_path(ShellKind::Unix, &target))
138 .build(),
139 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
140 .verb("Get-ChildItem")
141 .flag_if(show_hidden, "Force")
142 .named("Path", format_path(ShellKind::Windows, &target))
143 .build(),
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 command = match self.shell_kind {
160 ShellKind::Unix => ShellCommand::new(ShellKind::Unix).verb("pwd").build(),
161 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
162 .verb("Get-Location")
163 .build(),
164 };
165 let invocation = CommandInvocation::new(
166 self.shell_kind,
167 command,
168 CommandCategory::PrintDirectory,
169 self.working_dir.clone(),
170 );
171 self.policy.check(&invocation)?;
172 Ok(self.working_dir.to_string_lossy().into_owned())
173 }
174
175 pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
176 let target = self.resolve_path(path);
177 self.ensure_mutation_target_within_workspace(&target)?;
178
179 let command = match self.shell_kind {
180 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
181 .verb("mkdir")
182 .flag_if(parents, "p")
183 .value(format_path(ShellKind::Unix, &target))
184 .build(),
185 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
186 .verb("New-Item")
187 .flag("ItemType")
188 .value("Directory")
189 .flag_if(parents, "Force")
190 .named("Path", format_path(ShellKind::Windows, &target))
191 .build(),
192 };
193
194 let invocation = CommandInvocation::new(
195 self.shell_kind,
196 command,
197 CommandCategory::CreateDirectory,
198 self.working_dir.clone(),
199 )
200 .with_paths(vec![target]);
201
202 self.expect_success(invocation).map(|_| ())
203 }
204
205 pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
206 let target = self.resolve_path(path);
207 self.ensure_mutation_target_within_workspace(&target)?;
208
209 let command = match self.shell_kind {
210 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
211 .verb("rm")
212 .flag_if(recursive, "r")
213 .flag_if(force, "f")
214 .value(format_path(ShellKind::Unix, &target))
215 .build(),
216 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
217 .verb("Remove-Item")
218 .flag_if(recursive, "Recurse")
219 .flag_if(force, "Force")
220 .named("Path", format_path(ShellKind::Windows, &target))
221 .build(),
222 };
223
224 let invocation = CommandInvocation::new(
225 self.shell_kind,
226 command,
227 CommandCategory::Remove,
228 self.working_dir.clone(),
229 )
230 .with_paths(vec![target]);
231
232 self.expect_success(invocation).map(|_| ())
233 }
234
235 pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
236 let source_path = self.resolve_existing_path(source)?;
237 let dest_path = self.resolve_path(dest);
238 self.ensure_mutation_target_within_workspace(&dest_path)?;
239
240 let command = match self.shell_kind {
241 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
242 .verb("cp")
243 .flag_if(recursive, "r")
244 .value(format_path(ShellKind::Unix, &source_path))
245 .value(format_path(ShellKind::Unix, &dest_path))
246 .build(),
247 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
248 .verb("Copy-Item")
249 .named("Path", format_path(ShellKind::Windows, &source_path))
250 .named("Destination", format_path(ShellKind::Windows, &dest_path))
251 .flag_if(recursive, "Recurse")
252 .build(),
253 };
254
255 let invocation = CommandInvocation::new(
256 self.shell_kind,
257 command,
258 CommandCategory::Copy,
259 self.working_dir.clone(),
260 )
261 .with_paths(vec![source_path, dest_path]);
262
263 self.expect_success(invocation).map(|_| ())
264 }
265
266 pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
267 let source_path = self.resolve_existing_path(source)?;
268 let dest_path = self.resolve_path(dest);
269 self.ensure_mutation_target_within_workspace(&dest_path)?;
270
271 let command = match self.shell_kind {
272 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
273 .verb("mv")
274 .value(format_path(ShellKind::Unix, &source_path))
275 .value(format_path(ShellKind::Unix, &dest_path))
276 .build(),
277 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
278 .verb("Move-Item")
279 .named("Path", format_path(ShellKind::Windows, &source_path))
280 .named("Destination", format_path(ShellKind::Windows, &dest_path))
281 .build(),
282 };
283
284 let invocation = CommandInvocation::new(
285 self.shell_kind,
286 command,
287 CommandCategory::Move,
288 self.working_dir.clone(),
289 )
290 .with_paths(vec![source_path, dest_path]);
291
292 self.expect_success(invocation).map(|_| ())
293 }
294
295 pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
296 let target = path
297 .map(|p| self.resolve_existing_path(p))
298 .transpose()?
299 .unwrap_or_else(|| self.working_dir.clone());
300
301 let command = match self.shell_kind {
302 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
303 .verb("grep")
304 .flag("n")
305 .flag_if(recursive, "r")
306 .value(format_pattern(ShellKind::Unix, pattern))
307 .value(format_path(ShellKind::Unix, &target))
308 .build(),
309 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
310 .verb("Select-String")
311 .named("Pattern", format_pattern(ShellKind::Windows, pattern))
312 .named("Path", format_path(ShellKind::Windows, &target))
313 .value("-SimpleMatch")
314 .flag_if(recursive, "Recurse")
315 .build(),
316 };
317
318 let invocation = CommandInvocation::new(
319 self.shell_kind,
320 command,
321 CommandCategory::Search,
322 self.working_dir.clone(),
323 )
324 .with_paths(vec![target]);
325
326 let output = self.execute_invocation(invocation)?;
327 if output.status.success() {
328 return Ok(output.stdout);
329 }
330
331 if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
332 Ok(String::new())
333 } else {
334 Err(anyhow!(
335 "search command failed: {}",
336 if output.stderr.trim().is_empty() {
337 output.stdout
338 } else {
339 output.stderr
340 }
341 ))
342 }
343 }
344
345 fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
346 self.policy.check(&invocation)?;
347 self.executor.execute(&invocation)
348 }
349
350 fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
351 let output = self.execute_invocation(invocation.clone())?;
352 if output.status.success() {
353 Ok(output)
354 } else {
355 Err(anyhow!(
356 "command `{}` failed: {}",
357 invocation.command,
358 if output.stderr.trim().is_empty() {
359 output.stdout
360 } else {
361 output.stderr
362 }
363 ))
364 }
365 }
366
367 fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
368 let path = self.resolve_path(raw);
369 if !path.exists() {
370 bail!("path `{}` does not exist", path.display());
371 }
372
373 let canonical = self.cached_canonicalize(&path)?;
374
375 self.ensure_within_workspace(&canonical)?;
376 Ok(canonical)
377 }
378
379 fn resolve_path(&self, raw: &str) -> PathBuf {
380 let candidate = Path::new(raw);
381 let joined = if candidate.is_absolute() {
382 candidate.to_path_buf()
383 } else {
384 self.working_dir.join(candidate)
385 };
386 joined.clean()
387 }
388
389 fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
390 if let Ok(metadata) = fs::symlink_metadata(candidate)
391 && metadata.file_type().is_symlink()
392 {
393 let canonical = self.cached_canonicalize(candidate)?;
394 return self.ensure_within_workspace(&canonical);
395 }
396
397 if candidate.exists() {
398 let canonical = self.cached_canonicalize(candidate)?;
399 self.ensure_within_workspace(&canonical)
400 } else {
401 let parent = self.canonicalize_existing_parent(candidate)?;
402 self.ensure_within_workspace(&parent)
403 }
404 }
405
406 fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
407 let mut current = candidate.parent();
408 while let Some(path) = current {
409 if path.exists() {
410 return self.cached_canonicalize(path);
411 }
412 current = path.parent();
413 }
414
415 Ok(self.working_dir.clone())
416 }
417
418 fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
419 vtcode_commons::paths::ensure_path_within_workspace(candidate, &self.workspace_root)
422 .map_err(|error| {
423 error.context(format!(
424 "path `{}` escapes workspace root `{}`",
425 candidate.display(),
426 self.workspace_root.display()
427 ))
428 })?;
429 Ok(())
430 }
431}
432
433fn default_shell_kind() -> ShellKind {
434 if cfg!(windows) {
435 ShellKind::Windows
436 } else {
437 ShellKind::Unix
438 }
439}
440
441fn join_command(parts: Vec<String>) -> String {
442 parts
443 .into_iter()
444 .filter(|part| !part.is_empty())
445 .collect::<Vec<_>>()
446 .join(" ")
447}
448
449fn format_path(shell: ShellKind, path: &Path) -> String {
450 match shell {
451 ShellKind::Unix => escape(path.to_string_lossy()).into_owned(),
452 ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
453 }
454}
455
456fn format_pattern(shell: ShellKind, pattern: &str) -> String {
457 match shell {
458 ShellKind::Unix => escape(pattern.into()).into_owned(),
459 ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
460 }
461}
462
463struct ShellCommand {
469 shell: ShellKind,
470 parts: Vec<String>,
471}
472
473impl ShellCommand {
474 fn new(shell: ShellKind) -> Self {
475 Self {
476 shell,
477 parts: Vec::with_capacity(6),
478 }
479 }
480
481 fn verb(mut self, name: &str) -> Self {
483 self.parts.push(name.to_string());
484 self
485 }
486
487 fn flag(mut self, name: &str) -> Self {
489 self.parts.push(format!("-{name}"));
490 self
491 }
492
493 fn flag_if(mut self, condition: bool, name: &str) -> Self {
495 if condition {
496 self.parts.push(format!("-{name}"));
497 }
498 self
499 }
500
501 fn named(mut self, name: &str, value: impl Into<String>) -> Self {
505 let v = value.into();
506 let token = match self.shell {
507 ShellKind::Unix => v,
508 ShellKind::Windows => format!("-{name} {v}"),
509 };
510 self.parts.push(token);
511 self
512 }
513
514 fn value(mut self, value: impl Into<String>) -> Self {
516 self.parts.push(value.into());
517 self
518 }
519
520 fn build(self) -> String {
521 join_command(self.parts)
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
529 use crate::policy::AllowAllPolicy;
530 use assert_fs::TempDir;
531 use std::sync::{Arc, Mutex};
532
533 #[derive(Clone, Default)]
534 struct RecordingExecutor {
535 invocations: Arc<Mutex<Vec<CommandInvocation>>>,
536 }
537
538 impl CommandExecutor for RecordingExecutor {
539 fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
540 self.invocations
541 .lock()
542 .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
543 .push(invocation.clone());
544 Ok(CommandOutput {
545 status: CommandStatus::new(true, Some(0)),
546 stdout: String::new(),
547 stderr: String::new(),
548 })
549 }
550 }
551
552 #[test]
553 fn cd_updates_working_directory() -> Result<()> {
554 let dir = TempDir::new()?;
555 let nested = dir.path().join("nested");
556 fs::create_dir(&nested)?;
557 let runner = BashRunner::new(
558 dir.path().to_path_buf(),
559 RecordingExecutor::default(),
560 AllowAllPolicy,
561 );
562 let mut runner = runner?;
563 runner.cd("nested")?;
564 let expected = nested.canonicalize()?;
566 assert_eq!(runner.working_dir(), expected);
567 Ok(())
568 }
569
570 #[test]
571 fn mkdir_records_invocation() -> Result<()> {
572 let dir = TempDir::new()?;
573 let executor = RecordingExecutor::default();
574 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
575 runner?.mkdir("new_dir", true)?;
576 let invocations = executor
577 .invocations
578 .lock()
579 .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
580 assert_eq!(invocations.len(), 1);
581 assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
582 Ok(())
583 }
584}