Skip to main content

oxdock_process/
shell_manager.rs

1use std::sync::{Arc, Mutex};
2
3use anyhow::{Context, Result, anyhow, bail};
4#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
5use std::process::Command as ProcessCommand;
6#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
7use std::process::Stdio;
8
9use oxdock_fs::PolicyPath;
10
11use crate::child::ChildHandle;
12use crate::contract::{
13    BackgroundHandle, CommandContext, CommandMode, CommandOptions, CommandResult, CommandStderr,
14    CommandStdin, CommandStdout, PROCESS_DEBUG_ENV_VAR, ProcessManager, SharedInput, SharedOutput,
15};
16use crate::shell::{direct_cmd, shell_cmd};
17
18/// Default process manager that shells out using the system shell.
19#[derive(Clone, Default)]
20#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
21pub struct ShellProcessManager;
22
23impl ProcessManager for ShellProcessManager {
24    type Handle = ChildHandle;
25
26    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
27    fn run_command(
28        &mut self,
29        ctx: &CommandContext,
30        script: &str,
31        options: CommandOptions,
32    ) -> Result<CommandResult<Self::Handle>> {
33        if std::env::var_os(PROCESS_DEBUG_ENV_VAR).is_some() {
34            eprintln!("oxbook run_command: {script}");
35        }
36        let mut command = shell_cmd(script);
37        apply_ctx(&mut command, ctx);
38        let CommandOptions {
39            mode,
40            stdin,
41            stdout,
42            stderr,
43        } = options;
44        run_prepared(&mut command, mode, stdin, stdout, stderr)
45    }
46
47    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
48    fn run_argv(
49        &mut self,
50        ctx: &CommandContext,
51        argv: &[String],
52        options: CommandOptions,
53    ) -> Result<CommandResult<Self::Handle>> {
54        if std::env::var_os(PROCESS_DEBUG_ENV_VAR).is_some() {
55            eprintln!("oxbook run_argv: {argv:?}");
56        }
57        let mut command = direct_cmd(argv)?;
58        apply_ctx(&mut command, ctx);
59        let CommandOptions {
60            mode,
61            stdin,
62            stdout,
63            stderr,
64        } = options;
65        run_prepared(&mut command, mode, stdin, stdout, stderr)
66    }
67}
68
69/// Shared foreground/background runner for an already-configured
70/// `std::process::Command`: stdio mapping, null-stdin isolation,
71/// capture semantics, and non-zero failures behave identically for
72/// shell (`run_command`) and direct-spawn (`run_argv`) paths.
73#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
74fn run_prepared(
75    command: &mut ProcessCommand,
76    mode: CommandMode,
77    stdin: CommandStdin,
78    stdout: CommandStdout,
79    stderr: CommandStderr,
80) -> Result<CommandResult<ChildHandle>> {
81    let (stdout_stream, capture_buf) = match stdout {
82        CommandStdout::Inherit => (None, None),
83        CommandStdout::Stream(stream) => (Some(stream), None),
84        CommandStdout::Capture => {
85            if matches!(mode, CommandMode::Background) {
86                bail!("cannot capture stdout for background command");
87            }
88            let buf = Arc::new(Mutex::new(Vec::new()));
89            let writer: SharedOutput = buf.clone();
90            (Some(writer), Some(buf))
91        }
92        #[cfg(not(miri))]
93        CommandStdout::OsPipe(writer) => {
94            // Move the FD into the child. `take` empties the parent slot,
95            // and `options` drops at the end of this function, so no parent
96            // copy survives to starve the reader of EOF.
97            let owned = writer.take()?;
98            command.stdout(Stdio::from(owned));
99            (None, None)
100        }
101    };
102
103    let stderr_stream = match stderr {
104        CommandStderr::Inherit => None,
105        CommandStderr::Stream(stream) => Some(stream),
106        #[cfg(not(miri))]
107        CommandStderr::OsPipe(writer) => {
108            // Same move and drop discipline as stdout above.
109            let owned = writer.take()?;
110            command.stderr(Stdio::from(owned));
111            None
112        }
113    };
114
115    let stdin_stream: Option<SharedInput> = match stdin {
116        // Do not inherit stdin by default; ensure isolation unless requested.
117        CommandStdin::Null => {
118            command.stdin(Stdio::null());
119            None
120        }
121        CommandStdin::Inherit => None,
122        CommandStdin::Stream(reader) => Some(reader),
123        #[cfg(not(miri))]
124        CommandStdin::OsPipe(reader) => {
125            // Same move and drop discipline as stdout above.
126            let owned = reader.take()?;
127            command.stdin(Stdio::from(owned));
128            None
129        }
130    };
131    let desc = format!("{:?}", command);
132
133    match mode {
134        CommandMode::Foreground => {
135            let mut handle =
136                spawn_child_with_streams(command, stdin_stream, stdout_stream, stderr_stream)?;
137            let status = handle
138                .wait()
139                .with_context(|| format!("failed to run {desc}"))?;
140            if !status.success() {
141                bail!("command {desc} failed with status {}", status);
142            }
143            if let Some(buf) = capture_buf {
144                let mut guard = buf.lock().map_err(|_| anyhow!("capture stdout poisoned"))?;
145                return Ok(CommandResult::Captured(std::mem::take(&mut *guard)));
146            }
147            Ok(CommandResult::Completed)
148        }
149        CommandMode::Background => {
150            let handle =
151                spawn_child_with_streams(command, stdin_stream, stdout_stream, stderr_stream)?;
152            Ok(CommandResult::Background(handle))
153        }
154    }
155}
156
157#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
158fn apply_ctx(command: &mut ProcessCommand, ctx: &CommandContext) {
159    // Use command_path to strip Windows verbatim prefixes (\\?\) before passing to Command.
160    // While Rust's `std::process::Command` handles verbatim paths in current_dir correctly,
161    // environment variables are passed as-is. If we pass a verbatim path in `CARGO_TARGET_DIR`,
162    // tools that don't understand it (or shell scripts echoing it) might misbehave or produce
163    // unexpected output. Normalizing here ensures consistency.
164    //
165    // Why the `\\?\` verbatim prefixes?
166    // On Windows we intentionally keep the canonical verbatim path (e.g. `\\?\C:\\repo`)
167    // inside every `GuardedPath`. This avoids MAX_PATH truncation and prevents subtle
168    // `PathBuf` casing/drive-letter surprises when the guard is later joined, copied,
169    // or passed through `std::fs`. When you need a human-readable path, call
170    // [`command_path`] (native separators, prefix stripped) or [`normalized_path`]
171    // (forward slashes) or use the `Display` impl, which already defers to
172    // `command_path`. Keep the debug view raw so diagnostics can show the exact path
173    // we are guarding.
174    let cwd_path: std::borrow::Cow<std::path::Path> = match ctx.cwd() {
175        PolicyPath::Guarded(p) => oxdock_fs::command_path(p),
176        PolicyPath::Unguarded(p) => std::borrow::Cow::Borrowed(p.as_path()),
177    };
178    command.current_dir(cwd_path);
179    command.envs(ctx.envs().as_ref());
180    if let Some(val) = ctx.envs().get("CARGO_TARGET_DIR") {
181        command.env("CARGO_TARGET_DIR", val);
182    } else {
183        command.env(
184            "CARGO_TARGET_DIR",
185            oxdock_fs::command_path(ctx.cargo_target_dir()).into_owned(),
186        );
187    }
188}
189
190#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
191fn spawn_child_with_streams(
192    cmd: &mut ProcessCommand,
193    stdin: Option<SharedInput>,
194    stdout: Option<SharedOutput>,
195    stderr: Option<SharedOutput>,
196) -> Result<ChildHandle> {
197    if stdin.is_some() {
198        cmd.stdin(Stdio::piped());
199    }
200    if stdout.is_some() {
201        cmd.stdout(Stdio::piped());
202    }
203    if stderr.is_some() {
204        cmd.stderr(Stdio::piped());
205    }
206
207    let mut child = cmd
208        .spawn()
209        .with_context(|| format!("failed to spawn {:?}", cmd))?;
210    let mut io_threads = Vec::new();
211
212    if let Some(stdin_stream) = stdin
213        && let Some(mut child_stdin) = child.stdin.take()
214    {
215        let thread = std::thread::spawn(move || {
216            if let Ok(mut guard) = stdin_stream.lock() {
217                let _ = std::io::copy(&mut *guard, &mut child_stdin);
218            }
219        });
220        io_threads.push(thread);
221    }
222
223    if let Some(stdout_stream) = stdout
224        && let Some(mut child_stdout) = child.stdout.take()
225    {
226        let stream_clone = stdout_stream.clone();
227        let thread = std::thread::spawn(move || {
228            let mut buf = [0u8; 1024];
229            loop {
230                match std::io::Read::read(&mut child_stdout, &mut buf) {
231                    Ok(0) => break,
232                    Ok(n) => {
233                        if let Ok(mut guard) = stream_clone.lock() {
234                            if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
235                                break;
236                            }
237                            let _ = std::io::Write::flush(&mut *guard);
238                        }
239                    }
240                    Err(_) => break,
241                }
242            }
243        });
244        io_threads.push(thread);
245    }
246
247    if let Some(stderr_stream) = stderr
248        && let Some(mut child_stderr) = child.stderr.take()
249    {
250        let stream_clone = stderr_stream.clone();
251        let thread = std::thread::spawn(move || {
252            let mut buf = [0u8; 1024];
253            loop {
254                match std::io::Read::read(&mut child_stderr, &mut buf) {
255                    Ok(0) => break,
256                    Ok(n) => {
257                        if let Ok(mut guard) = stream_clone.lock() {
258                            if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
259                                break;
260                            }
261                            let _ = std::io::Write::flush(&mut *guard);
262                        }
263                    }
264                    Err(_) => break,
265                }
266            }
267        });
268        io_threads.push(thread);
269    }
270
271    Ok(ChildHandle::new(child, io_threads))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use oxdock_fs::GuardedPath;
278    use std::collections::HashMap;
279
280    fn make_ctx(envs: &[(&str, &str)]) -> (oxdock_fs::GuardedTempDir, CommandContext) {
281        let temp = GuardedPath::tempdir().expect("tempdir");
282        let guard = temp.as_guarded_path().clone();
283        let cwd: PolicyPath = guard.clone().into();
284        let map: HashMap<String, String> = envs
285            .iter()
286            .map(|(key, value)| (key.to_string(), value.to_string()))
287            .collect();
288        let ctx = CommandContext::from_map(&cwd, &map, &guard, &guard, &guard);
289        (temp, ctx)
290    }
291
292    #[test]
293    fn background_capture_stdout_bails_without_spawning() {
294        let (_temp, ctx) = make_ctx(&[]);
295        let mut pm = ShellProcessManager;
296        let options = CommandOptions {
297            mode: CommandMode::Background,
298            stdout: CommandStdout::Capture,
299            ..Default::default()
300        };
301        let err = match pm.run_command(&ctx, "echo hi", options) {
302            Err(err) => err,
303            Ok(_) => panic!("background capture must bail"),
304        };
305        assert!(
306            err.to_string().contains("cannot capture stdout"),
307            "unexpected error: {err}"
308        );
309    }
310
311    #[cfg_attr(
312        miri,
313        ignore = "spawns processes; Miri does not support process execution"
314    )]
315    #[test]
316    fn foreground_capture_returns_child_stdout_bytes() {
317        let (_temp, ctx) = make_ctx(&[]);
318        let mut pm = ShellProcessManager;
319        let options = CommandOptions {
320            stdout: CommandStdout::Capture,
321            ..Default::default()
322        };
323        match pm
324            .run_command(&ctx, "echo hello-capture", options)
325            .expect("run")
326        {
327            CommandResult::Captured(bytes) => {
328                let out = String::from_utf8_lossy(&bytes);
329                assert!(out.contains("hello-capture"), "captured: {out}");
330            }
331            CommandResult::Completed => panic!("expected Captured, got Completed"),
332            CommandResult::Background(_) => panic!("expected Captured, got Background"),
333        }
334    }
335
336    #[cfg_attr(
337        miri,
338        ignore = "spawns processes; Miri does not support process execution"
339    )]
340    #[test]
341    fn run_argv_spawns_directly_without_shell() {
342        let (_temp, ctx) = make_ctx(&[]);
343        let mut pm = ShellProcessManager;
344        let options = CommandOptions {
345            stdout: CommandStdout::Capture,
346            ..Default::default()
347        };
348        // `cargo` drives the test suite itself, so it is present on every
349        // platform without relying on shell builtins (`echo` is not a
350        // Windows executable).
351        let argv = vec!["cargo".to_string(), "--version".to_string()];
352        match pm.run_argv(&ctx, &argv, options).expect("run_argv") {
353            CommandResult::Captured(bytes) => {
354                let out = String::from_utf8_lossy(&bytes);
355                assert!(out.contains("cargo"), "captured: {out}");
356            }
357            CommandResult::Completed => panic!("expected Captured, got Completed"),
358            CommandResult::Background(_) => panic!("expected Captured, got Background"),
359        }
360    }
361
362    #[test]
363    fn run_argv_rejects_empty_argv_without_spawning() {
364        let (_temp, ctx) = make_ctx(&[]);
365        let mut pm = ShellProcessManager;
366        let err = match pm.run_argv(&ctx, &[], CommandOptions::foreground()) {
367            Err(err) => err,
368            Ok(_) => panic!("empty argv must bail"),
369        };
370        assert!(
371            err.to_string().contains("at least one argument"),
372            "unexpected error: {err}"
373        );
374    }
375
376    fn large_output_script() -> &'static str {
377        #[cfg(windows)]
378        {
379            "for /l %i in (1,1,20000) do @echo 0123456789abcdef"
380        }
381        #[cfg(not(windows))]
382        {
383            "i=0; while [ $i -lt 20000 ]; do echo 0123456789abcdef; i=$((i+1)); done"
384        }
385    }
386
387    #[cfg_attr(
388        miri,
389        ignore = "spawns processes; Miri does not support process execution"
390    )]
391    #[test]
392    fn streams_large_stdout_through_shared_output_without_deadlock() {
393        let (_temp, ctx) = make_ctx(&[]);
394        let mut pm = ShellProcessManager;
395        let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
396        let options = CommandOptions {
397            stdout: CommandStdout::Stream(buffer.clone()),
398            ..Default::default()
399        };
400        pm.run_command(&ctx, large_output_script(), options)
401            .expect("run");
402        let bytes = buffer.lock().expect("buffer lock").len();
403        // 20000 lines x 17 bytes ~= 340KB, far beyond any OS pipe buffer, so
404        // the reader pump must have iterated continuously.
405        assert!(bytes >= 300_000, "streamed only {bytes} bytes");
406    }
407
408    #[cfg_attr(
409        miri,
410        ignore = "spawns processes; Miri does not support process execution"
411    )]
412    #[test]
413    fn foreground_stdin_is_piped_through_copy_thread() {
414        let (_temp, ctx) = make_ctx(&[]);
415        let mut pm = ShellProcessManager;
416        // Windows `sort` requires `\r\n` line endings to recognize lines.
417        #[cfg(windows)]
418        let input: &[u8] = b"b\r\na\r\n";
419        #[cfg(not(windows))]
420        let input: &[u8] = b"b\na\n";
421        let payload: SharedInput =
422            std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(input.to_vec())));
423        let options = CommandOptions {
424            stdin: CommandStdin::Stream(payload),
425            stdout: CommandStdout::Capture,
426            ..Default::default()
427        };
428        // `sort` reads stdin to EOF on both Unix and Windows before printing.
429        match pm.run_command(&ctx, "sort", options).expect("run") {
430            CommandResult::Captured(bytes) => {
431                assert!(bytes.starts_with(b"a"), "sorted output: {:?}", bytes);
432                assert!(windows_compatible_contains(&bytes, b"b"));
433            }
434            CommandResult::Completed => panic!("expected Captured, got Completed"),
435            CommandResult::Background(_) => panic!("expected Captured, got Background"),
436        }
437    }
438
439    fn windows_compatible_contains(haystack: &[u8], needle: &[u8]) -> bool {
440        haystack.windows(needle.len()).any(|w| w == needle)
441    }
442
443    /// Concurrent producer and consumer wired through one kernel pipe. The
444    /// consumer (`sort`) only returns after EOF, which proves the parent
445    /// retained no writer copy after spawning the producer.
446    #[cfg(not(miri))]
447    #[test]
448    fn os_pipe_streams_producer_to_consumer_with_eof() {
449        use crate::contract::{BackgroundHandle, create_os_pipe};
450
451        let (_temp, ctx) = make_ctx(&[]);
452        let (reader, writer) = create_os_pipe().expect("os pipe");
453        let mut pm = ShellProcessManager;
454
455        let producer = match pm
456            .run_command(
457                &ctx,
458                "echo hello-os-pipe",
459                CommandOptions {
460                    mode: CommandMode::Background,
461                    stdout: CommandStdout::OsPipe(writer),
462                    ..Default::default()
463                },
464            )
465            .expect("spawn producer")
466        {
467            CommandResult::Background(handle) => handle,
468            _ => panic!("expected background producer handle"),
469        };
470
471        let options = CommandOptions {
472            stdin: CommandStdin::OsPipe(reader),
473            stdout: CommandStdout::Capture,
474            ..Default::default()
475        };
476        // `sort` reads stdin to EOF on every platform before printing.
477        let captured = match pm.run_command(&ctx, "sort", options).expect("run consumer") {
478            CommandResult::Captured(bytes) => bytes,
479            _ => panic!("expected captured consumer output"),
480        };
481        assert!(
482            windows_compatible_contains(&captured, b"hello-os-pipe"),
483            "piped output: {:?}",
484            String::from_utf8_lossy(&captured)
485        );
486
487        let mut producer = producer;
488        let status = producer.wait().expect("wait producer");
489        assert!(status.success(), "producer failed: {status:?}");
490
491        // A spent handle cannot be reused for a second spawn.
492        let (spent_reader, spent_writer) = create_os_pipe().expect("os pipe");
493        spent_reader.take().expect("first reader take");
494        assert!(
495            spent_reader.take().is_err(),
496            "reader take must be single use"
497        );
498        spent_writer.take().expect("first writer take");
499        assert!(
500            spent_writer.take().is_err(),
501            "writer take must be single use"
502        );
503    }
504
505    #[test]
506    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
507    fn apply_ctx_sets_cwd_and_cargo_target_dir_precedence() {
508        // Default branch: executor-provided cargo target dir wins when the
509        // env map has no explicit override.
510        let (temp_a, ctx_a) = make_ctx(&[]);
511        let expected_default = oxdock_fs::command_path(ctx_a.cargo_target_dir())
512            .to_string_lossy()
513            .into_owned();
514        let mut cmd = ProcessCommand::new("prog");
515        apply_ctx(&mut cmd, &ctx_a);
516        let envs_a: HashMap<String, String> = cmd
517            .get_envs()
518            .map(|(k, v)| {
519                (
520                    k.to_string_lossy().into_owned(),
521                    v.map(|value| value.to_string_lossy().into_owned())
522                        .unwrap_or_default(),
523                )
524            })
525            .collect();
526        assert_eq!(envs_a.get("CARGO_TARGET_DIR"), Some(&expected_default));
527        drop(temp_a);
528
529        // Override branch: an explicit CARGO_TARGET_DIR env mapping wins over
530        // the executor default.
531        let (temp_b, ctx_b) = make_ctx(&[("CARGO_TARGET_DIR", "custom-target"), ("FOO", "bar")]);
532        let mut cmd = ProcessCommand::new("prog");
533        apply_ctx(&mut cmd, &ctx_b);
534        let envs_b: HashMap<String, String> = cmd
535            .get_envs()
536            .map(|(k, v)| {
537                (
538                    k.to_string_lossy().into_owned(),
539                    v.map(|value| value.to_string_lossy().into_owned())
540                        .unwrap_or_default(),
541                )
542            })
543            .collect();
544        assert_eq!(
545            envs_b.get("CARGO_TARGET_DIR").map(String::as_str),
546            Some("custom-target")
547        );
548        assert_eq!(envs_b.get("FOO").map(String::as_str), Some("bar"));
549        drop(temp_b);
550    }
551
552    #[test]
553    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
554    fn apply_ctx_sets_working_directory_from_guarded_cwd() {
555        let (_temp, ctx) = make_ctx(&[]);
556        let mut cmd = ProcessCommand::new("prog");
557        apply_ctx(&mut cmd, &ctx);
558        let expected = oxdock_fs::command_path(match ctx.cwd() {
559            PolicyPath::Guarded(guarded) => guarded,
560            PolicyPath::Unguarded(_) => panic!("expected guarded cwd"),
561        });
562        assert_eq!(cmd.get_current_dir(), Some(expected.as_ref()));
563    }
564}