oxdock_process/
lib.rs

1pub mod builtin_env;
2#[cfg(feature = "mock-process")]
3mod mock;
4pub mod serial_cargo_env;
5mod shell;
6
7use anyhow::{Context, Result, anyhow, bail};
8pub use builtin_env::BuiltinEnv;
9use oxdock_fs::{GuardedPath, PolicyPath};
10pub use oxdock_sys_test_utils::TestEnvGuard;
11use shell::shell_cmd;
12pub use shell::{ShellLauncher, shell_program};
13use std::collections::HashMap;
14#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
15use std::fs::File;
16#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
17use std::path::{Path, PathBuf};
18#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
19use std::process::{Child, Command as ProcessCommand, ExitStatus, Output as StdOutput, Stdio};
20use std::{
21    ffi::{OsStr, OsString},
22    iter::IntoIterator,
23    mem,
24};
25
26#[cfg(miri)]
27use oxdock_fs::PathResolver;
28
29#[cfg(feature = "mock-process")]
30pub use mock::{MockHandle, MockProcessManager, MockRunCall, MockSpawnCall};
31
32/// Context passed to process managers describing the current execution
33/// environment. Clones are cheap and explicit so background handles can own
34/// their working roots without juggling lifetimes.
35#[derive(Clone, Debug)]
36pub struct CommandContext {
37    cwd: PolicyPath,
38    envs: HashMap<String, String>,
39    cargo_target_dir: GuardedPath,
40    workspace_root: GuardedPath,
41    build_context: GuardedPath,
42}
43
44impl CommandContext {
45    #[allow(clippy::too_many_arguments)]
46    pub fn new(
47        cwd: &PolicyPath,
48        envs: &HashMap<String, String>,
49        cargo_target_dir: &GuardedPath,
50        workspace_root: &GuardedPath,
51        build_context: &GuardedPath,
52    ) -> Self {
53        Self {
54            cwd: cwd.clone(),
55            envs: envs.clone(),
56            cargo_target_dir: cargo_target_dir.clone(),
57            workspace_root: workspace_root.clone(),
58            build_context: build_context.clone(),
59        }
60    }
61
62    pub fn cwd(&self) -> &PolicyPath {
63        &self.cwd
64    }
65
66    pub fn envs(&self) -> &HashMap<String, String> {
67        &self.envs
68    }
69
70    pub fn cargo_target_dir(&self) -> &GuardedPath {
71        &self.cargo_target_dir
72    }
73
74    pub fn workspace_root(&self) -> &GuardedPath {
75        &self.workspace_root
76    }
77
78    pub fn build_context(&self) -> &GuardedPath {
79        &self.build_context
80    }
81}
82
83fn expand_with_lookup<F>(input: &str, mut lookup: F) -> String
84where
85    F: FnMut(&str) -> Option<String>,
86{
87    let mut out = String::with_capacity(input.len());
88    let mut chars = input.chars().peekable();
89    while let Some(c) = chars.next() {
90        if c == '{' {
91            if let Some(&'{') = chars.peek() {
92                chars.next(); // consume second '{'
93                let mut content = String::new();
94                let mut closed = false;
95                // Look ahead for closing }}
96                let mut inner_chars = chars.clone();
97                while let Some(ch) = inner_chars.next() {
98                    if ch == '}'
99                        && let Some(&'}') = inner_chars.peek()
100                    {
101                        closed = true;
102                        break;
103                    }
104                    content.push(ch);
105                }
106
107                if closed {
108                    // Advance main iterator past content and closing braces
109                    for _ in 0..content.len() {
110                        chars.next();
111                    }
112                    chars.next(); // first }
113                    chars.next(); // second }
114
115                    let key = content.trim();
116                    if !key.is_empty() {
117                        out.push_str(&lookup(key).unwrap_or_default());
118                    }
119                } else {
120                    out.push('{');
121                    out.push('{');
122                }
123            } else {
124                out.push('{');
125            }
126        } else {
127            out.push(c);
128        }
129    }
130    out
131}
132
133pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
134    expand_with_lookup(input, |name| {
135        if let Some(key) = name.strip_prefix("env:") {
136            script_envs
137                .get(key)
138                .cloned()
139                .or_else(|| std::env::var(key).ok())
140        } else {
141            None
142        }
143    })
144}
145
146pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
147    expand_with_lookup(input, |name| {
148        if let Some(key) = name.strip_prefix("env:") {
149            ctx.envs().get(key).cloned()
150        } else {
151            None
152        }
153    })
154}
155
156/// Handle for background processes spawned by a [`ProcessManager`].
157pub trait BackgroundHandle {
158    fn try_wait(&mut self) -> Result<Option<ExitStatus>>;
159    fn kill(&mut self) -> Result<()>;
160    fn wait(&mut self) -> Result<ExitStatus>;
161}
162
163/// Abstraction for running shell commands both in the foreground and
164/// background. `oxdock-core` relies on this trait to decouple the executor
165/// from `std::process::Command`, which in turn enables Miri-friendly test
166/// doubles.
167use std::sync::{Arc, Mutex};
168
169pub type SharedInput = Arc<Mutex<dyn std::io::Read + Send>>;
170pub type SharedOutput = Arc<Mutex<dyn std::io::Write + Send>>;
171
172#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
173pub enum CommandMode {
174    #[default]
175    Foreground,
176    Background,
177}
178
179#[derive(Clone, Default)]
180pub enum CommandStdout {
181    #[default]
182    Inherit,
183    Stream(SharedOutput),
184    Capture,
185}
186
187#[derive(Clone, Default)]
188pub enum CommandStderr {
189    #[default]
190    Inherit,
191    Stream(SharedOutput),
192}
193
194#[derive(Clone, Default)]
195pub struct CommandOptions {
196    pub mode: CommandMode,
197    pub stdin: Option<SharedInput>,
198    pub stdout: CommandStdout,
199    pub stderr: CommandStderr,
200}
201
202impl CommandOptions {
203    pub fn foreground() -> Self {
204        Self::default()
205    }
206
207    pub fn background() -> Self {
208        Self {
209            mode: CommandMode::Background,
210            ..Self::default()
211        }
212    }
213}
214
215pub enum CommandResult<H> {
216    Completed,
217    Captured(Vec<u8>),
218    Background(H),
219}
220
221pub trait ProcessManager: Clone {
222    type Handle: BackgroundHandle;
223
224    fn run_command(
225        &mut self,
226        ctx: &CommandContext,
227        script: &str,
228        options: CommandOptions,
229    ) -> Result<CommandResult<Self::Handle>>;
230}
231
232/// Default process manager that shells out using the system shell.
233#[derive(Clone, Default)]
234#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
235pub struct ShellProcessManager;
236
237impl ProcessManager for ShellProcessManager {
238    type Handle = ChildHandle;
239
240    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
241    fn run_command(
242        &mut self,
243        ctx: &CommandContext,
244        script: &str,
245        options: CommandOptions,
246    ) -> Result<CommandResult<Self::Handle>> {
247        if std::env::var_os("OXBOOK_DEBUG").is_some() {
248            eprintln!("oxbook run_command: {script}");
249        }
250        let mut command = shell_cmd(script);
251        apply_ctx(&mut command, ctx);
252        let CommandOptions {
253            mode,
254            stdin,
255            stdout,
256            stderr,
257        } = options;
258
259        let (stdout_stream, capture_buf) = match stdout {
260            CommandStdout::Inherit => (None, None),
261            CommandStdout::Stream(stream) => (Some(stream), None),
262            CommandStdout::Capture => {
263                if matches!(mode, CommandMode::Background) {
264                    bail!("cannot capture stdout for background command");
265                }
266                let buf = Arc::new(Mutex::new(Vec::new()));
267                let writer: SharedOutput = buf.clone();
268                (Some(writer), Some(buf))
269            }
270        };
271
272        let stderr_stream = match stderr {
273            CommandStderr::Inherit => None,
274            CommandStderr::Stream(stream) => Some(stream),
275        };
276
277        let need_null_stdin = stdin.is_none();
278        if need_null_stdin {
279            // Do not inherit stdin by default; ensure isolation unless WITH_STDIN is used.
280            command.stdin(Stdio::null());
281        }
282        let desc = format!("{:?}", command);
283
284        match mode {
285            CommandMode::Foreground => {
286                let mut handle =
287                    spawn_child_with_streams(&mut command, stdin, stdout_stream, stderr_stream)?;
288                let status = handle
289                    .wait()
290                    .with_context(|| format!("failed to run {desc}"))?;
291                if !status.success() {
292                    bail!("command {desc} failed with status {}", status);
293                }
294                if let Some(buf) = capture_buf {
295                    let mut guard = buf.lock().map_err(|_| anyhow!("capture stdout poisoned"))?;
296                    return Ok(CommandResult::Captured(mem::take(&mut *guard)));
297                }
298                Ok(CommandResult::Completed)
299            }
300            CommandMode::Background => {
301                let handle =
302                    spawn_child_with_streams(&mut command, stdin, stdout_stream, stderr_stream)?;
303                Ok(CommandResult::Background(handle))
304            }
305        }
306    }
307}
308
309#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
310fn apply_ctx(command: &mut ProcessCommand, ctx: &CommandContext) {
311    // Use command_path to strip Windows verbatim prefixes (\\?\) before passing to Command.
312    // While Rust's `std::process::Command` handles verbatim paths in current_dir correctly,
313    // environment variables are passed as-is. If we pass a verbatim path in `CARGO_TARGET_DIR`,
314    // tools that don't understand it (or shell scripts echoing it) might misbehave or produce
315    // unexpected output. Normalizing here ensures consistency.
316    //
317    // Why the `\\?\` verbatim prefixes?
318    // On Windows we intentionally keep the canonical verbatim path (e.g. `\\?\C:\\repo`)
319    // inside every `GuardedPath`. This avoids MAX_PATH truncation and prevents subtle
320    // `PathBuf` casing/drive-letter surprises when the guard is later joined, copied,
321    // or passed through `std::fs`. When you need a human-readable path, call
322    // [`command_path`] (native separators, prefix stripped) or [`normalized_path`]
323    // (forward slashes) or use the `Display` impl, which already defers to
324    // `command_path`. Keep the debug view raw so diagnostics can show the exact path
325    // we are guarding.
326    let cwd_path: std::borrow::Cow<std::path::Path> = match ctx.cwd() {
327        PolicyPath::Guarded(p) => oxdock_fs::command_path(p),
328        PolicyPath::Unguarded(p) => std::borrow::Cow::Borrowed(p.as_path()),
329    };
330    command.current_dir(cwd_path);
331    command.envs(ctx.envs());
332    if let Some(val) = ctx.envs().get("CARGO_TARGET_DIR") {
333        command.env("CARGO_TARGET_DIR", val);
334    } else {
335        command.env(
336            "CARGO_TARGET_DIR",
337            oxdock_fs::command_path(ctx.cargo_target_dir()).into_owned(),
338        );
339    }
340}
341
342#[derive(Debug)]
343#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
344pub struct ChildHandle {
345    child: Child,
346    io_threads: Vec<std::thread::JoinHandle<()>>,
347}
348
349impl BackgroundHandle for ChildHandle {
350    fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
351        Ok(self.child.try_wait()?)
352    }
353
354    fn wait(&mut self) -> Result<ExitStatus> {
355        let status = self.child.wait()?;
356        // Wait for IO threads to finish to ensure all output is captured
357        for thread in self.io_threads.drain(..) {
358            let _ = thread.join();
359        }
360        Ok(status)
361    }
362
363    fn kill(&mut self) -> Result<()> {
364        if self.child.try_wait()?.is_none() {
365            let _ = self.child.kill();
366        }
367        Ok(())
368    }
369}
370
371// Synthetic process manager for Miri. Commands are interpreted with a tiny
372// shell that supports the patterns exercised in tests: sleep, printf/echo with
373// env interpolation, redirection, and exit codes. IO is routed through the
374// workspace filesystem so we never touch the host.
375#[cfg(miri)]
376#[derive(Clone, Default)]
377pub struct SyntheticProcessManager;
378
379#[cfg(miri)]
380#[derive(Clone)]
381pub struct SyntheticBgHandle {
382    ctx: CommandContext,
383    actions: Vec<Action>,
384    remaining: std::time::Duration,
385    last_polled: std::time::Instant,
386    status: ExitStatus,
387    applied: bool,
388    killed: bool,
389}
390
391#[cfg(miri)]
392#[derive(Clone)]
393enum Action {
394    WriteFile { target: GuardedPath, data: Vec<u8> },
395}
396
397#[cfg(miri)]
398impl BackgroundHandle for SyntheticBgHandle {
399    fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
400        if self.killed {
401            self.applied = true;
402            return Ok(Some(self.status));
403        }
404        if self.applied {
405            return Ok(Some(self.status));
406        }
407        let now = std::time::Instant::now();
408        let elapsed = now.saturating_duration_since(self.last_polled);
409        const MAX_ADVANCE: std::time::Duration = std::time::Duration::from_millis(15);
410        let advance = elapsed.min(MAX_ADVANCE).min(self.remaining);
411        self.remaining = self.remaining.saturating_sub(advance);
412        self.last_polled = now;
413
414        if self.remaining.is_zero() {
415            apply_actions(&self.ctx, &self.actions)?;
416            self.applied = true;
417            Ok(Some(self.status))
418        } else {
419            Ok(None)
420        }
421    }
422
423    fn kill(&mut self) -> Result<()> {
424        self.killed = true;
425        Ok(())
426    }
427
428    fn wait(&mut self) -> Result<ExitStatus> {
429        if self.killed {
430            self.applied = true;
431            return Ok(self.status);
432        }
433        if !self.applied {
434            if !self.remaining.is_zero() {
435                std::thread::sleep(self.remaining);
436            }
437            apply_actions(&self.ctx, &self.actions)?;
438            self.applied = true;
439        }
440        Ok(self.status)
441    }
442}
443
444#[cfg(miri)]
445impl ProcessManager for SyntheticProcessManager {
446    type Handle = SyntheticBgHandle;
447
448    fn run_command(
449        &mut self,
450        ctx: &CommandContext,
451        script: &str,
452        options: CommandOptions,
453    ) -> Result<CommandResult<Self::Handle>> {
454        let CommandOptions {
455            mode,
456            stdin,
457            stdout,
458            stderr,
459        } = options;
460
461        if let Some(reader) = stdin
462            && let Ok(mut guard) = reader.lock()
463        {
464            let mut sink = std::io::sink();
465            let _ = std::io::copy(&mut *guard, &mut sink);
466        }
467
468        match mode {
469            CommandMode::Foreground => {
470                let needs_bytes = matches!(stdout, CommandStdout::Capture)
471                    || matches!(stdout, CommandStdout::Stream(_));
472                let (out, status) = execute_sync(ctx, script, needs_bytes)?;
473                if !status.success() {
474                    bail!("command {:?} failed with status {}", script, status);
475                }
476                if matches!(stderr, CommandStderr::Stream(_)) {
477                    // Synthetic manager does not produce stderr output; warn if requested.
478                    // We simply ignore the stream since no bytes are generated.
479                }
480                match stdout {
481                    CommandStdout::Inherit => Ok(CommandResult::Completed),
482                    CommandStdout::Stream(writer) => {
483                        if needs_bytes && let Ok(mut guard) = writer.lock() {
484                            let _ = std::io::Write::write_all(&mut *guard, &out);
485                            let _ = std::io::Write::flush(&mut *guard);
486                        }
487                        Ok(CommandResult::Completed)
488                    }
489                    CommandStdout::Capture => Ok(CommandResult::Captured(out)),
490                }
491            }
492            CommandMode::Background => match stdout {
493                CommandStdout::Capture => {
494                    bail!("cannot capture stdout for background command under miri")
495                }
496                CommandStdout::Stream(_) => {
497                    bail!("stdout streaming not supported for background command under miri")
498                }
499                CommandStdout::Inherit => {
500                    if matches!(stderr, CommandStderr::Stream(_)) {
501                        bail!("stderr streaming not supported for background command under miri");
502                    }
503                    let plan = plan_background(ctx, script)?;
504                    Ok(CommandResult::Background(plan))
505                }
506            },
507        }
508    }
509}
510
511#[cfg(miri)]
512fn execute_sync(
513    ctx: &CommandContext,
514    script: &str,
515    capture: bool,
516) -> Result<(Vec<u8>, ExitStatus)> {
517    let mut stdout = Vec::new();
518    let mut status = exit_status_from_code(0);
519    let resolver = PathResolver::new(
520        ctx.workspace_root().as_path(),
521        ctx.build_context().as_path(),
522    )?;
523
524    let script = normalize_shell(script);
525    for raw in script.split(';') {
526        let cmd = raw.trim();
527        if cmd.is_empty() {
528            continue;
529        }
530        let (action, sleep_dur, exit_code) = parse_command(cmd, ctx, &resolver, capture)?;
531        if sleep_dur > std::time::Duration::ZERO {
532            std::thread::sleep(sleep_dur);
533        }
534        if let Some(action) = action {
535            match action {
536                CommandAction::Write { target, data } => {
537                    if let Some(parent) = target.as_path().parent() {
538                        let parent_guard = GuardedPath::new(target.root(), parent)?;
539                        resolver.create_dir_all(&parent_guard)?;
540                    }
541                    resolver.write_file(&target, &data)?;
542                }
543                CommandAction::Stdout { data } => {
544                    stdout.extend_from_slice(&data);
545                }
546            }
547        }
548        if let Some(code) = exit_code {
549            status = exit_status_from_code(code);
550            break;
551        }
552    }
553
554    Ok((stdout, status))
555}
556
557#[cfg(miri)]
558fn plan_background(ctx: &CommandContext, script: &str) -> Result<SyntheticBgHandle> {
559    let resolver = PathResolver::new(
560        ctx.workspace_root().as_path(),
561        ctx.build_context().as_path(),
562    )?;
563    let mut actions: Vec<Action> = Vec::new();
564    let mut ready = std::time::Duration::ZERO;
565    let mut status = exit_status_from_code(0);
566
567    let script = normalize_shell(script);
568    for raw in script.split(';') {
569        let cmd = raw.trim();
570        if cmd.is_empty() {
571            continue;
572        }
573        let (action, sleep_dur, exit_code) = parse_command(cmd, ctx, &resolver, false)?;
574        ready += sleep_dur;
575        if let Some(CommandAction::Write { target, data }) = action {
576            actions.push(Action::WriteFile { target, data });
577        }
578        if let Some(code) = exit_code {
579            status = exit_status_from_code(code);
580            break;
581        }
582    }
583
584    let min_ready = std::time::Duration::from_millis(50);
585    ready = ready.max(min_ready);
586
587    let handle = SyntheticBgHandle {
588        ctx: ctx.clone(),
589        actions,
590        remaining: ready,
591        last_polled: std::time::Instant::now(),
592        status,
593        applied: false,
594        killed: false,
595    };
596    Ok(handle)
597}
598
599#[cfg(miri)]
600enum CommandAction {
601    Write { target: GuardedPath, data: Vec<u8> },
602    Stdout { data: Vec<u8> },
603}
604
605#[cfg(miri)]
606fn parse_command(
607    cmd: &str,
608    ctx: &CommandContext,
609    resolver: &PathResolver,
610    capture: bool,
611) -> Result<(Option<CommandAction>, std::time::Duration, Option<i32>)> {
612    let (core, redirect) = split_redirect(cmd);
613    let tokens: Vec<&str> = core.split_whitespace().collect();
614    if tokens.is_empty() {
615        return Ok((None, std::time::Duration::ZERO, None));
616    }
617
618    match tokens[0] {
619        "sleep" => {
620            let dur = tokens
621                .get(1)
622                .and_then(|s| s.parse::<f64>().ok())
623                .unwrap_or(0.0);
624            let duration = std::time::Duration::from_secs_f64(dur);
625            Ok((None, duration, None))
626        }
627        "exit" => {
628            let code = tokens
629                .get(1)
630                .and_then(|s| s.parse::<i32>().ok())
631                .unwrap_or(0);
632            Ok((None, std::time::Duration::ZERO, Some(code)))
633        }
634        "printf" => {
635            let body = extract_body(&core, "printf %s");
636            let expanded = expand_env(&body, ctx);
637            let data = expanded.into_bytes();
638            if let Some(path_str) = redirect {
639                let target = resolve_write(resolver, ctx, &path_str)?;
640                Ok((
641                    Some(CommandAction::Write { target, data }),
642                    std::time::Duration::ZERO,
643                    None,
644                ))
645            } else if capture {
646                Ok((
647                    Some(CommandAction::Stdout { data }),
648                    std::time::Duration::ZERO,
649                    None,
650                ))
651            } else {
652                Ok((None, std::time::Duration::ZERO, None))
653            }
654        }
655        "echo" => {
656            let body = core.strip_prefix("echo").unwrap_or("").trim();
657            let expanded = expand_env(body, ctx);
658            let mut data = expanded.into_bytes();
659            data.push(b'\n');
660            if let Some(path_str) = redirect {
661                let target = resolve_write(resolver, ctx, &path_str)?;
662                Ok((
663                    Some(CommandAction::Write { target, data }),
664                    std::time::Duration::ZERO,
665                    None,
666                ))
667            } else if capture {
668                Ok((
669                    Some(CommandAction::Stdout { data }),
670                    std::time::Duration::ZERO,
671                    None,
672                ))
673            } else {
674                Ok((None, std::time::Duration::ZERO, None))
675            }
676        }
677        _ => {
678            // Fallback: treat as no-op success so Miri tests can proceed.
679            Ok((None, std::time::Duration::ZERO, None))
680        }
681    }
682}
683
684#[cfg(miri)]
685fn resolve_write(resolver: &PathResolver, ctx: &CommandContext, path: &str) -> Result<GuardedPath> {
686    match ctx.cwd() {
687        PolicyPath::Guarded(p) => resolver.resolve_write(p, path),
688        PolicyPath::Unguarded(_) => bail!("unguarded writes not supported in Miri"),
689    }
690}
691
692#[cfg(miri)]
693fn split_redirect(cmd: &str) -> (String, Option<String>) {
694    if let Some(idx) = cmd.find('>') {
695        let (left, right) = cmd.split_at(idx);
696        let path = right.trim_start_matches('>').trim();
697        (left.trim().to_string(), Some(path.to_string()))
698    } else {
699        (cmd.trim().to_string(), None)
700    }
701}
702
703#[cfg(miri)]
704fn extract_body(cmd: &str, prefix: &str) -> String {
705    cmd.strip_prefix(prefix)
706        .unwrap_or(cmd)
707        .trim()
708        .trim_matches('"')
709        .to_string()
710}
711
712#[cfg(miri)]
713fn expand_env(input: &str, ctx: &CommandContext) -> String {
714    // First expand any double-brace names ({{ ... }}), then expand simple
715    // shell-style `$VAR` and `${VAR}` occurrences so the synthetic Miri
716    // process emulation behaves like a real shell with respect to env vars.
717    let first = expand_with_lookup(input, |name| Some(env_lookup(name, ctx)));
718
719    let mut out = String::with_capacity(first.len());
720    let mut chars = first.chars().peekable();
721    while let Some(c) = chars.next() {
722        if c == '$' {
723            match chars.peek() {
724                Some('$') => {
725                    // preserve literal $$
726                    out.push('$');
727                    chars.next();
728                }
729                Some('{') => {
730                    // ${VAR}
731                    chars.next(); // consume '{'
732                    let mut name = String::new();
733                    while let Some(&ch) = chars.peek() {
734                        chars.next();
735                        if ch == '}' {
736                            break;
737                        }
738                        name.push(ch);
739                    }
740                    let val = if name == "CARGO_TARGET_DIR" {
741                        ctx.cargo_target_dir().display().to_string()
742                    } else {
743                        ctx.envs()
744                            .get(&name)
745                            .cloned()
746                            .or_else(|| std::env::var(&name).ok())
747                            .unwrap_or_default()
748                    };
749                    out.push_str(&val);
750                }
751                Some(next) if next.is_alphanumeric() || *next == '_' => {
752                    // $VAR
753                    let mut name = String::new();
754                    while let Some(&ch) = chars.peek() {
755                        if ch.is_alphanumeric() || ch == '_' {
756                            name.push(ch);
757                            chars.next();
758                        } else {
759                            break;
760                        }
761                    }
762                    let val = if name == "CARGO_TARGET_DIR" {
763                        ctx.cargo_target_dir().display().to_string()
764                    } else {
765                        ctx.envs()
766                            .get(&name)
767                            .cloned()
768                            .or_else(|| std::env::var(&name).ok())
769                            .unwrap_or_default()
770                    };
771                    out.push_str(&val);
772                }
773                _ => {
774                    // Not a recognized var form; keep literal '$'
775                    out.push('$');
776                }
777            }
778        } else {
779            out.push(c);
780        }
781    }
782
783    out
784}
785
786#[cfg(miri)]
787fn env_lookup(name: &str, ctx: &CommandContext) -> String {
788    if name == "CARGO_TARGET_DIR" {
789        return ctx.cargo_target_dir().display().to_string();
790    }
791    ctx.envs()
792        .get(name)
793        .cloned()
794        .or_else(|| std::env::var(name).ok())
795        .unwrap_or_default()
796}
797
798#[cfg(miri)]
799fn normalize_shell(script: &str) -> String {
800    let trimmed = script.trim();
801    if let Some(rest) = trimmed.strip_prefix("sh -c ") {
802        return rest.trim_matches(&['"', '\''] as &[_]).to_string();
803    }
804    if let Some(rest) = trimmed.strip_prefix("cmd /C ") {
805        return rest.trim_matches(&['"', '\''] as &[_]).to_string();
806    }
807    trimmed.to_string()
808}
809
810#[cfg(miri)]
811fn apply_actions(ctx: &CommandContext, actions: &[Action]) -> Result<()> {
812    let resolver = PathResolver::new(
813        ctx.workspace_root().as_path(),
814        ctx.build_context().as_path(),
815    )?;
816    for action in actions {
817        match action {
818            Action::WriteFile { target, data } => {
819                if let Some(parent) = target.as_path().parent() {
820                    let parent_guard = GuardedPath::new(target.root(), parent)?;
821                    resolver.create_dir_all(&parent_guard)?;
822                }
823                resolver.write_file(target, data)?;
824            }
825        }
826    }
827    Ok(())
828}
829
830#[cfg(miri)]
831fn exit_status_from_code(code: i32) -> ExitStatus {
832    #[cfg(unix)]
833    {
834        use std::os::unix::process::ExitStatusExt;
835        ExitStatusExt::from_raw(code << 8)
836    }
837    #[cfg(windows)]
838    {
839        use std::os::windows::process::ExitStatusExt;
840        ExitStatusExt::from_raw(code as u32)
841    }
842}
843
844#[cfg(not(miri))]
845pub type DefaultProcessManager = ShellProcessManager;
846
847#[cfg(miri)]
848pub type DefaultProcessManager = SyntheticProcessManager;
849
850pub fn default_process_manager() -> DefaultProcessManager {
851    DefaultProcessManager::default()
852}
853
854#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
855fn spawn_child_with_streams(
856    cmd: &mut ProcessCommand,
857    stdin: Option<SharedInput>,
858    stdout: Option<SharedOutput>,
859    stderr: Option<SharedOutput>,
860) -> Result<ChildHandle> {
861    if stdin.is_some() {
862        cmd.stdin(Stdio::piped());
863    }
864    if stdout.is_some() {
865        cmd.stdout(Stdio::piped());
866    }
867    if stderr.is_some() {
868        cmd.stderr(Stdio::piped());
869    }
870
871    let mut child = cmd
872        .spawn()
873        .with_context(|| format!("failed to spawn {:?}", cmd))?;
874    let mut io_threads = Vec::new();
875
876    if let Some(stdin_stream) = stdin
877        && let Some(mut child_stdin) = child.stdin.take()
878    {
879        let thread = std::thread::spawn(move || {
880            if let Ok(mut guard) = stdin_stream.lock() {
881                let _ = std::io::copy(&mut *guard, &mut child_stdin);
882            }
883        });
884        io_threads.push(thread);
885    }
886
887    if let Some(stdout_stream) = stdout
888        && let Some(mut child_stdout) = child.stdout.take()
889    {
890        let stream_clone = stdout_stream.clone();
891        let thread = std::thread::spawn(move || {
892            let mut buf = [0u8; 1024];
893            loop {
894                match std::io::Read::read(&mut child_stdout, &mut buf) {
895                    Ok(0) => break,
896                    Ok(n) => {
897                        if let Ok(mut guard) = stream_clone.lock() {
898                            if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
899                                break;
900                            }
901                            let _ = std::io::Write::flush(&mut *guard);
902                        }
903                    }
904                    Err(_) => break,
905                }
906            }
907        });
908        io_threads.push(thread);
909    }
910
911    if let Some(stderr_stream) = stderr
912        && let Some(mut child_stderr) = child.stderr.take()
913    {
914        let stream_clone = stderr_stream.clone();
915        let thread = std::thread::spawn(move || {
916            let mut buf = [0u8; 1024];
917            loop {
918                match std::io::Read::read(&mut child_stderr, &mut buf) {
919                    Ok(0) => break,
920                    Ok(n) => {
921                        if let Ok(mut guard) = stream_clone.lock() {
922                            if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
923                                break;
924                            }
925                            let _ = std::io::Write::flush(&mut *guard);
926                        }
927                    }
928                    Err(_) => break,
929                }
930            }
931        });
932        io_threads.push(thread);
933    }
934
935    Ok(ChildHandle { child, io_threads })
936}
937
938/// Builder wrapper that centralizes direct usages of `std::process::Command`.
939#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
940pub struct CommandBuilder {
941    inner: ProcessCommand,
942    program: OsString,
943    args: Vec<OsString>,
944    cwd: Option<PathBuf>,
945}
946
947#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
948impl CommandBuilder {
949    pub fn new(program: impl AsRef<OsStr>) -> Self {
950        let prog = program.as_ref().to_os_string();
951        Self {
952            inner: ProcessCommand::new(&prog),
953            program: prog,
954            args: Vec::new(),
955            cwd: None,
956        }
957    }
958
959    pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
960        let val = arg.as_ref().to_os_string();
961        self.inner.arg(&val);
962        self.args.push(val);
963        self
964    }
965
966    pub fn args<S, I>(&mut self, args: I) -> &mut Self
967    where
968        S: AsRef<OsStr>,
969        I: IntoIterator<Item = S>,
970    {
971        for arg in args {
972            self.arg(arg);
973        }
974        self
975    }
976
977    pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
978        self.inner.env(key, value);
979        self
980    }
981
982    pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
983        self.inner.env_remove(key);
984        self
985    }
986
987    pub fn stdin_file(&mut self, file: File) -> &mut Self {
988        self.inner.stdin(Stdio::from(file));
989        self
990    }
991
992    pub fn current_dir(&mut self, dir: impl AsRef<Path>) -> &mut Self {
993        let path = dir.as_ref();
994        self.inner.current_dir(path);
995        self.cwd = Some(path.to_path_buf());
996        self
997    }
998
999    pub fn status(&mut self) -> Result<ExitStatus> {
1000        #[cfg(miri)]
1001        {
1002            let snap = self.snapshot();
1003            synthetic_status(&snap)
1004        }
1005
1006        #[cfg(not(miri))]
1007        {
1008            let desc = format!("{:?}", self.inner);
1009            let status = self
1010                .inner
1011                .status()
1012                .with_context(|| format!("failed to run {desc}"))?;
1013            Ok(status)
1014        }
1015    }
1016
1017    pub fn output(&mut self) -> Result<CommandOutput> {
1018        #[cfg(miri)]
1019        {
1020            let snap = self.snapshot();
1021            synthetic_output(&snap)
1022        }
1023
1024        #[cfg(not(miri))]
1025        {
1026            let desc = format!("{:?}", self.inner);
1027            let out = self
1028                .inner
1029                .output()
1030                .with_context(|| format!("failed to run {desc}"))?;
1031            Ok(CommandOutput::from(out))
1032        }
1033    }
1034
1035    pub fn spawn(&mut self) -> Result<ChildHandle> {
1036        #[cfg(miri)]
1037        {
1038            bail!("spawn is not supported under miri synthetic process backend")
1039        }
1040
1041        #[cfg(not(miri))]
1042        {
1043            let desc = format!("{:?}", self.inner);
1044            let child = self
1045                .inner
1046                .spawn()
1047                .with_context(|| format!("failed to spawn {desc}"))?;
1048            Ok(ChildHandle {
1049                child,
1050                io_threads: Vec::new(),
1051            })
1052        }
1053    }
1054
1055    /// Return a lightweight snapshot of the command configuration for testing.
1056    pub fn snapshot(&self) -> CommandSnapshot {
1057        CommandSnapshot {
1058            program: self.program.clone(),
1059            args: self.args.clone(),
1060            cwd: self.cwd.clone(),
1061        }
1062    }
1063}
1064
1065#[derive(Clone, Debug, PartialEq, Eq)]
1066#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
1067pub struct CommandSnapshot {
1068    pub program: OsString,
1069    pub args: Vec<OsString>,
1070    pub cwd: Option<PathBuf>,
1071}
1072
1073pub struct CommandOutput {
1074    pub status: ExitStatus,
1075    pub stdout: Vec<u8>,
1076    pub stderr: Vec<u8>,
1077}
1078
1079impl CommandOutput {
1080    pub fn success(&self) -> bool {
1081        self.status.success()
1082    }
1083}
1084
1085#[allow(clippy::disallowed_types)]
1086impl From<StdOutput> for CommandOutput {
1087    fn from(value: StdOutput) -> Self {
1088        Self {
1089            status: value.status,
1090            stdout: value.stdout,
1091            stderr: value.stderr,
1092        }
1093    }
1094}
1095
1096#[cfg(miri)]
1097fn synthetic_status(snapshot: &CommandSnapshot) -> Result<ExitStatus> {
1098    Ok(synthetic_output(snapshot)?.status)
1099}
1100
1101#[cfg(miri)]
1102fn synthetic_output(snapshot: &CommandSnapshot) -> Result<CommandOutput> {
1103    let program = snapshot.program.to_string_lossy().to_string();
1104    let args: Vec<String> = snapshot
1105        .args
1106        .iter()
1107        .map(|a| a.to_string_lossy().to_string())
1108        .collect();
1109
1110    if program == "git" {
1111        return simulate_git(&args);
1112    }
1113    if program == "cargo" {
1114        return simulate_cargo(&args);
1115    }
1116
1117    Ok(CommandOutput {
1118        status: exit_status_from_code(0),
1119        stdout: Vec::new(),
1120        stderr: Vec::new(),
1121    })
1122}
1123
1124#[cfg(miri)]
1125fn simulate_git(args: &[String]) -> Result<CommandOutput> {
1126    let mut iter = args.iter();
1127    if matches!(iter.next(), Some(arg) if arg == "-C") {
1128        let _ = iter.next();
1129    }
1130    let remaining: Vec<String> = iter.map(|s| s.to_string()).collect();
1131
1132    if remaining.len() >= 2 && remaining[0] == "rev-parse" && remaining[1] == "HEAD" {
1133        return Ok(CommandOutput {
1134            status: exit_status_from_code(0),
1135            stdout: b"HEAD\n".to_vec(),
1136            stderr: Vec::new(),
1137        });
1138    }
1139
1140    // Default success for init/add/commit and other read-only queries.
1141    Ok(CommandOutput {
1142        status: exit_status_from_code(0),
1143        stdout: Vec::new(),
1144        stderr: Vec::new(),
1145    })
1146}
1147
1148#[cfg(miri)]
1149fn simulate_cargo(args: &[String]) -> Result<CommandOutput> {
1150    // Heuristic: manifests containing "build_exit_fail" should fail to mimic fixture.
1151    let mut status = exit_status_from_code(0);
1152    if args.iter().any(|a| a.contains("build_exit_fail")) {
1153        status = exit_status_from_code(1);
1154    }
1155    Ok(CommandOutput {
1156        status,
1157        stdout: Vec::new(),
1158        stderr: Vec::new(),
1159    })
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165    use oxdock_sys_test_utils::TestEnvGuard;
1166    use std::collections::HashMap;
1167
1168    #[test]
1169    fn expand_script_env_prefers_script_values() {
1170        let mut script_envs = HashMap::new();
1171        script_envs.insert("FOO".into(), "from-script".into());
1172        script_envs.insert("ONLY".into(), "only".into());
1173        let _env_guard = TestEnvGuard::set("FOO", "from-env");
1174        let rendered = expand_script_env(
1175            "{{ env:FOO }}:{{ env:ONLY }}:{{ env:MISSING }}",
1176            &script_envs,
1177        );
1178        assert_eq!(rendered, "from-script:only:");
1179    }
1180
1181    #[test]
1182    fn expand_script_env_supports_colon_separator() {
1183        let mut script_envs = HashMap::new();
1184        script_envs.insert("FOO".into(), "val".into());
1185        let rendered = expand_script_env("{{ env:FOO }}", &script_envs);
1186        assert_eq!(rendered, "val");
1187    }
1188
1189    #[test]
1190    fn expand_command_env_handles_var_forms() {
1191        let temp = GuardedPath::tempdir().expect("tempdir");
1192        let guard = temp.as_guarded_path().clone();
1193        let cwd: PolicyPath = guard.clone().into();
1194        let mut envs = HashMap::new();
1195        envs.insert("FOO".into(), "bar".into());
1196        envs.insert("PCT".into(), "percent".into());
1197        envs.insert("CARGO_TARGET_DIR".into(), guard.display().to_string());
1198        envs.insert("HOST_ONLY".into(), "host".into());
1199
1200        let ctx = CommandContext::new(&cwd, &envs, &guard, &guard, &guard);
1201
1202        // Valid syntax: {{ env:VAR }}
1203        let rendered = expand_command_env(
1204            "{{ env:FOO }}-{{ env:PCT }}-{{ env:HOST_ONLY }}-{{ env:CARGO_TARGET_DIR }}",
1205            &ctx,
1206        );
1207        assert_eq!(rendered, format!("bar-percent-host-{}", guard.display()));
1208
1209        // Invalid/Legacy syntax: treated as literal text
1210        // %FOO% -> %FOO%
1211        // {CARGO_TARGET_DIR} -> {CARGO_TARGET_DIR}
1212        // $$ -> $$
1213        let input_literal = "%FOO%-{CARGO_TARGET_DIR}-$$";
1214        let rendered_literal = expand_command_env(input_literal, &ctx);
1215        assert_eq!(rendered_literal, input_literal);
1216    }
1217
1218    #[test]
1219    fn expand_command_env_does_not_fallback_to_host() {
1220        let temp = GuardedPath::tempdir().expect("tempdir");
1221        let guard = temp.as_guarded_path().clone();
1222        let cwd: PolicyPath = guard.clone().into();
1223        let envs = HashMap::new();
1224        let _env_guard = TestEnvGuard::set("HOST_ONLY", "host");
1225
1226        let ctx = CommandContext::new(&cwd, &envs, &guard, &guard, &guard);
1227        let rendered = expand_command_env("{{ env:HOST_ONLY }}", &ctx);
1228        assert_eq!(rendered, "");
1229    }
1230}