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 if !candidate.starts_with(&self.workspace_root) {
420 bail!(
421 "path `{}` escapes workspace root `{}`",
422 candidate.display(),
423 self.workspace_root.display()
424 );
425 }
426 Ok(())
427 }
428}
429
430fn default_shell_kind() -> ShellKind {
431 if cfg!(windows) {
432 ShellKind::Windows
433 } else {
434 ShellKind::Unix
435 }
436}
437
438fn join_command(parts: Vec<String>) -> String {
439 parts
440 .into_iter()
441 .filter(|part| !part.is_empty())
442 .collect::<Vec<_>>()
443 .join(" ")
444}
445
446fn format_path(shell: ShellKind, path: &Path) -> String {
447 match shell {
448 ShellKind::Unix => escape(path.to_string_lossy()).into_owned(),
449 ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
450 }
451}
452
453fn format_pattern(shell: ShellKind, pattern: &str) -> String {
454 match shell {
455 ShellKind::Unix => escape(pattern.into()).into_owned(),
456 ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
457 }
458}
459
460struct ShellCommand {
466 shell: ShellKind,
467 parts: Vec<String>,
468}
469
470impl ShellCommand {
471 fn new(shell: ShellKind) -> Self {
472 Self {
473 shell,
474 parts: Vec::with_capacity(6),
475 }
476 }
477
478 fn verb(mut self, name: &str) -> Self {
480 self.parts.push(name.to_string());
481 self
482 }
483
484 fn flag(mut self, name: &str) -> Self {
486 self.parts.push(format!("-{}", name));
487 self
488 }
489
490 fn flag_if(mut self, condition: bool, name: &str) -> Self {
492 if condition {
493 self.parts.push(format!("-{}", name));
494 }
495 self
496 }
497
498 fn named(mut self, name: &str, value: impl Into<String>) -> Self {
502 let v = value.into();
503 let token = match self.shell {
504 ShellKind::Unix => v,
505 ShellKind::Windows => format!("-{} {}", name, v),
506 };
507 self.parts.push(token);
508 self
509 }
510
511 fn value(mut self, value: impl Into<String>) -> Self {
513 self.parts.push(value.into());
514 self
515 }
516
517 fn build(self) -> String {
518 join_command(self.parts)
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
526 use crate::policy::AllowAllPolicy;
527 use assert_fs::TempDir;
528 use std::sync::{Arc, Mutex};
529
530 #[derive(Clone, Default)]
531 struct RecordingExecutor {
532 invocations: Arc<Mutex<Vec<CommandInvocation>>>,
533 }
534
535 impl CommandExecutor for RecordingExecutor {
536 fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
537 self.invocations
538 .lock()
539 .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
540 .push(invocation.clone());
541 Ok(CommandOutput {
542 status: CommandStatus::new(true, Some(0)),
543 stdout: String::new(),
544 stderr: String::new(),
545 })
546 }
547 }
548
549 #[test]
550 fn cd_updates_working_directory() -> Result<()> {
551 let dir = TempDir::new()?;
552 let nested = dir.path().join("nested");
553 fs::create_dir(&nested)?;
554 let runner = BashRunner::new(
555 dir.path().to_path_buf(),
556 RecordingExecutor::default(),
557 AllowAllPolicy,
558 );
559 let mut runner = runner?;
560 runner.cd("nested")?;
561 let expected = nested.canonicalize()?;
563 assert_eq!(runner.working_dir(), expected);
564 Ok(())
565 }
566
567 #[test]
568 fn mkdir_records_invocation() -> Result<()> {
569 let dir = TempDir::new()?;
570 let executor = RecordingExecutor::default();
571 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
572 runner?.mkdir("new_dir", true)?;
573 let invocations = executor
574 .invocations
575 .lock()
576 .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
577 assert_eq!(invocations.len(), 1);
578 assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
579 Ok(())
580 }
581}