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            ctx.cargo_target_dir().command_path().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    let mut stdin_thread = None;
212
213    if let Some(stdin_stream) = stdin
214        && let Some(mut child_stdin) = child.stdin.take()
215    {
216        let thread = std::thread::spawn(move || {
217            if let Ok(mut guard) = stdin_stream.lock() {
218                let _ = std::io::copy(&mut *guard, &mut child_stdin);
219            }
220        });
221        stdin_thread = Some(thread);
222    }
223
224    if let Some(stdout_stream) = stdout
225        && let Some(mut child_stdout) = child.stdout.take()
226    {
227        let stream_clone = stdout_stream.clone();
228        let thread = std::thread::spawn(move || {
229            let mut buf = [0u8; 1024];
230            loop {
231                match std::io::Read::read(&mut child_stdout, &mut buf) {
232                    Ok(0) => break,
233                    Ok(n) => {
234                        if let Ok(mut guard) = stream_clone.lock() {
235                            if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
236                                break;
237                            }
238                            let _ = std::io::Write::flush(&mut *guard);
239                        }
240                    }
241                    Err(_) => break,
242                }
243            }
244        });
245        io_threads.push(thread);
246    }
247
248    if let Some(stderr_stream) = stderr
249        && let Some(mut child_stderr) = child.stderr.take()
250    {
251        let stream_clone = stderr_stream.clone();
252        let thread = std::thread::spawn(move || {
253            let mut buf = [0u8; 1024];
254            loop {
255                match std::io::Read::read(&mut child_stderr, &mut buf) {
256                    Ok(0) => break,
257                    Ok(n) => {
258                        if let Ok(mut guard) = stream_clone.lock() {
259                            if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
260                                break;
261                            }
262                            let _ = std::io::Write::flush(&mut *guard);
263                        }
264                    }
265                    Err(_) => break,
266                }
267            }
268        });
269        io_threads.push(thread);
270    }
271
272    Ok(ChildHandle::new(child, stdin_thread, io_threads))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use oxdock_fs::GuardedPath;
279    use std::collections::HashMap;
280
281    fn make_ctx(envs: &[(&str, &str)]) -> (oxdock_fs::GuardedTempDir, CommandContext) {
282        let temp = GuardedPath::tempdir().expect("tempdir");
283        let guard = temp.as_guarded_path().clone();
284        let cwd: PolicyPath = guard.clone().into();
285        let map: HashMap<String, String> = envs
286            .iter()
287            .map(|(key, value)| (key.to_string(), value.to_string()))
288            .collect();
289        let scratch = oxdock_fs::reserve_cargo_scratch().expect("scratch");
290        let ctx = CommandContext::from_map(&cwd, &map, &scratch, &guard, &guard);
291        (temp, ctx)
292    }
293
294    #[test]
295    fn background_capture_stdout_bails_without_spawning() {
296        let (_temp, ctx) = make_ctx(&[]);
297        let mut pm = ShellProcessManager;
298        let options = CommandOptions {
299            mode: CommandMode::Background,
300            stdout: CommandStdout::Capture,
301            ..Default::default()
302        };
303        let err = match pm.run_command(&ctx, "echo hi", options) {
304            Err(err) => err,
305            Ok(_) => panic!("background capture must bail"),
306        };
307        assert!(
308            err.to_string().contains("cannot capture stdout"),
309            "unexpected error: {err}"
310        );
311    }
312
313    #[cfg_attr(
314        miri,
315        ignore = "spawns processes; Miri does not support process execution"
316    )]
317    #[test]
318    fn foreground_capture_returns_child_stdout_bytes() {
319        let (_temp, ctx) = make_ctx(&[]);
320        let mut pm = ShellProcessManager;
321        let options = CommandOptions {
322            stdout: CommandStdout::Capture,
323            ..Default::default()
324        };
325        match pm
326            .run_command(&ctx, "echo hello-capture", options)
327            .expect("run")
328        {
329            CommandResult::Captured(bytes) => {
330                let out = String::from_utf8_lossy(&bytes);
331                assert!(out.contains("hello-capture"), "captured: {out}");
332            }
333            CommandResult::Completed => panic!("expected Captured, got Completed"),
334            CommandResult::Background(_) => panic!("expected Captured, got Background"),
335        }
336    }
337
338    #[cfg_attr(
339        miri,
340        ignore = "spawns processes; Miri does not support process execution"
341    )]
342    #[test]
343    fn run_argv_spawns_directly_without_shell() {
344        let (_temp, ctx) = make_ctx(&[]);
345        let mut pm = ShellProcessManager;
346        let options = CommandOptions {
347            stdout: CommandStdout::Capture,
348            ..Default::default()
349        };
350        // `cargo` drives the test suite itself, so it is present on every
351        // platform without relying on shell builtins (`echo` is not a
352        // Windows executable).
353        let argv = vec!["cargo".to_string(), "--version".to_string()];
354        match pm.run_argv(&ctx, &argv, options).expect("run_argv") {
355            CommandResult::Captured(bytes) => {
356                let out = String::from_utf8_lossy(&bytes);
357                assert!(out.contains("cargo"), "captured: {out}");
358            }
359            CommandResult::Completed => panic!("expected Captured, got Completed"),
360            CommandResult::Background(_) => panic!("expected Captured, got Background"),
361        }
362    }
363
364    #[test]
365    fn run_argv_rejects_empty_argv_without_spawning() {
366        let (_temp, ctx) = make_ctx(&[]);
367        let mut pm = ShellProcessManager;
368        let err = match pm.run_argv(&ctx, &[], CommandOptions::foreground()) {
369            Err(err) => err,
370            Ok(_) => panic!("empty argv must bail"),
371        };
372        assert!(
373            err.to_string().contains("at least one argument"),
374            "unexpected error: {err}"
375        );
376    }
377
378    fn large_output_script() -> &'static str {
379        #[cfg(windows)]
380        {
381            "for /l %i in (1,1,20000) do @echo 0123456789abcdef"
382        }
383        #[cfg(not(windows))]
384        {
385            "i=0; while [ $i -lt 20000 ]; do echo 0123456789abcdef; i=$((i+1)); done"
386        }
387    }
388
389    #[cfg_attr(
390        miri,
391        ignore = "spawns processes; Miri does not support process execution"
392    )]
393    #[test]
394    fn streams_large_stdout_through_shared_output_without_deadlock() {
395        let (_temp, ctx) = make_ctx(&[]);
396        let mut pm = ShellProcessManager;
397        let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
398        let options = CommandOptions {
399            stdout: CommandStdout::Stream(buffer.clone()),
400            ..Default::default()
401        };
402        pm.run_command(&ctx, large_output_script(), options)
403            .expect("run");
404        let bytes = buffer.lock().expect("buffer lock").len();
405        // 20000 lines x 17 bytes ~= 340KB, far beyond any OS pipe buffer, so
406        // the reader pump must have iterated continuously.
407        assert!(bytes >= 300_000, "streamed only {bytes} bytes");
408    }
409
410    #[cfg_attr(
411        miri,
412        ignore = "spawns processes; Miri does not support process execution"
413    )]
414    #[test]
415    fn foreground_stdin_is_piped_through_copy_thread() {
416        let (_temp, ctx) = make_ctx(&[]);
417        let mut pm = ShellProcessManager;
418        // Windows `sort` requires `\r\n` line endings to recognize lines.
419        #[cfg(windows)]
420        let input: &[u8] = b"b\r\na\r\n";
421        #[cfg(not(windows))]
422        let input: &[u8] = b"b\na\n";
423        let payload: SharedInput =
424            std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(input.to_vec())));
425        let options = CommandOptions {
426            stdin: CommandStdin::Stream(payload),
427            stdout: CommandStdout::Capture,
428            ..Default::default()
429        };
430        // `sort` reads stdin to EOF on both Unix and Windows before printing.
431        match pm.run_command(&ctx, "sort", options).expect("run") {
432            CommandResult::Captured(bytes) => {
433                assert!(bytes.starts_with(b"a"), "sorted output: {:?}", bytes);
434                assert!(windows_compatible_contains(&bytes, b"b"));
435            }
436            CommandResult::Completed => panic!("expected Captured, got Completed"),
437            CommandResult::Background(_) => panic!("expected Captured, got Background"),
438        }
439    }
440
441    fn windows_compatible_contains(haystack: &[u8], needle: &[u8]) -> bool {
442        haystack.windows(needle.len()).any(|w| w == needle)
443    }
444
445    /// Concurrent producer and consumer wired through one kernel pipe. The
446    /// consumer (`sort`) only returns after EOF, which proves the parent
447    /// retained no writer copy after spawning the producer.
448    #[cfg(not(miri))]
449    #[test]
450    fn os_pipe_streams_producer_to_consumer_with_eof() {
451        use crate::contract::{BackgroundHandle, create_os_pipe};
452
453        let (_temp, ctx) = make_ctx(&[]);
454        let (reader, writer) = create_os_pipe().expect("os pipe");
455        let mut pm = ShellProcessManager;
456
457        let producer = match pm
458            .run_command(
459                &ctx,
460                "echo hello-os-pipe",
461                CommandOptions {
462                    mode: CommandMode::Background,
463                    stdout: CommandStdout::OsPipe(writer),
464                    ..Default::default()
465                },
466            )
467            .expect("spawn producer")
468        {
469            CommandResult::Background(handle) => handle,
470            _ => panic!("expected background producer handle"),
471        };
472
473        let options = CommandOptions {
474            stdin: CommandStdin::OsPipe(reader),
475            stdout: CommandStdout::Capture,
476            ..Default::default()
477        };
478        // `sort` reads stdin to EOF on every platform before printing.
479        let captured = match pm.run_command(&ctx, "sort", options).expect("run consumer") {
480            CommandResult::Captured(bytes) => bytes,
481            _ => panic!("expected captured consumer output"),
482        };
483        assert!(
484            windows_compatible_contains(&captured, b"hello-os-pipe"),
485            "piped output: {:?}",
486            String::from_utf8_lossy(&captured)
487        );
488
489        let mut producer = producer;
490        let status = producer.wait().expect("wait producer");
491        assert!(status.success(), "producer failed: {status:?}");
492
493        // A spent handle cannot be reused for a second spawn.
494        let (spent_reader, spent_writer) = create_os_pipe().expect("os pipe");
495        spent_reader.take().expect("first reader take");
496        assert!(
497            spent_reader.take().is_err(),
498            "reader take must be single use"
499        );
500        spent_writer.take().expect("first writer take");
501        assert!(
502            spent_writer.take().is_err(),
503            "writer take must be single use"
504        );
505    }
506
507    #[test]
508    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
509    fn apply_ctx_sets_cwd_and_cargo_target_dir_precedence() {
510        // Default branch: executor-provided cargo target dir wins when the
511        // env map has no explicit override.
512        let (temp_a, ctx_a) = make_ctx(&[]);
513        let expected_default = ctx_a
514            .cargo_target_dir()
515            .command_path()
516            .to_string_lossy()
517            .into_owned();
518        let mut cmd = ProcessCommand::new("prog");
519        apply_ctx(&mut cmd, &ctx_a);
520        let envs_a: HashMap<String, String> = cmd
521            .get_envs()
522            .map(|(k, v)| {
523                (
524                    k.to_string_lossy().into_owned(),
525                    v.map(|value| value.to_string_lossy().into_owned())
526                        .unwrap_or_default(),
527                )
528            })
529            .collect();
530        assert_eq!(envs_a.get("CARGO_TARGET_DIR"), Some(&expected_default));
531        drop(temp_a);
532
533        // Override branch: an explicit CARGO_TARGET_DIR env mapping wins over
534        // the executor default.
535        let (temp_b, ctx_b) = make_ctx(&[("CARGO_TARGET_DIR", "custom-target"), ("FOO", "bar")]);
536        let mut cmd = ProcessCommand::new("prog");
537        apply_ctx(&mut cmd, &ctx_b);
538        let envs_b: HashMap<String, String> = cmd
539            .get_envs()
540            .map(|(k, v)| {
541                (
542                    k.to_string_lossy().into_owned(),
543                    v.map(|value| value.to_string_lossy().into_owned())
544                        .unwrap_or_default(),
545                )
546            })
547            .collect();
548        assert_eq!(
549            envs_b.get("CARGO_TARGET_DIR").map(String::as_str),
550            Some("custom-target")
551        );
552        assert_eq!(envs_b.get("FOO").map(String::as_str), Some("bar"));
553        drop(temp_b);
554    }
555
556    #[test]
557    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
558    fn apply_ctx_sets_working_directory_from_guarded_cwd() {
559        let (_temp, ctx) = make_ctx(&[]);
560        let mut cmd = ProcessCommand::new("prog");
561        apply_ctx(&mut cmd, &ctx);
562        let expected = oxdock_fs::command_path(match ctx.cwd() {
563            PolicyPath::Guarded(guarded) => guarded,
564            PolicyPath::Unguarded(_) => panic!("expected guarded cwd"),
565        });
566        assert_eq!(cmd.get_current_dir(), Some(expected.as_ref()));
567    }
568}