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("LiteralPath", 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 => {
152 let target = format_path(ShellKind::Windows, &target);
153 let validate = if parents {
154 ""
155 } else {
156 "if ([System.IO.Directory]::Exists($p)) { throw 'Directory already exists' }; if (-not [System.IO.Directory]::Exists([System.IO.Path]::GetDirectoryName($p))) { throw 'Parent directory does not exist' }; "
157 };
158 format!("$p = {target}; {validate}[void][System.IO.Directory]::CreateDirectory($p)")
159 }
160 };
161
162 let invocation = CommandInvocation::new(
163 self.shell_kind,
164 command,
165 CommandCategory::CreateDirectory,
166 self.working_dir.clone(),
167 )
168 .with_paths(vec![target])
169 .with_create_parents(parents);
170
171 self.expect_success(invocation).map(|_| ())
172 }
173
174 pub fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
175 self.rm_with_options(path, RmOptions { recursive, force })
176 }
177
178 pub fn rm_with_options(&self, path: &str, options: RmOptions) -> Result<()> {
180 let target = self.resolve_path(path)?;
181 let target_canonical = if target.exists() {
186 Some(self.resolve_canonical_path(&target)?)
187 } else {
188 None
189 };
190 if let Some(canonical) = &target_canonical {
191 self.ensure_not_workspace_root(canonical)?;
192 }
193 self.ensure_mutation_target_within_workspace(&target)?;
194
195 let command = match self.shell_kind {
196 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
197 .verb("rm")
198 .flag_if(options.recursive, "r")
199 .flag_if(options.force, "f")
200 .value(format_path(ShellKind::Unix, &target))
201 .build(),
202 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
203 .verb("Remove-Item")
204 .flag_if(options.recursive, "Recurse")
205 .flag_if(options.force, "Force")
206 .named("LiteralPath", format_path(ShellKind::Windows, &target))
207 .build(),
208 };
209
210 let invocation =
211 CommandInvocation::new(self.shell_kind, command, CommandCategory::Remove, self.working_dir.clone())
212 .with_paths(vec![target]);
213
214 self.expect_success(invocation).map(|_| ())
215 }
216
217 pub fn cp(&self, source: &str, dest: &str, recursive: bool) -> Result<()> {
218 let source_path = self.resolve_existing_path(source)?;
219 let dest_path = self.resolve_path(dest)?;
220 self.ensure_mutation_target_within_workspace(&dest_path)?;
221
222 let command = match self.shell_kind {
223 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
224 .verb("cp")
225 .flag_if(recursive, "r")
226 .value(format_path(ShellKind::Unix, &source_path))
227 .value(format_path(ShellKind::Unix, &dest_path))
228 .build(),
229 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
230 .verb("Copy-Item")
231 .named("LiteralPath", format_path(ShellKind::Windows, &source_path))
232 .named("Destination", format_path(ShellKind::Windows, &dest_path))
233 .flag_if(recursive, "Recurse")
234 .build(),
235 };
236
237 let invocation =
238 CommandInvocation::new(self.shell_kind, command, CommandCategory::Copy, self.working_dir.clone())
239 .with_paths(vec![source_path, dest_path]);
240
241 self.expect_success(invocation).map(|_| ())
242 }
243
244 pub fn mv(&self, source: &str, dest: &str) -> Result<()> {
245 let source_path = self.resolve_existing_path(source)?;
246 let dest_path = self.resolve_path(dest)?;
247 self.ensure_mutation_target_within_workspace(&dest_path)?;
248
249 let command = match self.shell_kind {
250 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
251 .verb("mv")
252 .value(format_path(ShellKind::Unix, &source_path))
253 .value(format_path(ShellKind::Unix, &dest_path))
254 .build(),
255 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
256 .verb("Move-Item")
257 .named("LiteralPath", format_path(ShellKind::Windows, &source_path))
258 .named("Destination", format_path(ShellKind::Windows, &dest_path))
259 .build(),
260 };
261
262 let invocation =
263 CommandInvocation::new(self.shell_kind, command, CommandCategory::Move, self.working_dir.clone())
264 .with_paths(vec![source_path, dest_path]);
265
266 self.expect_success(invocation).map(|_| ())
267 }
268
269 pub fn grep(&self, pattern: &str, path: Option<&str>, recursive: bool) -> Result<String> {
270 let target = path
271 .map(|p| self.resolve_existing_path(p))
272 .transpose()?
273 .unwrap_or_else(|| self.working_dir.clone());
274
275 let command = match self.shell_kind {
276 ShellKind::Unix => ShellCommand::new(ShellKind::Unix)
277 .verb("grep")
278 .flag("n")
279 .flag_if(recursive, "r")
280 .value(format_pattern(ShellKind::Unix, pattern))
281 .value(format_path(ShellKind::Unix, &target))
282 .build(),
283 ShellKind::Windows if recursive => format!(
284 "Get-ChildItem -LiteralPath {} -File -Recurse | Select-String -Pattern {} -SimpleMatch",
285 format_path(ShellKind::Windows, &target),
286 format_pattern(ShellKind::Windows, pattern)
287 ),
288 ShellKind::Windows => ShellCommand::new(ShellKind::Windows)
289 .verb("Select-String")
290 .named("Pattern", format_pattern(ShellKind::Windows, pattern))
291 .named("LiteralPath", format_path(ShellKind::Windows, &target))
292 .value("-SimpleMatch")
293 .build(),
294 };
295
296 let invocation =
297 CommandInvocation::new(self.shell_kind, command, CommandCategory::Search, self.working_dir.clone())
298 .with_paths(vec![target]);
299
300 let output = self.execute_invocation(invocation)?;
301 if output.status.success() {
302 return Ok(output.stdout);
303 }
304
305 if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
306 Ok(String::new())
307 } else {
308 Err(anyhow!(
309 "search command failed: {}",
310 if output.stderr.trim().is_empty() {
311 output.stdout
312 } else {
313 output.stderr
314 }
315 ))
316 }
317 }
318
319 fn execute_invocation(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
320 self.policy.check(&invocation)?;
321 self.executor.execute(&invocation)
322 }
323
324 fn expect_success(&self, invocation: CommandInvocation) -> Result<CommandOutput> {
325 let output = self.execute_invocation(invocation.clone())?;
326 if output.status.success() {
327 Ok(output)
328 } else {
329 Err(anyhow!(
330 "command `{}` failed: {}",
331 invocation.command,
332 if output.stderr.trim().is_empty() {
333 output.stdout
334 } else {
335 output.stderr
336 }
337 ))
338 }
339 }
340
341 fn resolve_existing_path(&self, raw: &str) -> Result<PathBuf> {
342 let path = self.resolve_path(raw)?;
343 if !path.exists() {
344 bail!("path `{}` does not exist", path.display());
345 }
346
347 let canonical = self.resolve_canonical_path(&path)?;
348
349 self.ensure_within_workspace(&canonical)?;
350 Ok(canonical)
351 }
352
353 fn resolve_path(&self, raw: &str) -> Result<PathBuf> {
354 if raw.trim().is_empty() {
359 bail!("path must not be empty");
360 }
361 let candidate = Path::new(raw);
362 let joined = if candidate.is_absolute() {
363 candidate.to_path_buf()
364 } else {
365 self.working_dir.join(candidate)
366 };
367 Ok(joined.clean())
368 }
369
370 fn ensure_mutation_target_within_workspace(&self, candidate: &Path) -> Result<()> {
371 if let Ok(metadata) = fs::symlink_metadata(candidate)
372 && metadata.file_type().is_symlink()
373 {
374 let canonical = self.resolve_canonical_path(candidate)?;
375 return self.ensure_within_workspace(&canonical);
376 }
377
378 if candidate.exists() {
379 let canonical = self.resolve_canonical_path(candidate)?;
380 self.ensure_within_workspace(&canonical)
381 } else {
382 let parent = self.canonicalize_existing_parent(candidate)?;
383 self.ensure_within_workspace(&parent)
384 }
385 }
386
387 fn ensure_not_workspace_root(&self, canonical_candidate: &Path) -> Result<()> {
393 if canonical_candidate == self.workspace_root {
394 bail!("refusing to operate on the workspace root itself (`{}`)", canonical_candidate.display());
395 }
396 Ok(())
397 }
398
399 fn canonicalize_existing_parent(&self, candidate: &Path) -> Result<PathBuf> {
400 let mut current = candidate.parent();
401 while let Some(path) = current {
402 if path.exists() {
403 return self.resolve_canonical_path(path);
404 }
405 current = path.parent();
406 }
407
408 Ok(self.working_dir.clone())
409 }
410
411 fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
412 vtcode_commons::paths::ensure_path_within_workspace(candidate, &self.workspace_root).map_err(|error| {
415 error.context(format!(
416 "path `{}` escapes workspace root `{}`",
417 candidate.display(),
418 self.workspace_root.display()
419 ))
420 })?;
421 Ok(())
422 }
423}
424
425fn default_shell_kind() -> ShellKind {
426 if cfg!(windows) {
427 ShellKind::Windows
428 } else {
429 ShellKind::Unix
430 }
431}
432
433fn join_command(parts: Vec<String>) -> String {
434 parts.into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" ")
435}
436
437fn format_path(shell: ShellKind, path: &Path) -> String {
438 match shell {
439 ShellKind::Unix => escape(path.to_string_lossy()).into_owned(),
440 ShellKind::Windows => powershell_data(&path.to_string_lossy()),
441 }
442}
443
444fn format_pattern(shell: ShellKind, pattern: &str) -> String {
445 match shell {
446 ShellKind::Unix => escape(pattern.into()).into_owned(),
447 ShellKind::Windows => powershell_data(pattern),
448 }
449}
450
451fn powershell_data(value: &str) -> String {
454 let bytes = value.as_bytes().iter().map(u8::to_string).collect::<Vec<_>>().join(",");
455 format!("([System.Text.Encoding]::UTF8.GetString([byte[]]@({bytes})))")
456}
457
458struct ShellCommand {
464 shell: ShellKind,
465 parts: Vec<String>,
466}
467
468impl ShellCommand {
469 fn new(shell: ShellKind) -> Self {
470 Self { shell, parts: Vec::new() }
471 }
472
473 fn verb(mut self, name: &str) -> Self {
475 self.parts.push(name.to_string());
476 self
477 }
478
479 fn flag(mut self, name: &str) -> Self {
481 self.parts.push(format!("-{name}"));
482 self
483 }
484
485 fn flag_if(mut self, condition: bool, name: &str) -> Self {
487 if condition {
488 self.parts.push(format!("-{name}"));
489 }
490 self
491 }
492
493 fn named(mut self, name: &str, value: impl Into<String>) -> Self {
497 let v = value.into();
498 let token = match self.shell {
499 ShellKind::Unix => v,
500 ShellKind::Windows => format!("-{name} {v}"),
501 };
502 self.parts.push(token);
503 self
504 }
505
506 fn value(mut self, value: impl Into<String>) -> Self {
508 self.parts.push(value.into());
509 self
510 }
511
512 fn build(self) -> String {
513 join_command(self.parts)
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::executor::{CommandInvocation, CommandOutput, CommandStatus};
521 use crate::policy::AllowAllPolicy;
522 use assert_fs::TempDir;
523 use std::sync::{Arc, Mutex};
524
525 #[derive(Clone, Default)]
526 struct RecordingExecutor {
527 invocations: Arc<Mutex<Vec<CommandInvocation>>>,
528 }
529
530 impl CommandExecutor for RecordingExecutor {
531 fn execute(&self, invocation: &CommandInvocation) -> Result<CommandOutput> {
532 self.invocations
533 .lock()
534 .map_err(|e| anyhow!("executor lock poisoned: {e}"))?
535 .push(invocation.clone());
536 Ok(CommandOutput {
537 status: CommandStatus::new(true, Some(0)),
538 stdout: String::new(),
539 stderr: String::new(),
540 })
541 }
542 }
543
544 #[test]
545 fn powershell_arguments_are_data_and_paths_are_literal() -> Result<()> {
546 let dir = TempDir::new()?;
547 let source = "item[1]’ ; Write-Output INJECTED #.txt";
548 fs::write(dir.path().join(source), "content")?;
549 let executor = RecordingExecutor::default();
550 let mut runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
551 runner.shell_kind = ShellKind::Windows;
552 runner.ls(Some(source), false)?;
553 runner.cp(source, "copy[2].txt", false)?;
554 runner.mv(source, "move[2].txt")?;
555 runner.rm(source, false, false)?;
556 runner.mkdir("new[3]", true)?;
557 runner.grep("a’ ; Write-Output INJECTED #", Some(source), false)?;
558 runner.grep("needle", Some(source), true)?;
559
560 let invocations = executor.invocations.lock().expect("invocations");
561 assert_eq!(invocations.len(), 7);
562 for invocation in invocations.iter() {
563 assert!(!invocation.command.contains("INJECTED"), "{}", invocation.command);
564 }
565 for index in [0, 1, 2, 3, 5, 6] {
566 assert!(invocations[index].command.contains("-LiteralPath"), "{}", invocations[index].command);
567 }
568 assert!(invocations[4].command.contains("[System.IO.Directory]::CreateDirectory($p)"));
569 assert!(invocations[4].create_parents);
570 assert!(invocations[6].command.contains("-File -Recurse | Select-String"));
571 assert_eq!(powershell_data("A’'"), "([System.Text.Encoding]::UTF8.GetString([byte[]]@(65,226,128,153,39)))");
572 Ok(())
573 }
574
575 #[cfg(feature = "pure-rust")]
576 #[test]
577 fn powershell_mkdir_parent_option_reaches_pure_rust_executor() -> Result<()> {
578 let dir = TempDir::new()?;
579 let mut runner =
580 BashRunner::new(dir.path().to_path_buf(), crate::executor::PureRustCommandExecutor, AllowAllPolicy)?;
581 runner.shell_kind = ShellKind::Windows;
582 assert!(runner.mkdir("missing/child", false).is_err());
583 runner.mkdir("missing/child", true)?;
584 assert!(dir.path().join("missing/child").is_dir());
585 Ok(())
586 }
587
588 #[cfg(all(feature = "pure-rust", unix))]
589 #[test]
590 fn recursive_copy_rejects_escaping_symlinks_on_both_sides() -> Result<()> {
591 use std::os::unix::fs::symlink;
592
593 let dir = TempDir::new()?;
594 let outside = TempDir::new()?;
595 let secret = outside.path().join("secret.txt");
596 fs::write(&secret, "classified")?;
597 let victim = outside.path().join("victim.txt");
598 fs::write(&victim, "untouched")?;
599
600 let source = dir.path().join("source");
601 fs::create_dir_all(source.join("real"))?;
602 fs::write(source.join("real").join("file.txt"), "data")?;
603 symlink(&secret, source.join("escape"))?;
604
605 let runner =
606 BashRunner::new(dir.path().to_path_buf(), crate::executor::PureRustCommandExecutor, AllowAllPolicy)?;
607 let error = runner.cp("source", "copy", true).expect_err("escaping source link must fail");
608 assert!(format!("{error:#}").contains("escaping"), "{error:#}");
609 assert!(!dir.path().join("copy").join("escape").exists());
610
611 fs::remove_file(source.join("escape"))?;
613 symlink(source.join("real").join("file.txt"), source.join("inner"))?;
614 runner.cp("source", "copy", true)?;
615 assert_eq!(fs::read_to_string(dir.path().join("copy").join("inner"))?, "data");
616
617 let dest = dir.path().join("planted");
620 fs::create_dir_all(&dest)?;
621 symlink(&victim, dest.join("inner"))?;
622 symlink(outside.path(), dest.join("real"))?;
623 let error = runner
624 .cp("source", "planted", true)
625 .expect_err("escaping destination link must fail");
626 assert!(format!("{error:#}").contains("escaping"), "{error:#}");
627 assert_eq!(fs::read_to_string(&victim)?, "untouched");
628 assert!(!outside.path().join("file.txt").exists());
629 Ok(())
630 }
631
632 #[test]
633 fn cd_updates_working_directory() -> Result<()> {
634 let dir = TempDir::new()?;
635 let nested = dir.path().join("nested");
636 fs::create_dir(&nested)?;
637 let runner = BashRunner::new(dir.path().to_path_buf(), RecordingExecutor::default(), AllowAllPolicy);
638 let mut runner = runner?;
639 runner.cd("nested")?;
640 let expected = canonicalize(&nested)?;
642 assert_eq!(runner.working_dir(), expected);
643 Ok(())
644 }
645
646 #[test]
647 fn rm_rejects_empty_path_instead_of_targeting_workspace_root() -> Result<()> {
648 let dir = TempDir::new()?;
649 let executor = RecordingExecutor::default();
650 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
651
652 for empty in ["", " ", "."] {
653 let result = runner.rm(empty, true, true);
654 assert!(result.is_err(), "rm({empty:?}) must be rejected");
655 }
656 assert!(
657 executor.invocations.lock().expect("invocations lock").is_empty(),
658 "no command must be built for empty paths"
659 );
660 Ok(())
661 }
662
663 #[test]
664 fn rm_with_options_matches_positional_rm() -> Result<()> {
665 let dir = TempDir::new()?;
666 let executor = RecordingExecutor::default();
667 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
668 let target = dir.path().join("named.txt");
669 fs::write(&target, "named")?;
670 runner.rm("named.txt", true, true)?;
672 runner.rm_with_options("named.txt", RmOptions { recursive: true, force: true })?;
673 runner.rm_with_options("named.txt", RmOptions::default())?;
674 let invocations = executor.invocations.lock().expect("invocations lock");
675 assert_eq!(invocations.len(), 3);
676 assert_eq!(invocations[0].command, invocations[1].command);
677 let (recursive_flag, force_flag) = match runner.shell_kind() {
678 ShellKind::Unix => ("-r", "-f"),
679 ShellKind::Windows => ("-Recurse", "-Force"),
680 };
681 assert!(
682 invocations[0].command.contains(recursive_flag),
683 "recursive flag missing: {}",
684 invocations[0].command
685 );
686 assert!(invocations[0].command.contains(force_flag), "force flag missing: {}", invocations[0].command);
687 assert!(!invocations[2].command.contains(recursive_flag));
688 assert!(!invocations[2].command.contains(force_flag));
689 Ok(())
690 }
691
692 #[test]
693 fn rm_rejects_workspace_root_via_parent_traversal_and_absolute_alias() -> Result<()> {
694 let dir = TempDir::new()?;
695 let executor = RecordingExecutor::default();
696 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
697 let mut runner = runner;
698 let canonical_root = runner.working_dir().to_path_buf();
699
700 fs::create_dir_all(runner.working_dir().join("sub"))?;
702 runner.cd("sub")?;
703 assert!(runner.rm("..", true, true).is_err(), "rm('..') from sub must be rejected");
704 let alias = dir.path().to_path_buf();
706 if alias != canonical_root {
707 assert!(
708 runner.rm(&alias.to_string_lossy(), true, true).is_err(),
709 "rm via non-canonical absolute alias must be rejected"
710 );
711 }
712 assert!(
714 runner.rm(&canonical_root.to_string_lossy(), true, true).is_err(),
715 "rm on the canonical root must be rejected"
716 );
717 let link = runner.working_dir().join("root-link");
719 #[cfg(unix)]
720 std::os::unix::fs::symlink(&canonical_root, &link).expect("create root symlink");
721 #[cfg(unix)]
722 assert!(
723 runner.rm(&link.to_string_lossy(), true, true).is_err(),
724 "rm through a symlink to the root must be rejected"
725 );
726 #[cfg(not(unix))]
727 let _ = &link;
728 assert!(
729 executor.invocations.lock().expect("invocations lock").is_empty(),
730 "no command must be built for root targets"
731 );
732 Ok(())
733 }
734
735 #[test]
736 fn mkdir_records_invocation() -> Result<()> {
737 let dir = TempDir::new()?;
738 let executor = RecordingExecutor::default();
739 let runner = BashRunner::new(dir.path().to_path_buf(), executor.clone(), AllowAllPolicy);
740 runner?.mkdir("new_dir", true)?;
741 let invocations = executor
742 .invocations
743 .lock()
744 .map_err(|e| anyhow!("executor lock poisoned: {e}"))?;
745 assert_eq!(invocations.len(), 1);
746 assert_eq!(invocations[0].category, CommandCategory::CreateDirectory);
747 Ok(())
748 }
749 #[cfg(unix)]
750 #[test]
751 fn symlink_retargeting_cannot_reuse_an_earlier_authorization() -> Result<()> {
752 let root = TempDir::new()?;
753 let outside = TempDir::new()?;
754 let inside = root.path().join("inside");
755 fs::create_dir(&inside)?;
756 let link = root.path().join("link");
757 std::os::unix::fs::symlink(&inside, &link)?;
758 let executor = RecordingExecutor::default();
759 let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
760 runner.ls(Some("link"), false)?;
761 fs::remove_file(&link)?;
762 std::os::unix::fs::symlink(outside.path(), &link)?;
763 assert!(runner.ls(Some("link"), false).is_err());
764 assert!(runner.mkdir("link/new-directory", false).is_err());
765 assert_eq!(executor.invocations.lock().expect("invocations lock").len(), 1);
766 Ok(())
767 }
768
769 #[cfg(unix)]
770 #[test]
771 fn shell_command_keeps_metacharacters_inside_a_single_literal_argument() -> Result<()> {
772 let root = TempDir::new()?;
773 let executor = RecordingExecutor::default();
774 let runner = BashRunner::new(root.path().to_path_buf(), executor.clone(), AllowAllPolicy)?;
775 let filename = "literal;$(echo injected)'file";
776 runner.mkdir(filename, false)?;
777 let invocations = executor.invocations.lock().expect("invocations lock");
778 let command = &invocations[0].command;
779 let output = std::process::Command::new("sh").arg("-c").arg(command).output()?;
780 assert!(output.status.success());
781 assert!(runner.workspace_root().join(filename).is_dir());
782 Ok(())
783 }
784}