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