1use anyhow::{Context, Result, bail};
2use std::collections::HashMap;
3use std::io::{self, Write};
4use std::process::ExitStatus;
5
6use crate::ast::{self, Step, StepKind, WorkspaceTarget};
7use oxdock_fs::{EntryKind, GuardedPath, PathResolver, WorkspaceFs};
8use oxdock_process::{BackgroundHandle, CommandContext, ProcessManager, default_process_manager};
9
10struct ExecState<P: ProcessManager> {
11 fs: Box<dyn WorkspaceFs>,
12 cargo_target_dir: GuardedPath,
13 cwd: GuardedPath,
14 envs: HashMap<String, String>,
15 bg_children: Vec<P::Handle>,
16}
17
18impl<P: ProcessManager> ExecState<P> {
19 fn command_ctx(&self) -> CommandContext {
20 CommandContext::new(
21 &self.cwd.clone().into(),
22 &self.envs,
23 &self.cargo_target_dir,
24 self.fs.root(),
25 self.fs.build_context(),
26 )
27 }
28}
29
30pub fn run_steps(fs_root: &GuardedPath, steps: &[Step]) -> Result<()> {
31 run_steps_with_context(fs_root, fs_root, steps)
32}
33
34pub fn run_steps_with_context(
35 fs_root: &GuardedPath,
36 build_context: &GuardedPath,
37 steps: &[Step],
38) -> Result<()> {
39 run_steps_with_context_result(fs_root, build_context, steps).map(|_| ())
40}
41
42pub fn run_steps_with_context_result(
44 fs_root: &GuardedPath,
45 build_context: &GuardedPath,
46 steps: &[Step],
47) -> Result<GuardedPath> {
48 match run_steps_inner(fs_root, build_context, steps) {
49 Ok(final_cwd) => Ok(final_cwd),
50 Err(err) => {
51 let chain = err.chain().map(|e| e.to_string()).collect::<Vec<_>>();
53 let primary = chain
54 .first()
55 .cloned()
56 .unwrap_or_else(|| "unknown error".into());
57 let rest = if chain.len() > 1 {
58 let causes = chain
59 .iter()
60 .skip(1)
61 .map(|s| s.as_str())
62 .collect::<Vec<_>>()
63 .join("\n ");
64 format!("\ncauses:\n {}", causes)
65 } else {
66 String::new()
67 };
68 let fs = PathResolver::new(fs_root.as_path(), build_context.as_path())?;
69 let tree = describe_dir(&fs, fs_root, 2, 24);
70 let snapshot = format!(
71 "filesystem snapshot (root {}):\n{}",
72 fs_root.display(),
73 tree
74 );
75 let msg = format!("{}{}\n{}", primary, rest, snapshot);
76 Err(anyhow::anyhow!(msg))
77 }
78 }
79}
80
81fn run_steps_inner(
82 fs_root: &GuardedPath,
83 build_context: &GuardedPath,
84 steps: &[Step],
85) -> Result<GuardedPath> {
86 let resolver = PathResolver::new_guarded(fs_root.clone(), build_context.clone())?;
87 run_steps_with_fs(Box::new(resolver), steps)
88}
89
90pub fn run_steps_with_fs(fs: Box<dyn WorkspaceFs>, steps: &[Step]) -> Result<GuardedPath> {
91 run_steps_with_manager(fs, steps, default_process_manager())
92}
93
94fn run_steps_with_manager<P: ProcessManager>(
95 fs: Box<dyn WorkspaceFs>,
96 steps: &[Step],
97 process: P,
98) -> Result<GuardedPath> {
99 let fs_root = fs.root().clone();
100 let cwd = fs.root().clone();
101 let mut state = ExecState {
102 fs,
103 cargo_target_dir: fs_root.join(".cargo-target")?,
104 cwd,
105 envs: HashMap::new(),
106 bg_children: Vec::new(),
107 };
108
109 let mut stdout = io::stdout();
110 let mut proc_mgr = process;
111 execute_steps(&mut state, &mut proc_mgr, steps, false, &mut stdout)?;
112
113 Ok(state.cwd)
114}
115
116fn execute_steps<P: ProcessManager>(
117 state: &mut ExecState<P>,
118 process: &mut P,
119 steps: &[Step],
120 capture_output: bool,
121 out: &mut dyn Write,
122) -> Result<()> {
123 let fs_root = state.fs.root().clone();
124 let build_context = state.fs.build_context().clone();
125
126 let check_bg = |bg: &mut Vec<P::Handle>| -> Result<Option<ExitStatus>> {
127 let mut finished: Option<ExitStatus> = None;
128 for child in bg.iter_mut() {
129 if let Some(status) = child.try_wait()? {
130 finished = Some(status);
131 break;
132 }
133 }
134 if let Some(status) = finished {
135 for child in bg.iter_mut() {
137 if child.try_wait()?.is_none() {
138 let _ = child.kill();
139 let _ = child.wait();
140 }
141 }
142 bg.clear();
143 return Ok(Some(status));
144 }
145 Ok(None)
146 };
147
148 for (idx, step) in steps.iter().enumerate() {
149 if !crate::ast::guards_allow_any(&step.guards, &state.envs) {
150 continue;
151 }
152 match &step.kind {
153 StepKind::Workdir(path) => {
154 state.cwd = state
155 .fs
156 .resolve_workdir(&state.cwd, path)
157 .with_context(|| format!("step {}: WORKDIR {}", idx + 1, path))?;
158 }
159 StepKind::Workspace(target) => match target {
160 WorkspaceTarget::Snapshot => {
161 state.fs.set_root(fs_root.clone());
162 state.cwd = state.fs.root().clone();
163 }
164 WorkspaceTarget::Local => {
165 state.fs.set_root(build_context.clone());
166 state.cwd = state.fs.root().clone();
167 }
168 },
169 StepKind::Env { key, value } => {
170 state.envs.insert(key.clone(), value.clone());
171 }
172 StepKind::Run(cmd) => {
173 let ctx = state.command_ctx();
174 if capture_output {
175 let output = process
176 .run_capture(&ctx, cmd)
177 .with_context(|| format!("step {}: RUN {}", idx + 1, cmd))?;
178 out.write_all(&output)?;
179 } else {
180 process
181 .run(&ctx, cmd)
182 .with_context(|| format!("step {}: RUN {}", idx + 1, cmd))?;
183 }
184 }
185 StepKind::Echo(msg) => {
186 let rendered = interpolate(msg, &state.envs);
187 writeln!(out, "{}", rendered)?;
188 }
189 StepKind::RunBg(cmd) => {
190 if capture_output {
191 bail!("RUN_BG is not supported inside CAPTURE");
192 }
193 let ctx = state.command_ctx();
194 let child = process
195 .spawn_bg(&ctx, cmd)
196 .with_context(|| format!("step {}: RUN_BG {}", idx + 1, cmd))?;
197 state.bg_children.push(child);
198 }
199 StepKind::Copy { from, to } => {
200 let from_abs = state
201 .fs
202 .resolve_copy_source(from)
203 .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
204 let to_abs = state
205 .fs
206 .resolve_write(&state.cwd, to)
207 .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
208 copy_entry(state.fs.as_ref(), &from_abs, &to_abs)
209 .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
210 }
211 StepKind::CopyGit { rev, from, to } => {
212 let to_abs = state.fs.resolve_write(&state.cwd, to).with_context(|| {
213 format!("step {}: COPY_GIT {} {} {}", idx + 1, rev, from, to)
214 })?;
215 state
216 .fs
217 .copy_from_git(rev, from, &to_abs)
218 .with_context(|| {
219 format!("step {}: COPY_GIT {} {} {}", idx + 1, rev, from, to)
220 })?;
221 }
222
223 StepKind::Symlink { from, to } => {
224 let to_abs = state
225 .fs
226 .resolve_write(&state.cwd, to)
227 .with_context(|| format!("step {}: SYMLINK {} {}", idx + 1, from, to))?;
228 let from_abs = state
229 .fs
230 .resolve_copy_source(from)
231 .with_context(|| format!("step {}: SYMLINK {} {}", idx + 1, from, to))?;
232 state
233 .fs
234 .symlink(&from_abs, &to_abs)
235 .with_context(|| format!("step {}: SYMLINK {} {}", idx + 1, from, to))?;
236 }
237 StepKind::Mkdir(path) => {
238 let target = state
239 .fs
240 .resolve_write(&state.cwd, path)
241 .with_context(|| format!("step {}: MKDIR {}", idx + 1, path))?;
242 state
243 .fs
244 .create_dir_all(&target)
245 .with_context(|| format!("failed to create dir {}", target.display()))?;
246 }
247 StepKind::Ls(path_opt) => {
248 let dir = if let Some(p) = path_opt.as_deref() {
249 state
250 .fs
251 .resolve_read(&state.cwd, p)
252 .with_context(|| format!("step {}: LS {}", idx + 1, p))?
253 } else {
254 state.cwd.clone()
255 };
256 let mut entries = state
257 .fs
258 .read_dir_entries(&dir)
259 .with_context(|| format!("failed to read dir {}", dir.display()))?;
260 entries.sort_by_key(|a| a.file_name());
261 writeln!(out, "{}:", dir.display())?;
262 for entry in entries {
263 writeln!(out, "{}", entry.file_name().to_string_lossy())?;
264 }
265 }
266 StepKind::Cwd => {
267 let real = canonical_cwd(state.fs.as_ref(), &state.cwd).with_context(|| {
269 format!(
270 "step {}: CWD failed to canonicalize {}",
271 idx + 1,
272 state.cwd.display()
273 )
274 })?;
275 writeln!(out, "{}", real)?;
276 }
277 StepKind::Cat(path) => {
278 let target = state
279 .fs
280 .resolve_read(&state.cwd, path)
281 .with_context(|| format!("step {}: CAT {}", idx + 1, path))?;
282 let data = state
283 .fs
284 .read_file(&target)
285 .with_context(|| format!("failed to read {}", target.display()))?;
286 out.write_all(&data)
287 .with_context(|| format!("failed to write {} to stdout", target.display()))?;
288 }
289 StepKind::Write { path, contents } => {
290 let target = state
291 .fs
292 .resolve_write(&state.cwd, path)
293 .with_context(|| format!("step {}: WRITE {}", idx + 1, path))?;
294 if let Some(parent) = target.as_path().parent() {
295 let parent_guard = GuardedPath::new(target.root(), parent)?;
296 state
297 .fs
298 .create_dir_all(&parent_guard)
299 .with_context(|| format!("failed to create parent {}", parent.display()))?;
300 }
301 state
302 .fs
303 .write_file(&target, contents.as_bytes())
304 .with_context(|| format!("failed to write {}", target.display()))?;
305 }
306 StepKind::Capture { path, cmd } => {
307 let target = state
308 .fs
309 .resolve_write(&state.cwd, path)
310 .with_context(|| format!("step {}: CAPTURE {}", idx + 1, path))?;
311 if let Some(parent) = target.as_path().parent() {
312 let parent_guard = GuardedPath::new(target.root(), parent)?;
313 state
314 .fs
315 .create_dir_all(&parent_guard)
316 .with_context(|| format!("failed to create parent {}", parent.display()))?;
317 }
318 let steps = ast::parse_script(cmd)
319 .with_context(|| format!("step {}: CAPTURE parse failed", idx + 1))?;
320 if steps.len() != 1 {
321 bail!("CAPTURE expects exactly one instruction");
322 }
323 let mut sub_state = ExecState {
324 fs: Box::new(PathResolver::new(
325 state.fs.root().as_path(),
326 state.fs.build_context().as_path(),
327 )?),
328 cargo_target_dir: state.cargo_target_dir.clone(),
329 cwd: state.cwd.clone(),
330 envs: state.envs.clone(),
331 bg_children: Vec::new(),
332 };
333 let mut sub_process = process.clone();
334 let mut buf: Vec<u8> = Vec::new();
335 execute_steps(&mut sub_state, &mut sub_process, &steps, true, &mut buf)?;
336 state
337 .fs
338 .write_file(&target, &buf)
339 .with_context(|| format!("failed to write {}", target.display()))?;
340 }
341 StepKind::Exit(code) => {
342 for child in state.bg_children.iter_mut() {
343 if child.try_wait()?.is_none() {
344 let _ = child.kill();
345 let _ = child.wait();
346 }
347 }
348 state.bg_children.clear();
349 bail!("EXIT requested with code {}", code);
350 }
351 }
352
353 if let Some(status) = check_bg(&mut state.bg_children)? {
354 if status.success() {
355 return Ok(());
356 } else {
357 bail!("RUN_BG exited with status {}", status);
358 }
359 }
360 }
361
362 if !state.bg_children.is_empty() {
363 let mut first = state.bg_children.remove(0);
364 let status = first.wait()?;
365 for child in state.bg_children.iter_mut() {
366 if child.try_wait()?.is_none() {
367 let _ = child.kill();
368 let _ = child.wait();
369 }
370 }
371 state.bg_children.clear();
372 if status.success() {
373 return Ok(());
374 } else {
375 bail!("RUN_BG exited with status {}", status);
376 }
377 }
378
379 Ok(())
380}
381
382fn copy_entry(fs: &dyn WorkspaceFs, src: &GuardedPath, dst: &GuardedPath) -> Result<()> {
383 match fs.entry_kind(src)? {
384 EntryKind::Dir => {
385 fs.copy_dir_recursive(src, dst)?;
386 }
387 EntryKind::File => {
388 if let Some(parent) = dst.as_path().parent() {
389 let parent_guard = GuardedPath::new(dst.root(), parent)?;
390 fs.create_dir_all(&parent_guard)?;
391 }
392 fs.copy_file(src, dst)?;
393 }
394 }
395 Ok(())
396}
397
398fn canonical_cwd(fs: &dyn WorkspaceFs, cwd: &GuardedPath) -> Result<String> {
399 Ok(fs.canonicalize(cwd)?.display().to_string())
400}
401
402fn describe_dir(
403 fs: &dyn WorkspaceFs,
404 root: &GuardedPath,
405 max_depth: usize,
406 max_entries: usize,
407) -> String {
408 fn helper(
409 fs: &dyn WorkspaceFs,
410 guard_root: &GuardedPath,
411 path: &GuardedPath,
412 depth: usize,
413 max_depth: usize,
414 left: &mut usize,
415 out: &mut String,
416 ) {
417 if *left == 0 {
418 return;
419 }
420 let indent = " ".repeat(depth);
421 if depth > 0 {
422 out.push_str(&format!(
423 "{}{}\n",
424 indent,
425 path.as_path()
426 .file_name()
427 .unwrap_or_default()
428 .to_string_lossy()
429 ));
430 }
431 if depth >= max_depth {
432 return;
433 }
434 let entries = match fs.read_dir_entries(path) {
435 Ok(e) => e,
436 Err(_) => return,
437 };
438 let mut names: Vec<_> = entries.into_iter().collect();
439 names.sort_by_key(|a| a.file_name());
440 for entry in names {
441 if *left == 0 {
442 return;
443 }
444 *left -= 1;
445 let file_type = match entry.file_type() {
446 Ok(ft) => ft,
447 Err(_) => continue,
448 };
449 let p = entry.path();
450 let guarded_child = match GuardedPath::new(guard_root.root(), &p) {
451 Ok(child) => child,
452 Err(_) => continue,
453 };
454 if file_type.is_dir() {
455 helper(
456 fs,
457 guard_root,
458 &guarded_child,
459 depth + 1,
460 max_depth,
461 left,
462 out,
463 );
464 } else {
465 out.push_str(&format!(
466 "{} {}\n",
467 indent,
468 entry.file_name().to_string_lossy()
469 ));
470 }
471 }
472 }
473
474 let mut out = String::new();
475 let mut left = max_entries;
476 helper(fs, root, root, 0, max_depth, &mut left, &mut out);
477 out
478}
479
480fn interpolate(template: &str, script_envs: &HashMap<String, String>) -> String {
481 let mut out = String::with_capacity(template.len());
482 let mut chars = template.chars().peekable();
483 while let Some(c) = chars.next() {
484 if c == '$' {
485 if let Some(&'{') = chars.peek() {
486 chars.next();
487 let mut name = String::new();
488 while let Some(&ch) = chars.peek() {
489 chars.next();
490 if ch == '}' {
491 break;
492 }
493 name.push(ch);
494 }
495 if !name.is_empty() {
496 let val = script_envs
497 .get(&name)
498 .cloned()
499 .or_else(|| std::env::var(&name).ok())
500 .unwrap_or_default();
501 out.push_str(&val);
502 }
503 } else {
504 let mut name = String::new();
505 while let Some(&ch) = chars.peek() {
506 if ch.is_ascii_alphanumeric() || ch == '_' {
507 name.push(ch);
508 chars.next();
509 } else {
510 break;
511 }
512 }
513 if !name.is_empty() {
514 let val = script_envs
515 .get(&name)
516 .cloned()
517 .or_else(|| std::env::var(&name).ok())
518 .unwrap_or_default();
519 out.push_str(&val);
520 } else {
521 out.push('$');
522 }
523 }
524 } else if c == '{' {
525 let mut name = String::new();
526 for ch in chars.by_ref() {
527 if ch == '}' {
528 break;
529 }
530 name.push(ch);
531 }
532 if !name.is_empty() {
533 let val = script_envs
534 .get(&name)
535 .cloned()
536 .or_else(|| std::env::var(&name).ok())
537 .unwrap_or_default();
538 out.push_str(&val);
539 }
540 } else {
541 out.push(c);
542 }
543 }
544 out
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::Guard;
551 use oxdock_fs::{GuardedPath, MockFs};
552 use oxdock_process::{MockProcessManager, MockRunCall};
553 use std::collections::HashMap;
554
555 #[test]
556 fn run_records_env_and_cwd() {
557 let root = GuardedPath::new_root_from_str(".").unwrap();
558 let steps = vec![
559 Step {
560 guards: Vec::new(),
561 kind: StepKind::Env {
562 key: "FOO".into(),
563 value: "bar".into(),
564 },
565 },
566 Step {
567 guards: Vec::new(),
568 kind: StepKind::Run("echo hi".into()),
569 },
570 ];
571 let mock = MockProcessManager::default();
572 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
573 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
574 let runs = mock.recorded_runs();
575 assert_eq!(runs.len(), 1);
576 let MockRunCall {
577 script,
578 cwd,
579 envs,
580 cargo_target_dir,
581 } = &runs[0];
582 assert_eq!(script, "echo hi");
583 assert_eq!(cwd, root.as_path());
584 assert_eq!(
585 cargo_target_dir,
586 &root.join(".cargo-target").unwrap().to_path_buf()
587 );
588 assert_eq!(envs.get("FOO"), Some(&"bar".into()));
589 }
590
591 #[test]
592 fn run_bg_completion_short_circuits_pipeline() {
593 let root = GuardedPath::new_root_from_str(".").unwrap();
594 let steps = vec![
595 Step {
596 guards: Vec::new(),
597 kind: StepKind::RunBg("sleep".into()),
598 },
599 Step {
600 guards: Vec::new(),
601 kind: StepKind::Run("echo after".into()),
602 },
603 ];
604 let mock = MockProcessManager::default();
605 mock.push_bg_plan(0, success_status());
606 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
607 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
608 assert!(
609 mock.recorded_runs().is_empty(),
610 "foreground run should not execute when RUN_BG completes early"
611 );
612 let spawns = mock.spawn_log();
613 let spawned: Vec<_> = spawns.iter().map(|c| c.script.as_str()).collect();
614 assert_eq!(spawned, vec!["sleep"]);
615 }
616
617 #[test]
618 fn exit_kills_background_processes() {
619 let root = GuardedPath::new_root_from_str(".").unwrap();
620 let steps = vec![
621 Step {
622 guards: Vec::new(),
623 kind: StepKind::RunBg("bg-task".into()),
624 },
625 Step {
626 guards: Vec::new(),
627 kind: StepKind::Exit(5),
628 },
629 ];
630 let mock = MockProcessManager::default();
631 mock.push_bg_plan(usize::MAX, success_status());
632 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
633 let err = run_steps_with_manager(fs, &steps, mock.clone()).unwrap_err();
634 assert!(
635 err.to_string().contains("EXIT requested with code 5"),
636 "unexpected error: {err}"
637 );
638 assert_eq!(mock.killed(), vec!["bg-task"]);
639 }
640
641 #[test]
642 fn guarded_run_waits_for_env_to_be_set() {
643 let root = GuardedPath::new_root_from_str(".").unwrap();
644 let guard = Guard::EnvEquals {
645 key: "READY".into(),
646 value: "1".into(),
647 invert: false,
648 };
649 let steps = vec![
650 Step {
651 guards: vec![vec![guard.clone()]],
652 kind: StepKind::Run("echo first".into()),
653 },
654 Step {
655 guards: Vec::new(),
656 kind: StepKind::Env {
657 key: "READY".into(),
658 value: "1".into(),
659 },
660 },
661 Step {
662 guards: vec![vec![guard]],
663 kind: StepKind::Run("echo second".into()),
664 },
665 ];
666 let mock = MockProcessManager::default();
667 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
668 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
669 let runs = mock.recorded_runs();
670 assert_eq!(runs.len(), 1);
671 assert_eq!(runs[0].script, "echo second");
672 }
673
674 #[test]
675 fn guard_groups_allow_any_matching_branch() {
676 let root = GuardedPath::new_root_from_str(".").unwrap();
677 let guard_alpha = Guard::EnvEquals {
678 key: "MODE".into(),
679 value: "alpha".into(),
680 invert: false,
681 };
682 let guard_beta = Guard::EnvEquals {
683 key: "MODE".into(),
684 value: "beta".into(),
685 invert: false,
686 };
687 let steps = vec![
688 Step {
689 guards: Vec::new(),
690 kind: StepKind::Env {
691 key: "MODE".into(),
692 value: "beta".into(),
693 },
694 },
695 Step {
696 guards: vec![vec![guard_alpha], vec![guard_beta]],
697 kind: StepKind::Run("echo guarded".into()),
698 },
699 ];
700 let mock = MockProcessManager::default();
701 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
702 run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
703 let runs = mock.recorded_runs();
704 assert_eq!(runs.len(), 1);
705 assert_eq!(runs[0].script, "echo guarded");
706 }
707
708 #[test]
709 fn capture_rejects_multiple_instructions() {
710 let root = GuardedPath::new_root_from_str(".").unwrap();
711 let capture = Step {
712 guards: Vec::new(),
713 kind: StepKind::Capture {
714 path: "out.txt".into(),
715 cmd: "WRITE one 1; WRITE two 2".into(),
716 },
717 };
718 let mock = MockProcessManager::default();
719 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
720 let err = run_steps_with_manager(fs, &[capture], mock).unwrap_err();
721 assert!(
722 err.to_string()
723 .contains("CAPTURE expects exactly one instruction"),
724 "unexpected error: {err}"
725 );
726 }
727
728 fn success_status() -> ExitStatus {
729 exit_status_from_code(0)
730 }
731
732 #[cfg(unix)]
733 fn exit_status_from_code(code: i32) -> ExitStatus {
734 use std::os::unix::process::ExitStatusExt;
735 ExitStatusExt::from_raw(code << 8)
736 }
737
738 #[cfg(windows)]
739 fn exit_status_from_code(code: i32) -> ExitStatus {
740 use std::os::windows::process::ExitStatusExt;
741 ExitStatusExt::from_raw(code as u32)
742 }
743
744 fn create_exec_state(fs: MockFs) -> ExecState<MockProcessManager> {
745 let cargo = fs.root().join(".cargo-target").unwrap();
746 ExecState {
747 fs: Box::new(fs.clone()),
748 cargo_target_dir: cargo,
749 cwd: fs.root().clone(),
750 envs: HashMap::new(),
751 bg_children: Vec::new(),
752 }
753 }
754
755 fn run_with_mock_fs(steps: &[Step]) -> (GuardedPath, HashMap<String, Vec<u8>>) {
756 let fs = MockFs::new();
757 let mut state = create_exec_state(fs.clone());
758 let mut proc = MockProcessManager::default();
759 let mut sink = Vec::new();
760 execute_steps(&mut state, &mut proc, steps, false, &mut sink).unwrap();
761 (state.cwd, fs.snapshot())
762 }
763
764 #[test]
765 fn mock_fs_handles_workdir_and_write() {
766 let steps = vec![
767 Step {
768 guards: Vec::new(),
769 kind: StepKind::Mkdir("app".into()),
770 },
771 Step {
772 guards: Vec::new(),
773 kind: StepKind::Workdir("app".into()),
774 },
775 Step {
776 guards: Vec::new(),
777 kind: StepKind::Write {
778 path: "out.txt".into(),
779 contents: "hi".into(),
780 },
781 },
782 Step {
783 guards: Vec::new(),
784 kind: StepKind::Cat("out.txt".into()),
785 },
786 ];
787 let (_cwd, files) = run_with_mock_fs(&steps);
788 let written = files
789 .iter()
790 .find(|(k, _)| k.ends_with("app/out.txt"))
791 .map(|(_, v)| String::from_utf8_lossy(v).to_string());
792 assert_eq!(written, Some("hi".into()));
793 }
794
795 #[test]
796 fn final_cwd_tracks_last_workdir() {
797 let steps = vec![
798 Step {
799 guards: Vec::new(),
800 kind: StepKind::Write {
801 path: "temp.txt".into(),
802 contents: "123".into(),
803 },
804 },
805 Step {
806 guards: Vec::new(),
807 kind: StepKind::Workdir("sub".into()),
808 },
809 ];
810 let (cwd, snapshot) = run_with_mock_fs(&steps);
811 assert!(
812 cwd.as_path().ends_with("sub"),
813 "expected final cwd to match last WORKDIR, got {}",
814 cwd.display()
815 );
816 let keys: Vec<_> = snapshot.keys().cloned().collect();
817 assert!(
818 keys.iter().any(|path| path.ends_with("temp.txt")),
819 "WRITE should produce temp file, snapshot: {:?}",
820 keys
821 );
822 }
823
824 #[test]
825 fn mock_fs_normalizes_backslash_workdir() {
826 let steps = vec![
827 Step {
828 guards: Vec::new(),
829 kind: StepKind::Mkdir("win\\nested".into()),
830 },
831 Step {
832 guards: Vec::new(),
833 kind: StepKind::Workdir("win\\nested".into()),
834 },
835 Step {
836 guards: Vec::new(),
837 kind: StepKind::Write {
838 path: "inner.txt".into(),
839 contents: "ok".into(),
840 },
841 },
842 ];
843 let (cwd, snapshot) = run_with_mock_fs(&steps);
844 let cwd_display = cwd.display().to_string();
845 assert!(
846 cwd_display.ends_with("win\\nested") || cwd_display.ends_with("win/nested"),
847 "expected cwd to normalize backslashes, got {cwd_display}"
848 );
849 assert!(
850 snapshot
851 .keys()
852 .any(|path| path.ends_with("win/nested/inner.txt")),
853 "expected file under normalized path, snapshot: {:?}",
854 snapshot.keys()
855 );
856 }
857
858 #[cfg(windows)]
859 #[test]
860 fn mock_fs_rejects_absolute_windows_paths() {
861 let steps = vec![Step {
862 guards: Vec::new(),
863 kind: StepKind::Workdir(r"C:\outside".into()),
864 }];
865 let fs = MockFs::new();
866 let mut state = create_exec_state(fs);
867 let mut proc = MockProcessManager::default();
868 let mut sink = Vec::new();
869 let err = execute_steps(&mut state, &mut proc, &steps, false, &mut sink).unwrap_err();
870 let msg = format!("{err:#}");
871 assert!(
872 msg.contains("escapes allowed root"),
873 "unexpected error for absolute Windows path: {msg}"
874 );
875 }
876}