1use anyhow::{Context, Result, anyhow, bail};
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::io::{self, Read, Write};
4use std::process::ExitStatus;
5use std::sync::{Arc, Condvar, Mutex};
6
7use oxdock_fs::{EntryKind, GuardedPath, PathResolver, WorkspaceFs, to_forward_slashes};
8use oxdock_parser::{IoStream, Step, StepKind, TemplateString, WorkspaceTarget};
9use oxdock_process::{
10 BackgroundHandle, BuiltinEnv, CommandContext, CommandOptions, CommandResult, CommandStderr,
11 CommandStdout, ProcessManager, SharedInput, SharedOutput, default_process_manager,
12 expand_command_env,
13};
14use sha2::{Digest, Sha256};
15
16#[derive(Clone)]
17enum PipeEndpoint {
18 Stream(SharedOutput),
19 Script(ScriptPipeEndpoint),
20 Inherit,
21}
22
23impl PipeEndpoint {
24 fn stream(writer: SharedOutput) -> Self {
25 PipeEndpoint::Stream(writer)
26 }
27
28 fn script(endpoint: ScriptPipeEndpoint) -> Self {
29 PipeEndpoint::Script(endpoint)
30 }
31
32 fn to_stream_handle(&self) -> StreamHandle {
33 match self {
34 PipeEndpoint::Stream(writer) => StreamHandle::Stream(writer.clone()),
35 PipeEndpoint::Script(endpoint) => StreamHandle::Stream(endpoint.stream_handle()),
36 PipeEndpoint::Inherit => StreamHandle::Inherit,
37 }
38 }
39}
40
41#[derive(Clone, Default)]
42struct PipeOutputs {
43 stdout: Option<PipeEndpoint>,
44 stderr: Option<PipeEndpoint>,
45}
46
47struct ScriptPipe {
48 inner: Arc<PipeInner>,
49 reader: SharedInput,
50}
51
52impl ScriptPipe {
53 fn new() -> Self {
54 let inner = Arc::new(PipeInner::new());
55 let reader: SharedInput = Arc::new(Mutex::new(PipeReader::new(inner.clone())));
56 Self { inner, reader }
57 }
58
59 fn reader(&self) -> SharedInput {
60 self.reader.clone()
61 }
62
63 fn endpoint(&self) -> ScriptPipeEndpoint {
64 ScriptPipeEndpoint::new(self.inner.clone())
65 }
66}
67
68#[derive(Clone)]
69struct ScriptPipeEndpoint {
70 inner: Arc<PipeInner>,
71}
72
73impl ScriptPipeEndpoint {
74 fn new(inner: Arc<PipeInner>) -> Self {
75 Self { inner }
76 }
77
78 fn stream_handle(&self) -> SharedOutput {
79 Arc::new(Mutex::new(PipeWriter::new(self.inner.clone())))
80 }
81}
82
83struct PipeInner {
84 state: Mutex<PipeState>,
85 ready: Condvar,
86}
87
88impl PipeInner {
89 fn new() -> Self {
90 Self {
91 state: Mutex::new(PipeState::new()),
92 ready: Condvar::new(),
93 }
94 }
95
96 fn attach_writer(&self) {
97 let mut state = self.lock_state();
98 state.writers += 1;
99 state.closed = false;
100 }
101
102 fn detach_writer(&self) {
103 let mut state = self.lock_state();
104 state.writers = state.writers.saturating_sub(1);
105 if state.writers == 0 {
106 state.closed = true;
107 }
108 drop(state);
109 self.ready.notify_all();
110 }
111
112 fn push_bytes(&self, data: &[u8]) {
113 let mut state = self.lock_state();
114 state.buffer.extend(data.iter().copied());
115 drop(state);
116 self.ready.notify_all();
117 }
118
119 fn read_into(&self, buf: &mut [u8]) -> io::Result<usize> {
120 if buf.is_empty() {
121 return Ok(0);
122 }
123 let mut state = self.lock_state();
124 loop {
125 if !state.buffer.is_empty() {
126 let mut read = 0;
127 while read < buf.len() && !state.buffer.is_empty() {
128 if let Some(byte) = state.buffer.pop_front() {
129 buf[read] = byte;
130 read += 1;
131 }
132 }
133 return Ok(read);
134 }
135 if state.closed {
136 return Ok(0);
137 }
138 state = self
139 .ready
140 .wait(state)
141 .map_err(|_| io::Error::other("pipe wait poisoned"))?;
142 }
143 }
144
145 fn lock_state(&self) -> std::sync::MutexGuard<'_, PipeState> {
146 self.state.lock().expect("script pipe state poisoned")
147 }
148}
149
150struct PipeState {
151 buffer: VecDeque<u8>,
152 writers: usize,
153 closed: bool,
154}
155
156impl PipeState {
157 fn new() -> Self {
158 Self {
159 buffer: VecDeque::new(),
160 writers: 0,
161 closed: false,
162 }
163 }
164}
165
166struct PipeReader {
167 inner: Arc<PipeInner>,
168}
169
170impl PipeReader {
171 fn new(inner: Arc<PipeInner>) -> Self {
172 Self { inner }
173 }
174}
175
176impl Read for PipeReader {
177 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
178 self.inner.read_into(buf)
179 }
180}
181
182struct PipeWriter {
183 inner: Arc<PipeInner>,
184}
185
186impl PipeWriter {
187 fn new(inner: Arc<PipeInner>) -> Self {
188 inner.attach_writer();
189 Self { inner }
190 }
191}
192
193impl Write for PipeWriter {
194 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
195 self.inner.push_bytes(buf);
196 Ok(buf.len())
197 }
198
199 fn flush(&mut self) -> io::Result<()> {
200 Ok(())
201 }
202}
203
204impl Drop for PipeWriter {
205 fn drop(&mut self) {
206 self.inner.detach_writer();
207 }
208}
209
210#[derive(Clone, Default)]
211pub struct ExecIo {
212 stdin: Option<SharedInput>,
213 stdout: Option<SharedOutput>,
214 stderr: Option<SharedOutput>,
215 input_pipes: HashMap<String, SharedInput>,
216 output_pipes: HashMap<String, PipeOutputs>,
217 inherit_env_overrides: HashMap<String, String>,
218 inherit_env_removed: HashSet<String>,
219}
220
221#[derive(Clone)]
222enum StreamHandle {
223 Inherit,
224 Stream(SharedOutput),
225}
226
227impl StreamHandle {
228 fn to_stdout(&self) -> CommandStdout {
229 match self {
230 StreamHandle::Stream(writer) => CommandStdout::Stream(writer.clone()),
231 StreamHandle::Inherit => CommandStdout::Inherit,
232 }
233 }
234
235 fn to_stderr(&self) -> CommandStderr {
236 match self {
237 StreamHandle::Stream(writer) => CommandStderr::Stream(writer.clone()),
238 StreamHandle::Inherit => CommandStderr::Inherit,
239 }
240 }
241}
242
243fn write_stdout<F>(handle: Option<StreamHandle>, op: F) -> Result<()>
244where
245 F: FnOnce(&mut dyn Write) -> Result<()>,
246{
247 if let Some(StreamHandle::Stream(writer)) = handle {
248 if let Ok(mut guard) = writer.lock() {
249 op(&mut *guard)?;
250 }
251 Ok(())
252 } else {
253 let mut stdout = io::stdout();
254 op(&mut stdout)
255 }
256}
257
258impl ExecIo {
259 pub fn new() -> Self {
260 Self::default()
261 }
262
263 pub fn set_stdin(&mut self, stdin: Option<SharedInput>) {
264 self.stdin = stdin;
265 }
266
267 pub fn set_stdout(&mut self, stdout: Option<SharedOutput>) {
268 self.stdout = stdout.clone();
269 if self.stderr.is_none() {
270 self.stderr = stdout;
271 }
272 }
273
274 pub fn set_stderr(&mut self, stderr: Option<SharedOutput>) {
275 self.stderr = stderr;
276 }
277
278 pub fn insert_inherit_env<S: Into<String>, V: Into<String>>(&mut self, key: S, value: V) {
279 let key = key.into();
280 self.inherit_env_removed.remove(&key);
281 self.inherit_env_overrides.insert(key, value.into());
282 }
283
284 pub fn remove_inherit_env<S: Into<String>>(&mut self, key: S) {
285 let key = key.into();
286 self.inherit_env_overrides.remove(&key);
287 self.inherit_env_removed.insert(key);
288 }
289
290 pub fn inherit_env_value(&self, key: &str) -> Option<&String> {
291 self.inherit_env_overrides.get(key)
292 }
293
294 pub fn inherit_env_is_removed(&self, key: &str) -> bool {
295 self.inherit_env_removed.contains(key)
296 }
297
298 pub fn insert_input_pipe<S: Into<String>>(&mut self, name: S, reader: SharedInput) {
299 self.input_pipes.insert(name.into(), reader);
300 }
301
302 pub fn insert_output_pipe<S: Into<String>>(&mut self, name: S, writer: SharedOutput) {
303 let entry = self.output_pipes.entry(name.into()).or_default();
304 entry.stdout = Some(PipeEndpoint::stream(writer.clone()));
305 entry.stderr = Some(PipeEndpoint::stream(writer));
306 }
307
308 pub fn insert_output_pipe_stdout<S: Into<String>>(&mut self, name: S, writer: SharedOutput) {
309 let entry = self.output_pipes.entry(name.into()).or_default();
310 entry.stdout = Some(PipeEndpoint::stream(writer));
311 }
312
313 pub fn insert_output_pipe_stderr<S: Into<String>>(&mut self, name: S, writer: SharedOutput) {
314 let entry = self.output_pipes.entry(name.into()).or_default();
315 entry.stderr = Some(PipeEndpoint::stream(writer));
316 }
317
318 pub fn insert_output_pipe_stdout_inherit<S: Into<String>>(&mut self, name: S) {
319 let entry = self.output_pipes.entry(name.into()).or_default();
320 entry.stdout = Some(PipeEndpoint::Inherit);
321 }
322
323 pub fn insert_output_pipe_stderr_inherit<S: Into<String>>(&mut self, name: S) {
324 let entry = self.output_pipes.entry(name.into()).or_default();
325 entry.stderr = Some(PipeEndpoint::Inherit);
326 }
327
328 fn ensure_script_pipe(&mut self, name: &str) {
329 if self.input_pipes.contains_key(name) || self.output_pipes.contains_key(name) {
330 return;
331 }
332
333 let pipe = ScriptPipe::new();
334 self.input_pipes.insert(name.to_string(), pipe.reader());
335 let endpoint = PipeEndpoint::script(pipe.endpoint());
336 let outputs = PipeOutputs {
337 stdout: Some(endpoint.clone()),
338 stderr: Some(endpoint),
339 };
340 self.output_pipes.insert(name.to_string(), outputs);
341 }
342
343 pub fn stdin(&self) -> Option<SharedInput> {
344 self.stdin.clone()
345 }
346
347 pub fn stdout(&self) -> Option<SharedOutput> {
348 self.stdout.clone()
349 }
350
351 pub fn stderr(&self) -> Option<SharedOutput> {
352 self.stderr.clone().or_else(|| self.stdout.clone())
353 }
354
355 pub fn input_pipe(&self, name: &str) -> Option<SharedInput> {
356 self.input_pipes.get(name).cloned()
357 }
358
359 fn output_pipe_stdout(&self, name: &str) -> Option<PipeEndpoint> {
360 self.output_pipes
361 .get(name)
362 .and_then(|pipe| pipe.stdout.clone())
363 }
364
365 fn output_pipe_stderr(&self, name: &str) -> Option<PipeEndpoint> {
366 self.output_pipes
367 .get(name)
368 .and_then(|pipe| pipe.stderr.clone())
369 }
370}
371
372fn assemble_default_io(stdin: Option<SharedInput>, stdout: Option<SharedOutput>) -> ExecIo {
373 let mut io = ExecIo::new();
374 io.set_stdin(stdin);
375 io.set_stdout(stdout.clone());
376 io.set_stderr(stdout);
377 io
378}
379
380struct ExecState<P: ProcessManager> {
381 fs: Box<dyn WorkspaceFs>,
382 cargo_target_dir: GuardedPath,
383 cwd: GuardedPath,
384 envs: HashMap<String, String>,
385 bg_children: Vec<P::Handle>,
386 scope_stack: Vec<ScopeSnapshot>,
387 io: ExecIo,
388}
389
390struct ScopeSnapshot {
391 cwd: GuardedPath,
392 root: GuardedPath,
393 envs: HashMap<String, String>,
394}
395
396impl<P: ProcessManager> ExecState<P> {
397 fn command_ctx(&self) -> Result<CommandContext> {
398 Ok(CommandContext::new(
403 &self.cwd.clone().into(),
404 &self.envs,
405 &self.cargo_target_dir,
406 self.fs.root(),
407 self.fs.build_context(),
408 ))
409 }
410}
411
412fn expand_template(t: &TemplateString, ctx: &CommandContext) -> String {
413 expand_command_env(&t.0, ctx)
414}
415
416pub fn run_steps(fs_root: &GuardedPath, steps: &[Step]) -> Result<()> {
417 run_steps_with_context(fs_root, fs_root, steps)
418}
419
420pub fn run_steps_with_context(
421 fs_root: &GuardedPath,
422 build_context: &GuardedPath,
423 steps: &[Step],
424) -> Result<()> {
425 run_steps_with_context_result(fs_root, build_context, steps, None, None).map(|_| ())
426}
427
428pub fn run_steps_with_context_result(
430 fs_root: &GuardedPath,
431 build_context: &GuardedPath,
432 steps: &[Step],
433 stdin: Option<SharedInput>,
434 stdout: Option<SharedOutput>,
435) -> Result<GuardedPath> {
436 let io = assemble_default_io(stdin, stdout);
437 run_steps_with_context_result_with_io(fs_root, build_context, steps, io)
438}
439
440pub fn run_steps_with_context_result_with_io(
441 fs_root: &GuardedPath,
442 build_context: &GuardedPath,
443 steps: &[Step],
444 io: ExecIo,
445) -> Result<GuardedPath> {
446 match run_steps_inner(fs_root, build_context, steps, io) {
447 Ok(final_cwd) => Ok(final_cwd),
448 Err(err) => {
449 let chain = err.chain().map(|e| e.to_string()).collect::<Vec<_>>();
451 let mut primary = chain
452 .first()
453 .cloned()
454 .unwrap_or_else(|| "unknown error".into());
455 let rest = if chain.len() > 1 {
456 let first_cause = chain[1].clone();
457 primary = format!("{primary} ({first_cause})");
458 if chain.len() > 2 {
459 let causes = chain
460 .iter()
461 .skip(2)
462 .map(|s| s.as_str())
463 .collect::<Vec<_>>()
464 .join("\n ");
465 format!("\ncauses:\n {}", causes)
466 } else {
467 String::new()
468 }
469 } else {
470 String::new()
471 };
472 let fs = PathResolver::new(fs_root.as_path(), build_context.as_path())?;
473 let tree = describe_dir(&fs, fs_root, 2, 24);
474 let snapshot = format!(
475 "filesystem snapshot (root {}):\n{}",
476 fs_root.display(),
477 tree
478 );
479 let msg = format!("{}{}\n{}", primary, rest, snapshot);
480 Err(anyhow::anyhow!(msg))
481 }
482 }
483}
484
485fn run_steps_inner(
486 fs_root: &GuardedPath,
487 build_context: &GuardedPath,
488 steps: &[Step],
489 io: ExecIo,
490) -> Result<GuardedPath> {
491 let mut resolver = PathResolver::new_guarded(fs_root.clone(), build_context.clone())?;
492 resolver.set_workspace_root(build_context.clone());
493 run_steps_with_fs_with_io(Box::new(resolver), steps, io)
494}
495
496pub fn run_steps_with_fs(
497 fs: Box<dyn WorkspaceFs>,
498 steps: &[Step],
499 stdin: Option<SharedInput>,
500 stdout: Option<SharedOutput>,
501) -> Result<GuardedPath> {
502 let io = assemble_default_io(stdin, stdout);
503 run_steps_with_fs_with_io(fs, steps, io)
504}
505
506pub fn run_steps_with_fs_with_io(
507 fs: Box<dyn WorkspaceFs>,
508 steps: &[Step],
509 io: ExecIo,
510) -> Result<GuardedPath> {
511 run_steps_with_manager(fs, steps, default_process_manager(), io)
512}
513
514fn run_steps_with_manager<P: ProcessManager>(
515 fs: Box<dyn WorkspaceFs>,
516 steps: &[Step],
517 process: P,
518 io: ExecIo,
519) -> Result<GuardedPath> {
520 let fs_root = fs.root().clone();
521 let cwd = fs.root().clone();
522 let build_context = fs.build_context().clone();
523 let envs = BuiltinEnv::collect(&build_context).into_envs();
524 let mut state = ExecState {
525 fs,
526 cargo_target_dir: fs_root.join(".cargo-target")?,
527 cwd,
528 envs,
529 bg_children: Vec::new(),
530 scope_stack: Vec::new(),
531 io,
532 };
533
534 let _default_stdout = io::stdout();
535 let stdin = state.io.stdin();
536 let stdout = state.io.stdout().map(StreamHandle::Stream);
537 let stderr = state.io.stderr().map(StreamHandle::Stream);
538 let mut proc_mgr = process;
539 execute_steps(
540 &mut state,
541 &mut proc_mgr,
542 steps,
543 stdin,
544 false,
545 stdout,
546 stderr,
547 true,
548 )?;
549
550 Ok(state.cwd)
551}
552
553#[allow(clippy::too_many_arguments)]
554fn execute_steps<P: ProcessManager>(
555 state: &mut ExecState<P>,
556 process: &mut P,
557 steps: &[Step],
558 stdin: Option<SharedInput>,
559 expose_stdin: bool,
560 out: Option<StreamHandle>,
561 err: Option<StreamHandle>,
562 wait_at_end: bool,
563) -> Result<()> {
564 let fs_root = state.fs.root().clone();
565 let build_context = state.fs.build_context().clone();
566
567 let check_bg = |bg: &mut Vec<P::Handle>| -> Result<Option<ExitStatus>> {
583 let mut finished: Option<ExitStatus> = None;
584 for child in bg.iter_mut() {
585 if let Some(status) = child.try_wait()? {
586 finished = Some(status);
587 break;
588 }
589 }
590 if let Some(status) = finished {
591 for child in bg.iter_mut() {
593 if child.try_wait()?.is_none() {
594 let _ = child.kill();
595 let _ = child.wait();
596 }
597 }
598 bg.clear();
599 return Ok(Some(status));
600 }
601 Ok(None)
602 };
603
604 for (idx, step) in steps.iter().enumerate() {
605 if step.scope_enter > 0 {
606 for _ in 0..step.scope_enter {
607 state.scope_stack.push(ScopeSnapshot {
608 cwd: state.cwd.clone(),
609 root: state.fs.root().clone(),
610 envs: state.envs.clone(),
611 });
612 }
613 }
614
615 let should_run = oxdock_parser::guard_option_allows(step.guard.as_ref(), &state.envs);
616 let step_result: Result<()> = if !should_run {
617 Ok(())
618 } else {
619 match &step.kind {
620 StepKind::InheritEnv { keys } => {
621 for key in keys {
622 if state.io.inherit_env_is_removed(key) {
623 state.envs.remove(key);
624 continue;
625 }
626 if let Some(value) = state.io.inherit_env_value(key).cloned() {
627 state.envs.insert(key.clone(), value);
628 continue;
629 }
630 if let Ok(value) = std::env::var(key) {
631 state.envs.insert(key.clone(), value);
632 }
633 }
634 Ok(())
635 }
636 StepKind::Workdir(path) => {
637 let ctx = state.command_ctx()?;
638 let rendered = expand_template(path, &ctx);
639 state.cwd = state
640 .fs
641 .resolve_workdir(&state.cwd, &rendered)
642 .with_context(|| format!("step {}: WORKDIR {}", idx + 1, rendered))?;
643 Ok(())
644 }
645 StepKind::Workspace(target) => {
646 match target {
647 WorkspaceTarget::Snapshot => {
648 state.fs.set_root(fs_root.clone());
649 state.cwd = state.fs.root().clone();
650 }
651 WorkspaceTarget::Local => {
652 state.fs.set_root(build_context.clone());
653 state.cwd = state.fs.root().clone();
654 }
655 }
656 Ok(())
657 }
658 StepKind::Env { key, value } => {
659 let ctx = state.command_ctx()?;
660 let rendered = expand_template(value, &ctx);
661 state.envs.insert(key.clone(), rendered);
662 Ok(())
663 }
664 StepKind::Run(cmd) => {
665 let ctx = state.command_ctx()?;
666 let rendered = expand_template(cmd, &ctx);
667 let step_stdin = if expose_stdin { stdin.clone() } else { None };
668
669 let inherit_override = state
673 .envs
674 .get("OXDOCK_INHERIT_STDOUT")
675 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
676 .unwrap_or(false);
677
678 if std::env::var("OXBOOK_DEBUG").is_ok() {
679 eprintln!(
680 "DEBUG: step RUN {} inherit_override={}",
681 rendered, inherit_override
682 );
683 }
684
685 let stdout_mode = if inherit_override {
686 CommandStdout::Inherit
687 } else {
688 out.clone()
689 .map(|handle| handle.to_stdout())
690 .unwrap_or(CommandStdout::Inherit)
691 };
692 let stderr_mode = if inherit_override {
693 CommandStderr::Inherit
694 } else {
695 err.clone()
696 .map(|handle| handle.to_stderr())
697 .unwrap_or(CommandStderr::Inherit)
698 };
699
700 let mut options = CommandOptions::foreground();
701 options.stdin = step_stdin;
702 options.stdout = stdout_mode;
703 options.stderr = stderr_mode;
704 match process
705 .run_command(&ctx, &rendered, options)
706 .with_context(|| format!("step {}: RUN {}", idx + 1, rendered))?
707 {
708 CommandResult::Completed => Ok(()),
709 CommandResult::Captured(_) => {
710 bail!(
711 "step {}: RUN {} unexpectedly captured output",
712 idx + 1,
713 rendered
714 )
715 }
716 CommandResult::Background(_) => {
717 bail!(
718 "step {}: RUN {} returned background handle",
719 idx + 1,
720 rendered
721 )
722 }
723 }
724 }
725 StepKind::Echo(msg) => {
726 let ctx = state.command_ctx()?;
727 let rendered = expand_template(msg, &ctx);
728 write_stdout(out.clone(), |writer| {
729 writeln!(writer, "{}", rendered)?;
730 Ok(())
731 })?;
732 Ok(())
733 }
734 StepKind::RunBg(cmd) => {
735 let ctx = state.command_ctx()?;
736 let rendered = expand_template(cmd, &ctx);
737 let step_stdin = if expose_stdin { stdin.clone() } else { None };
738 let stdout_mode = out
739 .clone()
740 .map(|handle| handle.to_stdout())
741 .unwrap_or(CommandStdout::Inherit);
742 let stderr_mode = err
743 .clone()
744 .map(|handle| handle.to_stderr())
745 .unwrap_or(CommandStderr::Inherit);
746 let mut options = CommandOptions::background();
747 options.stdin = step_stdin;
748 options.stdout = stdout_mode;
749 options.stderr = stderr_mode;
750 match process
751 .run_command(&ctx, &rendered, options)
752 .with_context(|| format!("step {}: RUN_BG {}", idx + 1, rendered))?
753 {
754 CommandResult::Background(handle) => {
755 state.bg_children.push(handle);
756 Ok(())
757 }
758 CommandResult::Completed => {
759 bail!(
760 "step {}: RUN_BG {} finished synchronously",
761 idx + 1,
762 rendered
763 )
764 }
765 CommandResult::Captured(_) => {
766 bail!(
767 "step {}: RUN_BG {} attempted to capture output",
768 idx + 1,
769 rendered
770 )
771 }
772 }
773 }
774 StepKind::Copy {
775 from_current_workspace,
776 from,
777 to,
778 } => {
779 let ctx = state.command_ctx()?;
780 let from_rendered = expand_template(from, &ctx);
781 let to_rendered = expand_template(to, &ctx);
782 let from_abs = if *from_current_workspace {
783 state
784 .fs
785 .resolve_copy_source_from_workspace(&from_rendered)
786 .with_context(|| {
787 format!("step {}: COPY {} {}", idx + 1, from_rendered, to_rendered)
788 })?
789 } else {
790 state
791 .fs
792 .resolve_copy_source(&from_rendered)
793 .with_context(|| {
794 format!("step {}: COPY {} {}", idx + 1, from_rendered, to_rendered)
795 })?
796 };
797 let to_abs = state
798 .fs
799 .resolve_write(&state.cwd, &to_rendered)
800 .with_context(|| {
801 format!("step {}: COPY {} {}", idx + 1, from_rendered, to_rendered)
802 })?;
803 copy_entry(state.fs.as_ref(), &from_abs, &to_abs).with_context(|| {
804 format!("step {}: COPY {} {}", idx + 1, from_rendered, to_rendered)
805 })?;
806 Ok(())
807 }
808 StepKind::CopyGit {
809 rev,
810 from,
811 to,
812 include_dirty,
813 } => {
814 let ctx = state.command_ctx()?;
815 let rev_rendered = expand_template(rev, &ctx);
816 let from_rendered = expand_template(from, &ctx);
817 let to_rendered = expand_template(to, &ctx);
818 let to_abs = state
819 .fs
820 .resolve_write(&state.cwd, &to_rendered)
821 .with_context(|| {
822 format!(
823 "step {}: COPY_GIT {} {} {}",
824 idx + 1,
825 rev_rendered,
826 from_rendered,
827 to_rendered
828 )
829 })?;
830 state
831 .fs
832 .copy_from_git(&rev_rendered, &from_rendered, &to_abs, *include_dirty)
833 .with_context(|| {
834 format!(
835 "step {}: COPY_GIT {} {} {}",
836 idx + 1,
837 rev_rendered,
838 from_rendered,
839 to_rendered
840 )
841 })?;
842 Ok(())
843 }
844 StepKind::HashSha256 { path } => {
845 let ctx = state.command_ctx()?;
846 let rendered = expand_template(path, &ctx);
847 let target = state
848 .fs
849 .resolve_read(&state.cwd, &rendered)
850 .with_context(|| format!("step {}: HASH_SHA256 {}", idx + 1, rendered))?;
851 let mut hasher = Sha256::new();
852 hash_path(state.fs.as_ref(), &target, "", &mut hasher)?;
853 let digest = hasher.finalize();
854 write_stdout(out.clone(), |writer| {
855 writeln!(writer, "{:x}", digest)?;
856 Ok(())
857 })?;
858 Ok(())
859 }
860
861 StepKind::Symlink { from, to } => {
862 let ctx = state.command_ctx()?;
863 let from_rendered = expand_template(from, &ctx);
864 let to_rendered = expand_template(to, &ctx);
865 let to_abs = state
866 .fs
867 .resolve_write(&state.cwd, &to_rendered)
868 .with_context(|| {
869 format!(
870 "step {}: SYMLINK {} {}",
871 idx + 1,
872 from_rendered,
873 to_rendered
874 )
875 })?;
876 let from_abs =
877 state
878 .fs
879 .resolve_copy_source(&from_rendered)
880 .with_context(|| {
881 format!(
882 "step {}: SYMLINK {} {}",
883 idx + 1,
884 from_rendered,
885 to_rendered
886 )
887 })?;
888 state.fs.symlink(&from_abs, &to_abs).with_context(|| {
889 format!(
890 "step {}: SYMLINK {} {}",
891 idx + 1,
892 from_rendered,
893 to_rendered
894 )
895 })?;
896 Ok(())
897 }
898 StepKind::Mkdir(path) => {
899 let ctx = state.command_ctx()?;
900 let rendered = expand_template(path, &ctx);
901 let target = state
902 .fs
903 .resolve_write(&state.cwd, &rendered)
904 .with_context(|| format!("step {}: MKDIR {}", idx + 1, rendered))?;
905 state
906 .fs
907 .create_dir_all(&target)
908 .with_context(|| format!("failed to create dir {}", target.display()))?;
909 Ok(())
910 }
911 StepKind::Ls(arg) => {
912 let ctx = state.command_ctx()?;
913 let target_dir = if let Some(p) = arg {
914 let rendered = expand_template(p, &ctx);
915 state
916 .fs
917 .resolve_read(&state.cwd, &rendered)
918 .with_context(|| format!("step {}: LS {}", idx + 1, rendered))?
919 } else {
920 state.cwd.clone()
921 };
922 let mut entries =
923 state.fs.read_dir_entries(&target_dir).with_context(|| {
924 format!("step {}: LS {}", idx + 1, target_dir.display())
925 })?;
926 entries.sort_by_key(|e| e.file_name());
927 write_stdout(out.clone(), |writer| {
928 writeln!(writer, "{}:", target_dir.display())?;
929 for entry in &entries {
930 writeln!(writer, "{}", entry.file_name().to_string_lossy())?;
931 }
932 Ok(())
933 })?;
934 Ok(())
935 }
936 StepKind::Cwd => {
937 let real = canonical_cwd(state.fs.as_ref(), &state.cwd).with_context(|| {
939 format!(
940 "step {}: CWD failed to canonicalize {}",
941 idx + 1,
942 state.cwd.display()
943 )
944 })?;
945 write_stdout(out.clone(), |writer| {
946 writeln!(writer, "{}", real)?;
947 Ok(())
948 })?;
949 Ok(())
950 }
951 StepKind::Read(path_opt) => {
952 let data = if let Some(path) = path_opt {
953 let ctx = state.command_ctx()?;
954 let rendered = expand_template(path, &ctx);
955 let target = state
956 .fs
957 .resolve_read(&state.cwd, &rendered)
958 .with_context(|| format!("step {}: READ {}", idx + 1, rendered))?;
959 state
960 .fs
961 .read_file(&target)
962 .with_context(|| format!("failed to read {}", target.display()))?
963 } else {
964 let mut buf = Vec::new();
965 if let Some(input_stream) = stdin.clone()
966 && let Ok(mut guard) = input_stream.lock()
967 {
968 guard
969 .read_to_end(&mut buf)
970 .context("failed to read from stdin")?;
971 }
972
973 buf
974 };
975 write_stdout(out.clone(), |writer| {
976 writer
977 .write_all(&data)
978 .context("failed to write to output")?;
979 Ok(())
980 })?;
981 Ok(())
982 }
983 StepKind::Write { path, contents } => {
984 let ctx = state.command_ctx()?;
985 let path_rendered = expand_template(path, &ctx);
986 let target = state
987 .fs
988 .resolve_write(&state.cwd, &path_rendered)
989 .with_context(|| format!("step {}: WRITE {}", idx + 1, path_rendered))?;
990 state.fs.ensure_parent_dir(&target).with_context(|| {
991 format!("failed to create parent for {}", target.display())
992 })?;
993 if let Some(body) = contents {
994 let rendered = expand_template(body, &ctx);
995 state
996 .fs
997 .write_file(&target, rendered.as_bytes())
998 .with_context(|| format!("failed to write {}", target.display()))?;
999 } else {
1000 let Some(input_stream) = stdin.clone() else {
1001 bail!(
1002 "step {}: WRITE {} requires stdin (use WITH_IO [stdin=...] WRITE)",
1003 idx + 1,
1004 path_rendered
1005 );
1006 };
1007 let mut guard = input_stream
1008 .lock()
1009 .map_err(|_| anyhow!("failed to lock stdin for WRITE"))?;
1010 let mut data = Vec::new();
1011 guard
1012 .read_to_end(&mut data)
1013 .context("failed to read from stdin for WRITE")?;
1014 drop(guard);
1015 state
1016 .fs
1017 .write_file(&target, &data)
1018 .with_context(|| format!("failed to write {}", target.display()))?;
1019 }
1020 Ok(())
1021 }
1022 StepKind::WithIo { bindings, cmd } => {
1023 let inner_step = Step {
1024 guard: None,
1025 kind: *cmd.clone(),
1026 scope_enter: 0,
1027 scope_exit: 0,
1028 };
1029 let steps = vec![inner_step];
1030
1031 let mut step_stdin = None;
1032 let mut step_stdout = out.clone();
1033 let mut step_stderr = err.clone();
1034 let mut next_expose_stdin = false;
1035 let mut seen_stdin = false;
1036 let mut seen_stdout = false;
1037 let mut seen_stderr = false;
1038
1039 for binding in bindings {
1040 if let Some(pipe) = &binding.pipe {
1041 state.io.ensure_script_pipe(pipe);
1042 }
1043 match binding.stream {
1044 IoStream::Stdin => {
1045 if seen_stdin {
1046 bail!(
1047 "step {}: WITH_IO declared stdin more than once",
1048 idx + 1
1049 );
1050 }
1051 seen_stdin = true;
1052 next_expose_stdin = true;
1053 step_stdin = if let Some(pipe) = &binding.pipe {
1054 Some(state.io.input_pipe(pipe).ok_or_else(|| {
1055 anyhow!(
1056 "step {}: WITH_IO stdin pipe '{}' is undefined",
1057 idx + 1,
1058 pipe
1059 )
1060 })?)
1061 } else {
1062 stdin.clone()
1063 };
1064 }
1065 IoStream::Stdout => {
1066 if seen_stdout {
1067 bail!(
1068 "step {}: WITH_IO declared stdout more than once",
1069 idx + 1
1070 );
1071 }
1072 seen_stdout = true;
1073 step_stdout = if let Some(pipe) = &binding.pipe {
1074 Some(
1075 state
1076 .io
1077 .output_pipe_stdout(pipe)
1078 .ok_or_else(|| {
1079 anyhow!(
1080 "step {}: WITH_IO stdout pipe '{}' is undefined",
1081 idx + 1,
1082 pipe
1083 )
1084 })?
1085 .to_stream_handle(),
1086 )
1087 } else {
1088 out.clone()
1089 };
1090 }
1091 IoStream::Stderr => {
1092 if seen_stderr {
1093 bail!(
1094 "step {}: WITH_IO declared stderr more than once",
1095 idx + 1
1096 );
1097 }
1098 seen_stderr = true;
1099 step_stderr = if let Some(pipe) = &binding.pipe {
1100 Some(
1101 state
1102 .io
1103 .output_pipe_stderr(pipe)
1104 .ok_or_else(|| {
1105 anyhow!(
1106 "step {}: WITH_IO stderr pipe '{}' is undefined",
1107 idx + 1,
1108 pipe
1109 )
1110 })?
1111 .to_stream_handle(),
1112 )
1113 } else {
1114 err.clone()
1115 };
1116 }
1117 }
1118 }
1119
1120 execute_steps(
1121 state,
1122 process,
1123 &steps,
1124 step_stdin,
1125 next_expose_stdin,
1126 step_stdout,
1127 step_stderr,
1128 false,
1129 )?;
1130 Ok(())
1131 }
1132 StepKind::WithIoBlock { .. } => {
1133 bail!("WITH_IO block should have been expanded during parsing")
1134 }
1135
1136 StepKind::Exit(code) => {
1137 for child in state.bg_children.iter_mut() {
1138 if child.try_wait()?.is_none() {
1139 let _ = child.kill();
1140 let _ = child.wait();
1141 }
1142 }
1143 state.bg_children.clear();
1144 bail!("EXIT requested with code {}", code);
1145 }
1146 }
1147 };
1148
1149 let restore_result = restore_scopes(state, step.scope_exit);
1150 step_result?;
1151 restore_result?;
1152 if let Some(status) = check_bg(&mut state.bg_children)? {
1153 if status.success() {
1154 return Ok(());
1155 } else {
1156 bail!("RUN_BG exited with status {}", status);
1157 }
1158 }
1159 }
1160
1161 if wait_at_end && !state.bg_children.is_empty() {
1162 let mut first = state.bg_children.remove(0);
1163 let status = first.wait()?;
1164 for child in state.bg_children.iter_mut() {
1165 if child.try_wait()?.is_none() {
1166 let _ = child.kill();
1167 let _ = child.wait();
1168 }
1169 }
1170 state.bg_children.clear();
1171 if status.success() {
1172 return Ok(());
1173 } else {
1174 bail!("RUN_BG exited with status {}", status);
1175 }
1176 }
1177
1178 Ok(())
1179}
1180
1181fn restore_scopes<P: ProcessManager>(state: &mut ExecState<P>, count: usize) -> Result<()> {
1182 for _ in 0..count {
1183 let snapshot = state
1184 .scope_stack
1185 .pop()
1186 .ok_or_else(|| anyhow!("scope stack underflow during pop"))?;
1187 state.fs.set_root(snapshot.root.clone());
1188 state.cwd = snapshot.cwd;
1189 state.envs = snapshot.envs;
1190 }
1191 Ok(())
1192}
1193
1194fn copy_entry(fs: &dyn WorkspaceFs, src: &GuardedPath, dst: &GuardedPath) -> Result<()> {
1195 match fs.entry_kind(src)? {
1196 EntryKind::Dir => {
1197 fs.copy_dir_recursive(src, dst)?;
1198 }
1199 EntryKind::File => {
1200 fs.ensure_parent_dir(dst)?;
1201 fs.copy_file(src, dst)?;
1202 }
1203 }
1204 Ok(())
1205}
1206
1207fn canonical_cwd(fs: &dyn WorkspaceFs, cwd: &GuardedPath) -> Result<String> {
1208 Ok(fs.canonicalize(cwd)?.display().to_string())
1209}
1210
1211fn describe_dir(
1212 fs: &dyn WorkspaceFs,
1213 root: &GuardedPath,
1214 max_depth: usize,
1215 max_entries: usize,
1216) -> String {
1217 fn helper(
1218 fs: &dyn WorkspaceFs,
1219 guard_root: &GuardedPath,
1220 path: &GuardedPath,
1221 depth: usize,
1222 max_depth: usize,
1223 left: &mut usize,
1224 out: &mut String,
1225 ) {
1226 if *left == 0 {
1227 return;
1228 }
1229 let indent = " ".repeat(depth);
1230 if depth > 0 {
1231 out.push_str(&format!(
1232 "{}{}\n",
1233 indent,
1234 path.as_path()
1235 .file_name()
1236 .unwrap_or_default()
1237 .to_string_lossy()
1238 ));
1239 }
1240 if depth >= max_depth {
1241 return;
1242 }
1243 let entries = match fs.read_dir_entries(path) {
1244 Ok(e) => e,
1245 Err(_) => return,
1246 };
1247 let mut names: Vec<_> = entries.into_iter().collect();
1248 names.sort_by_key(|a| a.file_name());
1249 for entry in names {
1250 if *left == 0 {
1251 return;
1252 }
1253 *left -= 1;
1254 let file_type = match entry.file_type() {
1255 Ok(ft) => ft,
1256 Err(_) => continue,
1257 };
1258 let p = entry.path();
1259 let guarded_child = match GuardedPath::new(guard_root.root(), &p) {
1260 Ok(child) => child,
1261 Err(_) => continue,
1262 };
1263 if file_type.is_dir() {
1264 helper(
1265 fs,
1266 guard_root,
1267 &guarded_child,
1268 depth + 1,
1269 max_depth,
1270 left,
1271 out,
1272 );
1273 } else {
1274 out.push_str(&format!(
1275 "{} {}\n",
1276 indent,
1277 entry.file_name().to_string_lossy()
1278 ));
1279 }
1280 }
1281 }
1282
1283 let mut out = String::new();
1284 let mut left = max_entries;
1285 helper(fs, root, root, 0, max_depth, &mut left, &mut out);
1286 out
1287}
1288
1289fn hash_path(
1290 fs: &dyn WorkspaceFs,
1291 path: &GuardedPath,
1292 rel: &str,
1293 hasher: &mut Sha256,
1294) -> Result<()> {
1295 match fs.entry_kind(path)? {
1296 EntryKind::Dir => {
1297 hasher.update(b"D\0");
1298 hasher.update(rel.as_bytes());
1299 hasher.update(b"\0");
1300 let mut entries = fs.read_dir_entries(path)?;
1301 entries.sort_by_key(|entry| entry.file_name());
1302 for entry in entries {
1303 let name = entry.file_name().to_string_lossy().to_string();
1304 let child = path.join(&name)?;
1305 let child_rel = if rel.is_empty() {
1306 name
1307 } else {
1308 format!("{}/{}", rel, name)
1309 };
1310 let child_rel = to_forward_slashes(&child_rel);
1311 hash_path(fs, &child, &child_rel, hasher)?;
1312 }
1313 }
1314 EntryKind::File => {
1315 let data = fs.read_file(path)?;
1316 if rel.is_empty() {
1317 hasher.update(&data);
1318 } else {
1319 hasher.update(b"F\0");
1320 hasher.update(rel.as_bytes());
1321 hasher.update(b"\0");
1322 hasher.update(&data);
1323 }
1324 }
1325 }
1326 Ok(())
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::*;
1332 use oxdock_fs::{GuardedPath, MockFs};
1333 use oxdock_parser::{Guard, GuardExpr, IoBinding, IoStream};
1334 use oxdock_process::{MockProcessManager, MockRunCall};
1335 use std::collections::HashMap;
1336
1337 #[test]
1338 fn run_records_env_and_cwd() {
1339 let root = GuardedPath::new_root_from_str(".").unwrap();
1340 let steps = vec![
1341 Step {
1342 guard: None,
1343 kind: StepKind::Env {
1344 key: "FOO".into(),
1345 value: "bar".into(),
1346 },
1347 scope_enter: 0,
1348 scope_exit: 0,
1349 },
1350 Step {
1351 guard: None,
1352 kind: StepKind::Run("echo hi".into()),
1353 scope_enter: 0,
1354 scope_exit: 0,
1355 },
1356 ];
1357 let mock = MockProcessManager::default();
1358 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1359 run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1360 let runs = mock.recorded_runs();
1361 assert_eq!(runs.len(), 1);
1362 let MockRunCall {
1363 script,
1364 cwd,
1365 envs,
1366 cargo_target_dir,
1367 ..
1368 } = &runs[0];
1369 assert_eq!(script, "echo hi");
1370 assert_eq!(cwd, root.as_path());
1371 assert_eq!(
1372 cargo_target_dir,
1373 &root.join(".cargo-target").unwrap().to_path_buf()
1374 );
1375 assert_eq!(envs.get("FOO"), Some(&"bar".into()));
1376 }
1377
1378 #[test]
1379 fn run_expands_env_values() {
1380 let root = GuardedPath::new_root_from_str(".").unwrap();
1381 let steps = vec![
1382 Step {
1383 guard: None,
1384 kind: StepKind::Env {
1385 key: "FOO".into(),
1386 value: "bar".into(),
1387 },
1388 scope_enter: 0,
1389 scope_exit: 0,
1390 },
1391 Step {
1392 guard: None,
1393 kind: StepKind::Run("echo {{ env:FOO }}".into()),
1394 scope_enter: 0,
1395 scope_exit: 0,
1396 },
1397 ];
1398 let mock = MockProcessManager::default();
1399 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1400 run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1401 let runs = mock.recorded_runs();
1402 assert_eq!(runs.len(), 1);
1403 assert_eq!(runs[0].script, "echo bar");
1404 }
1405
1406 #[test]
1407 fn run_bg_completion_short_circuits_pipeline() {
1408 let root = GuardedPath::new_root_from_str(".").unwrap();
1409 let steps = vec![
1410 Step {
1411 guard: None,
1412 kind: StepKind::RunBg("sleep".into()),
1413 scope_enter: 0,
1414 scope_exit: 0,
1415 },
1416 Step {
1417 guard: None,
1418 kind: StepKind::Run("echo after".into()),
1419 scope_enter: 0,
1420 scope_exit: 0,
1421 },
1422 ];
1423 let mock = MockProcessManager::default();
1424 mock.push_bg_plan(0, success_status());
1425 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1426 run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1427 assert!(
1428 mock.recorded_runs().is_empty(),
1429 "foreground run should not execute when RUN_BG completes early"
1430 );
1431 let spawns = mock.spawn_log();
1432 let spawned: Vec<_> = spawns.iter().map(|c| c.script.as_str()).collect();
1433 assert_eq!(spawned, vec!["sleep"]);
1434 }
1435
1436 #[test]
1437 fn exit_kills_background_processes() {
1438 let root = GuardedPath::new_root_from_str(".").unwrap();
1439 let steps = vec![
1440 Step {
1441 guard: None,
1442 kind: StepKind::RunBg("bg-task".into()),
1443 scope_enter: 0,
1444 scope_exit: 0,
1445 },
1446 Step {
1447 guard: None,
1448 kind: StepKind::Exit(5),
1449 scope_enter: 0,
1450 scope_exit: 0,
1451 },
1452 ];
1453 let mock = MockProcessManager::default();
1454 mock.push_bg_plan(usize::MAX, success_status());
1455 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1456 let err = run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap_err();
1457 assert!(
1458 err.to_string().contains("EXIT requested with code 5"),
1459 "unexpected error: {err}"
1460 );
1461 assert_eq!(mock.killed(), vec!["bg-task"]);
1462 }
1463
1464 #[test]
1465 fn symlink_errors_report_underlying_cause() {
1466 let temp = GuardedPath::tempdir().unwrap();
1467 let root = temp.as_guarded_path().clone();
1468 let steps = vec![
1469 Step {
1470 guard: None,
1471 kind: StepKind::Mkdir("client".into()),
1472 scope_enter: 0,
1473 scope_exit: 0,
1474 },
1475 Step {
1476 guard: None,
1477 kind: StepKind::Symlink {
1478 from: "client".into(),
1479 to: "client".into(),
1480 },
1481 scope_enter: 0,
1482 scope_exit: 0,
1483 },
1484 ];
1485 let err = run_steps(&root, &steps).unwrap_err();
1486 let msg = err.to_string();
1487 assert!(
1488 msg.contains("step 2: SYMLINK client client"),
1489 "error should include step context: {msg}"
1490 );
1491 assert!(
1492 msg.contains("SYMLINK destination already exists"),
1493 "error should surface underlying cause: {msg}"
1494 );
1495 }
1496
1497 #[test]
1498 fn guarded_run_waits_for_env_to_be_set() {
1499 let root = GuardedPath::new_root_from_str(".").unwrap();
1500 let guard = Guard::EnvEquals {
1501 key: "READY".into(),
1502 value: "1".into(),
1503 invert: false,
1504 };
1505 let steps = vec![
1506 Step {
1507 guard: Some(guard.clone().into()),
1508 kind: StepKind::Run("echo first".into()),
1509 scope_enter: 0,
1510 scope_exit: 0,
1511 },
1512 Step {
1513 guard: None,
1514 kind: StepKind::Env {
1515 key: "READY".into(),
1516 value: "1".into(),
1517 },
1518 scope_enter: 0,
1519 scope_exit: 0,
1520 },
1521 Step {
1522 guard: Some(guard.into()),
1523 kind: StepKind::Run("echo second".into()),
1524 scope_enter: 0,
1525 scope_exit: 0,
1526 },
1527 ];
1528 let mock = MockProcessManager::default();
1529 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1530 run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1531 let runs = mock.recorded_runs();
1532 assert_eq!(runs.len(), 1);
1533 assert_eq!(runs[0].script, "echo second");
1534 }
1535
1536 #[test]
1537 fn guard_groups_allow_any_matching_branch() {
1538 let root = GuardedPath::new_root_from_str(".").unwrap();
1539 let guard_alpha = Guard::EnvEquals {
1540 key: "MODE".into(),
1541 value: "alpha".into(),
1542 invert: false,
1543 };
1544 let guard_beta = Guard::EnvEquals {
1545 key: "MODE".into(),
1546 value: "beta".into(),
1547 invert: false,
1548 };
1549 let steps = vec![
1550 Step {
1551 guard: None,
1552 kind: StepKind::Env {
1553 key: "MODE".into(),
1554 value: "beta".into(),
1555 },
1556 scope_enter: 0,
1557 scope_exit: 0,
1558 },
1559 Step {
1560 guard: Some(GuardExpr::or(vec![guard_alpha.into(), guard_beta.into()])),
1561 kind: StepKind::Run("echo guarded".into()),
1562 scope_enter: 0,
1563 scope_exit: 0,
1564 },
1565 ];
1566 let mock = MockProcessManager::default();
1567 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1568 run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1569 let runs = mock.recorded_runs();
1570 assert_eq!(runs.len(), 1);
1571 assert_eq!(runs[0].script, "echo guarded");
1572 }
1573
1574 #[test]
1575 fn with_io_pipe_routes_stdout_to_run_stdin() {
1576 let steps = vec![
1577 Step {
1578 guard: None,
1579 kind: StepKind::WithIo {
1580 bindings: vec![IoBinding {
1581 stream: IoStream::Stdout,
1582 pipe: Some("shared".into()),
1583 }],
1584 cmd: Box::new(StepKind::Echo("hello".into())),
1585 },
1586 scope_enter: 0,
1587 scope_exit: 0,
1588 },
1589 Step {
1590 guard: None,
1591 kind: StepKind::WithIo {
1592 bindings: vec![IoBinding {
1593 stream: IoStream::Stdin,
1594 pipe: Some("shared".into()),
1595 }],
1596 cmd: Box::new(StepKind::Run("cat".into())),
1597 },
1598 scope_enter: 0,
1599 scope_exit: 0,
1600 },
1601 ];
1602
1603 let fs = MockFs::new();
1604 let mut state = create_exec_state(fs);
1605 let mut proc = MockProcessManager::default();
1606 execute_steps(&mut state, &mut proc, &steps, None, false, None, None, true)
1607 .expect("pipeline executes");
1608
1609 let runs = proc.recorded_runs();
1610 assert_eq!(runs.len(), 1);
1611 let MockRunCall { stdin, .. } = &runs[0];
1612 assert_eq!(stdin.as_deref(), Some(b"hello\n".as_slice()));
1613 }
1614
1615 fn success_status() -> ExitStatus {
1616 exit_status_from_code(0)
1617 }
1618
1619 #[cfg(unix)]
1620 fn exit_status_from_code(code: i32) -> ExitStatus {
1621 use std::os::unix::process::ExitStatusExt;
1622 ExitStatusExt::from_raw(code << 8)
1623 }
1624
1625 #[cfg(windows)]
1626 fn exit_status_from_code(code: i32) -> ExitStatus {
1627 use std::os::windows::process::ExitStatusExt;
1628 ExitStatusExt::from_raw(code as u32)
1629 }
1630
1631 fn create_exec_state(fs: MockFs) -> ExecState<MockProcessManager> {
1632 let cargo = fs.root().join(".cargo-target").unwrap();
1633 ExecState {
1634 fs: Box::new(fs.clone()),
1635 cargo_target_dir: cargo,
1636 cwd: fs.root().clone(),
1637 envs: HashMap::new(),
1638 bg_children: Vec::new(),
1639 scope_stack: Vec::new(),
1640 io: ExecIo::new(),
1641 }
1642 }
1643
1644 fn run_with_mock_fs(steps: &[Step]) -> (GuardedPath, HashMap<String, Vec<u8>>) {
1645 let fs = MockFs::new();
1646 let mut state = create_exec_state(fs.clone());
1647 let mut proc = MockProcessManager::default();
1648 execute_steps(&mut state, &mut proc, steps, None, false, None, None, true).unwrap();
1649 (state.cwd, fs.snapshot())
1650 }
1651
1652 #[test]
1653 fn mock_fs_handles_workdir_and_write() {
1654 let steps = vec![
1655 Step {
1656 guard: None,
1657 kind: StepKind::Mkdir("app".into()),
1658 scope_enter: 0,
1659 scope_exit: 0,
1660 },
1661 Step {
1662 guard: None,
1663 kind: StepKind::Workdir("app".into()),
1664 scope_enter: 0,
1665 scope_exit: 0,
1666 },
1667 Step {
1668 guard: None,
1669 kind: StepKind::Write {
1670 path: "out.txt".into(),
1671 contents: Some("hi".into()),
1672 },
1673 scope_enter: 0,
1674 scope_exit: 0,
1675 },
1676 Step {
1677 guard: None,
1678 kind: StepKind::Read(Some("out.txt".into())),
1679 scope_enter: 0,
1680 scope_exit: 0,
1681 },
1682 ];
1683 let (_cwd, files) = run_with_mock_fs(&steps);
1684 let written = files
1685 .iter()
1686 .find(|(k, _)| k.ends_with("app/out.txt"))
1687 .map(|(_, v)| String::from_utf8_lossy(v).to_string());
1688 assert_eq!(written, Some("hi".into()));
1689 }
1690
1691 #[test]
1692 fn write_interpolates_env_values() {
1693 let steps = vec![
1694 Step {
1695 guard: None,
1696 kind: StepKind::Env {
1697 key: "FOO".into(),
1698 value: "bar".into(),
1699 },
1700 scope_enter: 0,
1701 scope_exit: 0,
1702 },
1703 Step {
1704 guard: None,
1705 kind: StepKind::Env {
1706 key: "BAZ".into(),
1707 value: "{{ env:FOO }}-baz".into(),
1708 },
1709 scope_enter: 0,
1710 scope_exit: 0,
1711 },
1712 Step {
1713 guard: None,
1714 kind: StepKind::Write {
1715 path: "out.txt".into(),
1716 contents: Some("val {{ env:BAZ }}".into()),
1717 },
1718 scope_enter: 0,
1719 scope_exit: 0,
1720 },
1721 ];
1722 let (_cwd, files) = run_with_mock_fs(&steps);
1723 let written = files
1724 .iter()
1725 .find(|(k, _)| k.ends_with("out.txt"))
1726 .map(|(_, v)| String::from_utf8_lossy(v).to_string());
1727 assert_eq!(written, Some("val bar-baz".into()));
1728 }
1729
1730 #[cfg_attr(
1731 miri,
1732 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
1733 )]
1734 #[test]
1735 fn cat_and_capture_expand_env_paths() {
1736 let temp = GuardedPath::tempdir().expect("tempdir");
1737 let root = temp.as_guarded_path().clone();
1738 let steps = vec![
1739 Step {
1740 guard: None,
1741 kind: StepKind::Write {
1742 path: "snippet.txt".into(),
1743 contents: Some("payload".into()),
1744 },
1745 scope_enter: 0,
1746 scope_exit: 0,
1747 },
1748 Step {
1749 guard: None,
1750 kind: StepKind::Env {
1751 key: "SNIPPET".into(),
1752 value: "snippet.txt".into(),
1753 },
1754 scope_enter: 0,
1755 scope_exit: 0,
1756 },
1757 Step {
1758 guard: None,
1759 kind: StepKind::Env {
1760 key: "OUT_FILE".into(),
1761 value: "cat-{{ env:SNIPPET }}".into(),
1762 },
1763 scope_enter: 0,
1764 scope_exit: 0,
1765 },
1766 Step {
1767 guard: None,
1768 kind: StepKind::WithIo {
1769 bindings: vec![IoBinding {
1770 stream: IoStream::Stdout,
1771 pipe: Some("cap-cat".to_string()),
1772 }],
1773 cmd: Box::new(StepKind::Read(Some("{{ env:SNIPPET }}".into()))),
1774 },
1775 scope_enter: 0,
1776 scope_exit: 0,
1777 },
1778 Step {
1779 guard: None,
1780 kind: StepKind::WithIo {
1781 bindings: vec![IoBinding {
1782 stream: IoStream::Stdin,
1783 pipe: Some("cap-cat".to_string()),
1784 }],
1785 cmd: Box::new(StepKind::Write {
1786 path: "{{ env:OUT_FILE }}".into(),
1787 contents: None,
1788 }),
1789 },
1790 scope_enter: 0,
1791 scope_exit: 0,
1792 },
1793 ];
1794 run_steps(&root, &steps).expect("capture with env paths succeeds");
1795 let resolver = PathResolver::new(root.as_path(), root.as_path()).expect("resolver");
1796 let captured_path = root.join("cat-snippet.txt").expect("capture path");
1797 let contents = resolver
1798 .read_to_string(&captured_path)
1799 .expect("read captured output");
1800 assert_eq!(contents, "payload");
1801 }
1802
1803 #[test]
1804 fn final_cwd_tracks_last_workdir() {
1805 let steps = vec![
1806 Step {
1807 guard: None,
1808 kind: StepKind::Write {
1809 path: "temp.txt".into(),
1810 contents: Some("123".into()),
1811 },
1812 scope_enter: 0,
1813 scope_exit: 0,
1814 },
1815 Step {
1816 guard: None,
1817 kind: StepKind::Workdir("sub".into()),
1818 scope_enter: 0,
1819 scope_exit: 0,
1820 },
1821 ];
1822 let (cwd, snapshot) = run_with_mock_fs(&steps);
1823 assert!(
1824 cwd.as_path().ends_with("sub"),
1825 "expected final cwd to match last WORKDIR, got {}",
1826 cwd.display()
1827 );
1828 let keys: Vec<_> = snapshot.keys().cloned().collect();
1829 assert!(
1830 keys.iter().any(|path| path.ends_with("temp.txt")),
1831 "WRITE should produce temp file, snapshot: {:?}",
1832 keys
1833 );
1834 }
1835
1836 #[test]
1837 fn mock_fs_normalizes_backslash_workdir() {
1838 let steps = vec![
1839 Step {
1840 guard: None,
1841 kind: StepKind::Mkdir("win\\nested".into()),
1842 scope_enter: 0,
1843 scope_exit: 0,
1844 },
1845 Step {
1846 guard: None,
1847 kind: StepKind::Workdir("win\\nested".into()),
1848 scope_enter: 0,
1849 scope_exit: 0,
1850 },
1851 Step {
1852 guard: None,
1853 kind: StepKind::Write {
1854 path: "inner.txt".into(),
1855 contents: Some("ok".into()),
1856 },
1857 scope_enter: 0,
1858 scope_exit: 0,
1859 },
1860 ];
1861 let (cwd, snapshot) = run_with_mock_fs(&steps);
1862 let cwd_display = cwd.display().to_string();
1863 assert!(
1864 cwd_display.ends_with("win\\nested") || cwd_display.ends_with("win/nested"),
1865 "expected cwd to normalize backslashes, got {cwd_display}"
1866 );
1867 assert!(
1868 snapshot
1869 .keys()
1870 .any(|path| path.ends_with("win/nested/inner.txt")),
1871 "expected file under normalized path, snapshot: {:?}",
1872 snapshot.keys()
1873 );
1874 }
1875
1876 #[cfg(windows)]
1877 #[test]
1878 fn mock_fs_rejects_absolute_windows_paths() {
1879 let steps = vec![Step {
1880 guard: None,
1881 kind: StepKind::Workdir(r"C:\outside".into()),
1882 scope_enter: 0,
1883 scope_exit: 0,
1884 }];
1885 let fs = MockFs::new();
1886 let mut state = create_exec_state(fs);
1887 let mut proc = MockProcessManager::default();
1888 let sink = Arc::new(Mutex::new(Vec::new()));
1889 let err = execute_steps(
1890 &mut state,
1891 &mut proc,
1892 &steps,
1893 None,
1894 false,
1895 Some(StreamHandle::Stream(sink.clone())),
1896 Some(StreamHandle::Stream(sink)),
1897 false,
1898 )
1899 .unwrap_err();
1900 let msg = format!("{err:#}");
1901 assert!(
1902 msg.contains("escapes allowed root"),
1903 "unexpected error for absolute Windows path: {msg}"
1904 );
1905 }
1906
1907 #[test]
1908 fn with_stdin_passes_content_to_run() {
1909 let steps = vec![Step {
1910 guard: None,
1911 kind: StepKind::WithIo {
1912 bindings: vec![IoBinding {
1913 stream: IoStream::Stdin,
1914 pipe: None,
1915 }],
1916 cmd: Box::new(StepKind::Run("cat".into())),
1917 },
1918 scope_enter: 0,
1919 scope_exit: 0,
1920 }];
1921
1922 let mock = MockProcessManager::default();
1923 let root = GuardedPath::new_root_from_str(".").unwrap();
1924 let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1925
1926 let input = Arc::new(Mutex::new(std::io::Cursor::new(b"hello world".to_vec())));
1927
1928 let mut io_cfg = ExecIo::new();
1929 io_cfg.set_stdin(Some(input));
1930
1931 run_steps_with_manager(fs, &steps, mock.clone(), io_cfg).unwrap();
1932
1933 let runs = mock.recorded_runs();
1934 assert_eq!(runs.len(), 1);
1935 assert_eq!(runs[0].script, "cat");
1936 assert_eq!(runs[0].stdin, Some(b"hello world".to_vec()));
1937 }
1938}