1use crate::executor::{CommandCategory, CommandExecutor, CommandInvocation, CommandOutput, ShellKind};
2use crate::policy::CommandPolicy;
3use anyhow::{Context, Result, anyhow, bail};
4use path_clean::PathClean;
5use shell_escape::escape;
6use std::fs;
7use std::path::{Path, PathBuf};
8use vtcode_commons::{WorkspacePaths, canonicalize};
9
10pub struct BashRunner<E, P> {
11 executor: E,
12 policy: P,
13 workspace_root: PathBuf,
14 working_dir: PathBuf,
15 shell_kind: ShellKind,
16}
17
18impl<E, P> BashRunner<E, P>
19where
20 E: CommandExecutor,
21 P: CommandPolicy,
22{
23 pub fn new(workspace_root: PathBuf, executor: E, policy: P) -> Result<Self> {
24 if !workspace_root.exists() {
25 bail!("workspace root `{}` does not exist", workspace_root.display());
26 }
27
28 let canonical_root = canonicalize(&workspace_root)
29 .with_context(|| format!("failed to canonicalize `{}`", workspace_root.display()))?;
30
31 Ok(Self {
32 executor,
33 policy,
34 workspace_root: canonical_root.clone(),
35 working_dir: canonical_root,
36 shell_kind: default_shell_kind(),
37 })
38 }
39
40 pub fn from_workspace_paths<W>(paths: &W, executor: E, policy: P) -> Result<Self>
41 where
42 W: WorkspacePaths,
43 {
44 Self::new(paths.workspace_root().to_path_buf(), executor, policy)
45 }
46
47 pub fn workspace_root(&self) -> &Path {
48 &self.workspace_root
49 }
50
51 fn working_dir(&self) -> &Path {
52 &self.working_dir
53 }
54
55 pub fn shell_kind(&self) -> ShellKind {
56 self.shell_kind
57 }
58
59 fn resolve_canonical_path(&self, path: &Path) -> Result<PathBuf> {
61 canonicalize(path).with_context(|| format!("failed to canonicalize `{}`", path.display()))
62 }
63
64 pub fn cd(&mut self, path: &str) -> Result<()> {
65 let candidate = self.resolve_path(path)?;
66 if !candidate.exists() {
67 bail!("directory `{}` does not exist", candidate.display());
68 }
69 if !candidate.is_dir() {
70 bail!("path `{}` is not a directory", candidate.display());
71 }
72
73 let canonical = self.resolve_canonical_path(&candidate)?;
74
75 self.ensure_within_workspace(&canonical)?;
76
77 let invocation = CommandInvocation::new(
78 self.shell_kind,
79 format!("cd {}", format_path(self.shell_kind, &canonical)),
80 CommandCategory::ChangeDirectory,
81 canonical.clone(),
82 )
83 .with_paths(vec![canonical.clone()]);
84
85 self.policy.check(&invocation)?;
86 self.working_dir = canonical;
87 Ok(())
88 }
89
90 pub fn ls(&self, path: Option<&str>, show_hidden: bool) -> Result<String> {
91 let target = path
92 .map(|p| self.resolve_existing_path(p))
93 .transpose()?
94 .unwrap_or_else(|| self.working_dir.clone());
95
96 let command = match self.shell_kind {
97 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
98 .verb("ls")
99 .flag(if show_hidden { "la" } else { "l" })
100 .value(format_path(ShellKind::Unix, &target))
101 .build(),
102 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
103 .verb("Get-ChildItem")
104 .flag_if(show_hidden, "Force")
105 .named("Path", format_path(ShellKind::Windows, &target))
106 .build(),
107 };
108
109 let invocation =
110 CommandInvocation::new(self.shell_kind, command, CommandCategory::ListDirectory, self.working_dir.clone())
111 .with_paths(vec![target]);
112
113 let output = self.expect_success(invocation)?;
114 Ok(output.stdout)
115 }
116
117 pub fn pwd(&self) -> Result<String> {
118 let command = match self.shell_kind {
119 ShellKind::Unix => ShellCommand::new(ShellKind::Unix).verb("pwd").build(),
120 ShellKind::Windows => ShellCommand::new(ShellKind::Windows).verb("Get-Location").build(),
121 };
122 let invocation =
123 CommandInvocation::new(self.shell_kind, command, CommandCategory::PrintDirectory, self.working_dir.clone());
124 self.policy.check(&invocation)?;
125 Ok(self.working_dir.to_string_lossy().into_owned())
126 }
127
128 pub fn mkdir(&self, path: &str, parents: bool) -> Result<()> {
129 let target = self.resolve_path(path)?;
130 self.ensure_mutation_target_within_workspace(&target)?;
131
132 let command = match self.shell_kind {
133 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
134 .verb("mkdir")
135 .flag_if(parents, "p")
136 .value(format_path(ShellKind::Unix, &target))
137 .build(),
138 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
139 .verb("New-Item")
140 .flag("ItemType")
141 .value("Directory")
142 .flag_if(parents, "Force")
143 .named("Path", format_path(ShellKind::Windows, &target))
144 .build(),
145 };
146
147 let invocation = CommandInvocation::new(
148 self.shell_kind,
149 command,
150 CommandCategory::CreateDirectory,
151 self.working_dir.clone(),
152 )
153 .with_paths(vec![target]);
154
155 self.expect_success(invocation).map(|_| ())
156 }
157
158 pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
159 let target = self.resolve_path(path)?;
160 let target_canonical = if target.exists() {
165 Some(self.resolve_canonical_path(&target)?)
166 } else {
167 None
168 };
169 if let Some(canonical) = &target_canonical {
170 self.ensure_not_workspace_root(canonical)?;
171 }
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("rm")
177 .flag_if(recursive, "r")
178 .flag_if(force, "f")
179 .value(format_path(ShellKind::Unix, &target))
180 .build(),
181 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
182 .verb("Remove-Item")
183 .flag_if(recursive, "Recurse")
184 .flag_if(force, "Force")
185 .named("Path", format_path(ShellKind::Windows, &target))
186 .build(),
187 };
188
189 let invocation =
190 CommandInvocation::new(self.shell_kind, command, CommandCategory::Remove, self.working_dir.clone())
191 .with_paths(vec![target]);
192
193 self.expect_success(invocation).map(|_| ())
194 }
195
196 pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
197 let source_path = self.resolve_existing_path(source)?;
198 let dest_path = self.resolve_path(dest)?;
199 self.ensure_mutation_target_within_workspace(&dest_path)?;
200
201 let command = match self.shell_kind {
202 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
203 .verb("cp")
204 .flag_if(recursive, "r")
205 .value(format_path(ShellKind::Unix, &source_path))
206 .value(format_path(ShellKind::Unix, &dest_path))
207 .build(),
208 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
209 .verb("Copy-Item")
210 .named("Path", format_path(ShellKind::Windows, &source_path))
211 .named("Destination", format_path(ShellKind::Windows, &dest_path))
212 .flag_if(recursive, "Recurse")
213 .build(),
214 };
215
216 let invocation =
217 CommandInvocation::new(self.shell_kind, command, CommandCategory::Copy, self.working_dir.clone())
218 .with_paths(vec![source_path, dest_path]);
219
220 self.expect_success(invocation).map(|_| ())
221 }
222
223 pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
224 let source_path = self.resolve_existing_path(source)?;
225 let dest_path = self.resolve_path(dest)?;
226 self.ensure_mutation_target_within_workspace(&dest_path)?;
227
228 let command = match self.shell_kind {
229 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
230 .verb("mv")
231 .value(format_path(ShellKind::Unix, &source_path))
232 .value(format_path(ShellKind::Unix, &dest_path))
233 .build(),
234 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
235 .verb("Move-Item")
236 .named("Path", format_path(ShellKind::Windows, &source_path))
237 .named("Destination", format_path(ShellKind::Windows, &dest_path))
238 .build(),
239 };
240
241 let invocation =
242 CommandInvocation::new(self.shell_kind, command, CommandCategory::Move, self.working_dir.clone())
243 .with_paths(vec![source_path, dest_path]);
244
245 self.expect_success(invocation).map(|_| ())
246 }
247
248 pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
249 let target = path
250 .map(|p| self.resolve_existing_path(p))
251 .transpose()?
252 .unwrap_or_else(|| self.working_dir.clone());
253
254 let command = match self.shell_kind {
255 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
256 .verb("grep")
257 .flag("n")
258 .flag_if(recursive, "r")
259 .value(format_pattern(ShellKind::Unix, pattern))
260 .value(format_path(ShellKind::Unix, &target))
261 .build(),
262 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
263 .verb("Select-String")
264 .named("Pattern", format_pattern(ShellKind::Windows, pattern))
265 .named("Path", format_path(ShellKind::Windows, &target))
266 .value("-SimpleMatch")
267 .flag_if(recursive, "Recurse")
268 .build(),
269 };
270
271 let invocation =
272 CommandInvocation::new(self.shell_kind, command, CommandCategory::Search, self.working_dir.clone())
273 .with_paths(vec![target]);
274
275 let output = self.execute_invocation(invocation)?;
276 if output.status.success() {
277 return Ok(output.stdout);
278 }
279
280 if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
281 Ok(String::new())
282 } else {
283 Err(anyhow!(
284 "search command failed: {}",
285 if output.stderr.trim().is_empty() {
286 output.stdout
287 } else {
288 output.stderr
289 }
290 ))
291 }
292 }
293
294 fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
295 self.policy.check(&invocation)?;
296 self.executor.execute(&invocation)
297 }
298
299 fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
300 let output = self.execute_invocation(invocation.clone())?;
301 if output.status.success() {
302 Ok(output)
303 } else {
304 Err(anyhow!(
305 "command `{}` failed: {}",
306 invocation.command,
307 if output.stderr.trim().is_empty() {
308 output.stdout
309 } else {
310 output.stderr
311 }
312 ))
313 }
314 }
315
316 fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
317 let path = self.resolve_path(raw)?;
318 if !path.exists() {
319 bail!("path `{}` does not exist", path.display());
320 }
321
322 let canonical = self.resolve_canonical_path(&path)?;
323
324 self.ensure_within_workspace(&canonical)?;
325 Ok(canonical)
326 }
327
328 fn resolve_path(&self, raw: &str) -> Result<PathBuf> {
329 if raw.trim().is_empty() {
334 bail!("path must not be empty");
335 }
336 let candidate = Path::new(raw);
337 let joined = if candidate.is_absolute() {
338 candidate.to_path_buf()
339 } else {
340 self.working_dir.join(candidate)
341 };
342 Ok(joined.clean())
343 }
344
345 fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
346 if let Ok(metadata) = fs::symlink_metadata(candidate)
347 && metadata.file_type().is_symlink()
348 {
349 let canonical = self.resolve_canonical_path(candidate)?;
350 return self.ensure_within_workspace(&canonical);
351 }
352
353 if candidate.exists() {
354 let canonical = self.resolve_canonical_path(candidate)?;
355 self.ensure_within_workspace(&canonical)
356 } else {
357 let parent = self.canonicalize_existing_parent(candidate)?;
358 self.ensure_within_workspace(&parent)
359 }
360 }
361
362 fn ensure_not_workspace_root(&self, canonical_candidate: &Path) -> Result<()> {
368 if canonical_candidate == self.workspace_root {
369 bail!("refusing to operate on the workspace root itself (`{}`)", canonical_candidate.display());
370 }
371 Ok(())
372 }
373
374 fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
375 let mut current = candidate.parent();
376 while let Some(path) = current {
377 if path.exists() {
378 return self.resolve_canonical_path(path);
379 }
380 current = path.parent();
381 }
382
383 Ok(self.working_dir.clone())
384 }
385
386 fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
387 vtcode_commons::paths::ensure_path_within_workspace(candidate, &self.workspace_root).map_err(|error| {
390 error.context(format!(
391 "path `{}` escapes workspace root `{}`",
392 candidate.display(),
393 self.workspace_root.display()
394 ))
395 })?;
396 Ok(())
397 }
398}
399
400fn default_shell_kind() -> ShellKind {
401 if cfg!(windows) {
402 ShellKind::Windows
403 } else {
404 ShellKind::Unix
405 }
406}
407
408fn join_command(parts: Vec<String>) -> String {
409 parts.into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" ")
410}
411
412fn format_path(shell: ShellKind, path: &Path) -> String {
413 match shell {
414 ShellKind::Unix => escape(path.to_string_lossy()).into_owned(),
415 ShellKind::Windows => format!("'{}'", path.to_string_lossy().replace('\'', "''")),
416 }
417}
418
419fn format_pattern(shell: ShellKind, pattern: &str) -> String {
420 match shell {
421 ShellKind::Unix => escape(pattern.into()).into_owned(),
422 ShellKind::Windows => format!("'{}'", pattern.replace('\'', "''")),
423 }
424}
425
426struct ShellCommand {
432 shell: ShellKind,
433 parts: Vec<String>,
434}
435
436impl ShellCommand {
437 fn new(shell: ShellKind) -> Self {
438 Self { shell, parts: Vec::new() }
439 }
440
441 fn verb(mut self, name: &str) -> Self {
443 self.parts.push(name.to_string());
444 self
445 }
446
447 fn flag(mut self, name: &str) -> Self {
449 self.parts.push(format!("-{name}"));
450 self
451 }
452
453 fn flag_if(mut self, condition: bool, name: &str) -> Self {
455 if condition {
456 self.parts.push(format!("-{name}"));
457 }
458 self
459 }
460
461 fn named(mut self, name: &str, value: impl Into<String>) -> Self {
465 let v = value.into();
466 let token = match self.shell {
467 ShellKind::Unix => v,
468 ShellKind::Windows => format!("-{name} {v}"),
469 };
470 self.parts.push(token);
471 self
472 }
473
474 fn value(mut self, value: impl Into<String>) -> Self {
476 self.parts.push(value.into());
477 self
478 }
479
480 fn build(self) -> String {
481 join_command(self.parts)
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
489 use crate::policy::AllowAllPolicy;
490 use assert_fs::TempDir;
491 use std::sync::{Arc, Mutex};
492
493 #[derive(Clone, Default)]
494 struct RecordingExecutor {
495 invocations: Arc<Mutex<Vec<CommandInvocation>>>,
496 }
497
498 impl CommandExecutor for RecordingExecutor {
499 fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
500 self.invocations
501 .lock()
502 .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
503 .push(invocation.clone());
504 Ok(CommandOutput {
505 status: CommandStatus::new(true, Some(0)),
506 stdout: String::new(),
507 stderr: String::new(),
508 })
509 }
510 }
511
512 #[test]
513 fn cd_updates_working_directory() -> Result<()> {
514 let dir = TempDir::new()?;
515 let nested = dir.path().join("nested");
516 fs::create_dir(&nested)?;
517 let runner = BashRunner::new(dir.path().to_path_buf(), RecordingExecutor::default(), AllowAllPolicy);
518 let mut runner = runner?;
519 runner.cd("nested")?;
520 let expected = canonicalize(&nested)?;
522 assert_eq!(runner.working_dir(), expected);
523 Ok(())
524 }
525
526 #[test]
527 fn rm_rejects_empty_path_instead_of_targeting_workspace_root() -> Result<()> {
528 let dir = TempDir::new()?;
529 let executor = RecordingExecutor::default();
530 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
531
532 for empty in ["", " ", "."] {
533 let result = runner.rm(empty, true, true);
534 assert!(result.is_err(), "rm({empty:?}) must be rejected");
535 }
536 assert!(
537 executor.invocations.lock().expect("invocations lock").is_empty(),
538 "no command must be built for empty paths"
539 );
540 Ok(())
541 }
542
543 #[test]
544 fn rm_rejects_workspace_root_via_parent_traversal_and_absolute_alias() -> Result<()> {
545 let dir = TempDir::new()?;
546 let executor = RecordingExecutor::default();
547 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
548 let mut runner = runner;
549 let canonical_root = runner.working_dir().to_path_buf();
550
551 fs::create_dir_all(runner.working_dir().join("sub"))?;
553 runner.cd("sub")?;
554 assert!(runner.rm("..", true, true).is_err(), "rm('..') from sub must be rejected");
555 let alias = dir.path().to_path_buf();
557 if alias != canonical_root {
558 assert!(
559 runner.rm(&alias.to_string_lossy(), true, true).is_err(),
560 "rm via non-canonical absolute alias must be rejected"
561 );
562 }
563 assert!(
565 runner.rm(&canonical_root.to_string_lossy(), true, true).is_err(),
566 "rm on the canonical root must be rejected"
567 );
568 let link = runner.working_dir().join("root-link");
570 #[cfg(unix)]
571 std::os::unix::fs::symlink(&canonical_root, &link).expect("create root symlink");
572 #[cfg(unix)]
573 assert!(
574 runner.rm(&link.to_string_lossy(), true, true).is_err(),
575 "rm through a symlink to the root must be rejected"
576 );
577 #[cfg(not(unix))]
578 let _ = &link;
579 assert!(
580 executor.invocations.lock().expect("invocations lock").is_empty(),
581 "no command must be built for root targets"
582 );
583 Ok(())
584 }
585
586 #[test]
587 fn mkdir_records_invocation() -> Result<()> {
588 let dir = TempDir::new()?;
589 let executor = RecordingExecutor::default();
590 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
591 runner?.mkdir("new_dir", true)?;
592 let invocations = executor
593 .invocations
594 .lock()
595 .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
596 assert_eq!(invocations.len(), 1);
597 assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
598 Ok(())
599 }
600 #[cfg(unix)]
601 #[test]
602 fn symlink_retargeting_cannot_reuse_an_earlier_authorization() -> Result<()> {
603 let root = TempDir::new()?;
604 let outside = TempDir::new()?;
605 let inside = root.path().join("inside");
606 fs::create_dir(&inside)?;
607 let link = root.path().join("link");
608 std::os::unix::fs::symlink(&inside, &link)?;
609 let executor = RecordingExecutor::default();
610 let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
611 runner.ls(Some("link"), false)?;
612 fs::remove_file(&link)?;
613 std::os::unix::fs::symlink(outside.path(), &link)?;
614 assert!(runner.ls(Some("link"), false).is_err());
615 assert!(runner.mkdir("link/new-directory", false).is_err());
616 assert_eq!(executor.invocations.lock().expect("invocations lock").len(), 1);
617 Ok(())
618 }
619
620 #[cfg(unix)]
621 #[test]
622 fn shell_command_keeps_metacharacters_inside_a_single_literal_argument() -> Result<()> {
623 let root = TempDir::new()?;
624 let executor = RecordingExecutor::default();
625 let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
626 let filename = "literal;$(echo injected)'file";
627 runner.mkdir(filename, false)?;
628 let invocations = executor.invocations.lock().expect("invocations lock");
629 let command = &invocations[0].command;
630 let output = std::process::Command::new("sh").arg("-c").arg(command).output()?;
631 assert!(output.status.success());
632 assert!(runner.workspace_root().join(filename).is_dir());
633 Ok(())
634 }
635}