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