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    CommandStdout, ProcessManager, SharedInput, SharedOutput,
15};
16use crate::shell::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("OXBOOK_DEBUG").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
45        let (stdout_stream, capture_buf) = match stdout {
46            CommandStdout::Inherit => (None, None),
47            CommandStdout::Stream(stream) => (Some(stream), None),
48            CommandStdout::Capture => {
49                if matches!(mode, CommandMode::Background) {
50                    bail!("cannot capture stdout for background command");
51                }
52                let buf = Arc::new(Mutex::new(Vec::new()));
53                let writer: SharedOutput = buf.clone();
54                (Some(writer), Some(buf))
55            }
56        };
57
58        let stderr_stream = match stderr {
59            CommandStderr::Inherit => None,
60            CommandStderr::Stream(stream) => Some(stream),
61        };
62
63        let need_null_stdin = stdin.is_none();
64        if need_null_stdin {
65            // Do not inherit stdin by default; ensure isolation unless WITH_STDIN is used.
66            command.stdin(Stdio::null());
67        }
68        let desc = format!("{:?}", command);
69
70        match mode {
71            CommandMode::Foreground => {
72                let mut handle =
73                    spawn_child_with_streams(&mut command, stdin, stdout_stream, stderr_stream)?;
74                let status = handle
75                    .wait()
76                    .with_context(|| format!("failed to run {desc}"))?;
77                if !status.success() {
78                    bail!("command {desc} failed with status {}", status);
79                }
80                if let Some(buf) = capture_buf {
81                    let mut guard = buf.lock().map_err(|_| anyhow!("capture stdout poisoned"))?;
82                    return Ok(CommandResult::Captured(std::mem::take(&mut *guard)));
83                }
84                Ok(CommandResult::Completed)
85            }
86            CommandMode::Background => {
87                let handle =
88                    spawn_child_with_streams(&mut command, stdin, stdout_stream, stderr_stream)?;
89                Ok(CommandResult::Background(handle))
90            }
91        }
92    }
93}
94
95#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
96fn apply_ctx(command: &mut ProcessCommand, ctx: &CommandContext) {
97    // Use command_path to strip Windows verbatim prefixes (\\?\) before passing to Command.
98    // While Rust's `std::process::Command` handles verbatim paths in current_dir correctly,
99    // environment variables are passed as-is. If we pass a verbatim path in `CARGO_TARGET_DIR`,
100    // tools that don't understand it (or shell scripts echoing it) might misbehave or produce
101    // unexpected output. Normalizing here ensures consistency.
102    //
103    // Why the `\\?\` verbatim prefixes?
104    // On Windows we intentionally keep the canonical verbatim path (e.g. `\\?\C:\\repo`)
105    // inside every `GuardedPath`. This avoids MAX_PATH truncation and prevents subtle
106    // `PathBuf` casing/drive-letter surprises when the guard is later joined, copied,
107    // or passed through `std::fs`. When you need a human-readable path, call
108    // [`command_path`] (native separators, prefix stripped) or [`normalized_path`]
109    // (forward slashes) or use the `Display` impl, which already defers to
110    // `command_path`. Keep the debug view raw so diagnostics can show the exact path
111    // we are guarding.
112    let cwd_path: std::borrow::Cow<std::path::Path> = match ctx.cwd() {
113        PolicyPath::Guarded(p) => oxdock_fs::command_path(p),
114        PolicyPath::Unguarded(p) => std::borrow::Cow::Borrowed(p.as_path()),
115    };
116    command.current_dir(cwd_path);
117    command.envs(ctx.envs().as_ref());
118    if let Some(val) = ctx.envs().get("CARGO_TARGET_DIR") {
119        command.env("CARGO_TARGET_DIR", val);
120    } else {
121        command.env(
122            "CARGO_TARGET_DIR",
123            oxdock_fs::command_path(ctx.cargo_target_dir()).into_owned(),
124        );
125    }
126}
127
128#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
129fn spawn_child_with_streams(
130    cmd: &mut ProcessCommand,
131    stdin: Option<SharedInput>,
132    stdout: Option<SharedOutput>,
133    stderr: Option<SharedOutput>,
134) -> Result<ChildHandle> {
135    if stdin.is_some() {
136        cmd.stdin(Stdio::piped());
137    }
138    if stdout.is_some() {
139        cmd.stdout(Stdio::piped());
140    }
141    if stderr.is_some() {
142        cmd.stderr(Stdio::piped());
143    }
144
145    let mut child = cmd
146        .spawn()
147        .with_context(|| format!("failed to spawn {:?}", cmd))?;
148    let mut io_threads = Vec::new();
149
150    if let Some(stdin_stream) = stdin
151        && let Some(mut child_stdin) = child.stdin.take()
152    {
153        let thread = std::thread::spawn(move || {
154            if let Ok(mut guard) = stdin_stream.lock() {
155                let _ = std::io::copy(&mut *guard, &mut child_stdin);
156            }
157        });
158        io_threads.push(thread);
159    }
160
161    if let Some(stdout_stream) = stdout
162        && let Some(mut child_stdout) = child.stdout.take()
163    {
164        let stream_clone = stdout_stream.clone();
165        let thread = std::thread::spawn(move || {
166            let mut buf = [0u8; 1024];
167            loop {
168                match std::io::Read::read(&mut child_stdout, &mut buf) {
169                    Ok(0) => break,
170                    Ok(n) => {
171                        if let Ok(mut guard) = stream_clone.lock() {
172                            if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
173                                break;
174                            }
175                            let _ = std::io::Write::flush(&mut *guard);
176                        }
177                    }
178                    Err(_) => break,
179                }
180            }
181        });
182        io_threads.push(thread);
183    }
184
185    if let Some(stderr_stream) = stderr
186        && let Some(mut child_stderr) = child.stderr.take()
187    {
188        let stream_clone = stderr_stream.clone();
189        let thread = std::thread::spawn(move || {
190            let mut buf = [0u8; 1024];
191            loop {
192                match std::io::Read::read(&mut child_stderr, &mut buf) {
193                    Ok(0) => break,
194                    Ok(n) => {
195                        if let Ok(mut guard) = stream_clone.lock() {
196                            if std::io::Write::write_all(&mut *guard, &buf[..n]).is_err() {
197                                break;
198                            }
199                            let _ = std::io::Write::flush(&mut *guard);
200                        }
201                    }
202                    Err(_) => break,
203                }
204            }
205        });
206        io_threads.push(thread);
207    }
208
209    Ok(ChildHandle::new(child, io_threads))
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use oxdock_fs::GuardedPath;
216    use std::collections::HashMap;
217
218    fn make_ctx(envs: &[(&str, &str)]) -> (oxdock_fs::GuardedTempDir, CommandContext) {
219        let temp = GuardedPath::tempdir().expect("tempdir");
220        let guard = temp.as_guarded_path().clone();
221        let cwd: PolicyPath = guard.clone().into();
222        let map: HashMap<String, String> = envs
223            .iter()
224            .map(|(key, value)| (key.to_string(), value.to_string()))
225            .collect();
226        let ctx = CommandContext::from_map(&cwd, &map, &guard, &guard, &guard);
227        (temp, ctx)
228    }
229
230    #[test]
231    fn background_capture_stdout_bails_without_spawning() {
232        let (_temp, ctx) = make_ctx(&[]);
233        let mut pm = ShellProcessManager;
234        let options = CommandOptions {
235            mode: CommandMode::Background,
236            stdout: CommandStdout::Capture,
237            ..Default::default()
238        };
239        let err = match pm.run_command(&ctx, "echo hi", options) {
240            Err(err) => err,
241            Ok(_) => panic!("background capture must bail"),
242        };
243        assert!(
244            err.to_string().contains("cannot capture stdout"),
245            "unexpected error: {err}"
246        );
247    }
248
249    #[cfg_attr(
250        miri,
251        ignore = "spawns processes; Miri does not support process execution"
252    )]
253    #[test]
254    fn foreground_capture_returns_child_stdout_bytes() {
255        let (_temp, ctx) = make_ctx(&[]);
256        let mut pm = ShellProcessManager;
257        let options = CommandOptions {
258            stdout: CommandStdout::Capture,
259            ..Default::default()
260        };
261        match pm
262            .run_command(&ctx, "echo hello-capture", options)
263            .expect("run")
264        {
265            CommandResult::Captured(bytes) => {
266                let out = String::from_utf8_lossy(&bytes);
267                assert!(out.contains("hello-capture"), "captured: {out}");
268            }
269            CommandResult::Completed => panic!("expected Captured, got Completed"),
270            CommandResult::Background(_) => panic!("expected Captured, got Background"),
271        }
272    }
273
274    fn large_output_script() -> &'static str {
275        #[cfg(windows)]
276        {
277            "for /l %i in (1,1,20000) do @echo 0123456789abcdef"
278        }
279        #[cfg(not(windows))]
280        {
281            "i=0; while [ $i -lt 20000 ]; do echo 0123456789abcdef; i=$((i+1)); done"
282        }
283    }
284
285    #[cfg_attr(
286        miri,
287        ignore = "spawns processes; Miri does not support process execution"
288    )]
289    #[test]
290    fn streams_large_stdout_through_shared_output_without_deadlock() {
291        let (_temp, ctx) = make_ctx(&[]);
292        let mut pm = ShellProcessManager;
293        let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
294        let options = CommandOptions {
295            stdout: CommandStdout::Stream(buffer.clone()),
296            ..Default::default()
297        };
298        pm.run_command(&ctx, large_output_script(), options)
299            .expect("run");
300        let bytes = buffer.lock().expect("buffer lock").len();
301        // 20000 lines x 17 bytes ~= 340KB, far beyond any OS pipe buffer, so
302        // the reader pump must have iterated continuously.
303        assert!(bytes >= 300_000, "streamed only {bytes} bytes");
304    }
305
306    #[cfg_attr(
307        miri,
308        ignore = "spawns processes; Miri does not support process execution"
309    )]
310    #[test]
311    fn foreground_stdin_is_piped_through_copy_thread() {
312        let (_temp, ctx) = make_ctx(&[]);
313        let mut pm = ShellProcessManager;
314        // Windows `sort` requires `\r\n` line endings to recognize lines.
315        #[cfg(windows)]
316        let input: &[u8] = b"b\r\na\r\n";
317        #[cfg(not(windows))]
318        let input: &[u8] = b"b\na\n";
319        let payload: SharedInput =
320            std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(input.to_vec())));
321        let options = CommandOptions {
322            stdin: Some(payload),
323            stdout: CommandStdout::Capture,
324            ..Default::default()
325        };
326        // `sort` reads stdin to EOF on both Unix and Windows before printing.
327        match pm.run_command(&ctx, "sort", options).expect("run") {
328            CommandResult::Captured(bytes) => {
329                assert!(bytes.starts_with(b"a"), "sorted output: {:?}", bytes);
330                assert!(windows_compatible_contains(&bytes, b"b"));
331            }
332            CommandResult::Completed => panic!("expected Captured, got Completed"),
333            CommandResult::Background(_) => panic!("expected Captured, got Background"),
334        }
335    }
336
337    fn windows_compatible_contains(haystack: &[u8], needle: &[u8]) -> bool {
338        haystack.windows(needle.len()).any(|w| w == needle)
339    }
340
341    #[test]
342    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
343    fn apply_ctx_sets_cwd_and_cargo_target_dir_precedence() {
344        // Default branch: executor-provided cargo target dir wins when the
345        // env map has no explicit override.
346        let (temp_a, ctx_a) = make_ctx(&[]);
347        let expected_default = oxdock_fs::command_path(ctx_a.cargo_target_dir())
348            .to_string_lossy()
349            .into_owned();
350        let mut cmd = ProcessCommand::new("prog");
351        apply_ctx(&mut cmd, &ctx_a);
352        let envs_a: HashMap<String, String> = cmd
353            .get_envs()
354            .map(|(k, v)| {
355                (
356                    k.to_string_lossy().into_owned(),
357                    v.map(|value| value.to_string_lossy().into_owned())
358                        .unwrap_or_default(),
359                )
360            })
361            .collect();
362        assert_eq!(envs_a.get("CARGO_TARGET_DIR"), Some(&expected_default));
363        drop(temp_a);
364
365        // Override branch: an explicit CARGO_TARGET_DIR env mapping wins over
366        // the executor default.
367        let (temp_b, ctx_b) = make_ctx(&[("CARGO_TARGET_DIR", "custom-target"), ("FOO", "bar")]);
368        let mut cmd = ProcessCommand::new("prog");
369        apply_ctx(&mut cmd, &ctx_b);
370        let envs_b: HashMap<String, String> = cmd
371            .get_envs()
372            .map(|(k, v)| {
373                (
374                    k.to_string_lossy().into_owned(),
375                    v.map(|value| value.to_string_lossy().into_owned())
376                        .unwrap_or_default(),
377                )
378            })
379            .collect();
380        assert_eq!(
381            envs_b.get("CARGO_TARGET_DIR").map(String::as_str),
382            Some("custom-target")
383        );
384        assert_eq!(envs_b.get("FOO").map(String::as_str), Some("bar"));
385        drop(temp_b);
386    }
387
388    #[test]
389    #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
390    fn apply_ctx_sets_working_directory_from_guarded_cwd() {
391        let (_temp, ctx) = make_ctx(&[]);
392        let mut cmd = ProcessCommand::new("prog");
393        apply_ctx(&mut cmd, &ctx);
394        let expected = oxdock_fs::command_path(match ctx.cwd() {
395            PolicyPath::Guarded(guarded) => guarded,
396            PolicyPath::Unguarded(_) => panic!("expected guarded cwd"),
397        });
398        assert_eq!(cmd.get_current_dir(), Some(expected.as_ref()));
399    }
400}