Skip to main content

oxdock_process/
shell.rs

1use anyhow::{Context, Result, bail};
2use oxdock_fs::GuardedPath;
3#[cfg(all(unix, not(miri)))]
4use oxdock_fs::PathResolver;
5use std::ffi::OsStr;
6use std::fs::File;
7#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
8use std::process::{Command, ExitStatus, Stdio};
9
10use crate::CommandBuilder;
11
12pub fn shell_program() -> String {
13    #[cfg(windows)]
14    {
15        std::env::var("COMSPEC").unwrap_or_else(|_| "cmd".to_string())
16    }
17
18    #[cfg(not(windows))]
19    {
20        std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string())
21    }
22}
23
24#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
25pub(crate) fn direct_cmd(argv: &[String]) -> Result<Command> {
26    let (program, rest) = argv
27        .split_first()
28        .ok_or_else(|| anyhow::anyhow!("RUN exec form requires at least one argument"))?;
29    if program.is_empty() {
30        bail!("RUN exec form requires a non-empty executable");
31    }
32    let mut c = Command::new(program);
33    c.args(rest);
34    Ok(c)
35}
36
37#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
38pub(crate) fn shell_cmd(cmd: &str) -> Command {
39    let program = shell_program();
40    let mut c = Command::new(program);
41    #[allow(clippy::disallowed_macros)]
42    if cfg!(windows) {
43        c.arg("/C").arg(cmd);
44    } else {
45        c.arg("-c").arg(cmd);
46    }
47    c
48}
49
50#[derive(Default)]
51pub struct ShellLauncher;
52
53#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
54impl ShellLauncher {
55    pub fn run(&self, cmd: &mut Command) -> Result<()> {
56        let status = cmd
57            .status()
58            .with_context(|| format!("failed to run {:?}", cmd))?;
59        if !status.success() {
60            bail!("command {:?} failed with status {}", cmd, status);
61        }
62        Ok(())
63    }
64
65    pub fn run_with_output(&self, cmd: &mut Command) -> Result<(ExitStatus, Vec<u8>, Vec<u8>)> {
66        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
67        let output = cmd
68            .output()
69            .with_context(|| format!("failed to run {:?}", cmd))?;
70        Ok((output.status, output.stdout, output.stderr))
71    }
72
73    pub fn spawn(&self, cmd: &mut Command) -> Result<()> {
74        cmd.spawn()
75            .with_context(|| format!("failed to spawn {:?}", cmd))?;
76        Ok(())
77    }
78
79    pub fn with_stdins<'a>(&self, cmd: &'a mut Command, stdin: Option<File>) -> &'a mut Command {
80        if let Some(file) = stdin {
81            cmd.stdin(file);
82        }
83        cmd
84    }
85
86    pub fn program_arg(&self, program: impl AsRef<OsStr>) -> Command {
87        Command::new(program)
88    }
89}
90
91/// Spawn an interactive shell rooted at `cwd`, printing `banner` before handing
92/// control to the user's shell.
93///
94/// `workspace_root` anchors the guarded path resolver used to reattach the
95/// controlling TTY on Unix.
96pub fn spawn_interactive_shell(
97    cwd: &GuardedPath,
98    workspace_root: &GuardedPath,
99    banner: &str,
100) -> Result<()> {
101    let _ = workspace_root;
102    #[cfg(unix)]
103    {
104        let mut cmd = CommandBuilder::new(shell_program());
105        cmd.current_dir(cwd.as_path());
106
107        // Print a single banner inside the subshell, then exec the user's shell to stay interactive.
108        // The banner travels via env and the shell program via `$1` so neither is interpolated into
109        // the script string (paths with spaces or `%` would break quoting or printf parsing).
110        const SCRIPT: &str = "printf '%s\\n' \"$OXDOCK_BANNER\"; exec \"$1\"";
111        cmd.env("OXDOCK_BANNER", banner);
112        cmd.arg("-c").arg(SCRIPT).arg("sh").arg(shell_program());
113
114        // Reattach stdin to the controlling TTY so a piped-in script can still open an interactive shell.
115        #[cfg(not(miri))]
116        {
117            #[allow(clippy::disallowed_types)]
118            let tty_path = oxdock_fs::UnguardedPath::external("/dev/tty");
119            if let Ok(resolver) =
120                PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
121                && let Ok(tty) = resolver.open_file_unguarded(&tty_path)
122            {
123                cmd.stdin_file(tty);
124            }
125        }
126
127        if try_shell_command_hook(&mut cmd)? {
128            return Ok(());
129        }
130
131        let status = cmd.status()?;
132        if !status.success() {
133            bail!("shell exited with status {}", status);
134        }
135        Ok(())
136    }
137
138    #[cfg(windows)]
139    {
140        // Launch via `start` so Windows opens a real interactive console window. Normalize the path
141        // and also set the parent process working directory to the temp workspace; this avoids
142        // start's `/D` parsing quirks on paths with spaces or verbatim prefixes.
143        let cwd_path = oxdock_fs::command_path(cwd);
144        let banner_cmd = windows_banner_command(banner, cwd);
145        let mut cmd = CommandBuilder::new("cmd");
146        cmd.env("OXDOCK_BANNER", banner);
147        cmd.current_dir(cwd_path.as_ref())
148            .arg("/C")
149            .arg("start")
150            .arg("oxdock shell")
151            .arg("cmd")
152            .arg("/K")
153            .arg(banner_cmd);
154
155        if try_shell_command_hook(&mut cmd)? {
156            return Ok(());
157        }
158
159        // Fire-and-forget so the parent console regains control immediately; the child window is
160        // fully interactive. If the launch fails, surface the error right away.
161        cmd.spawn()
162            .context("failed to start interactive shell window")?;
163        Ok(())
164    }
165
166    #[cfg(not(any(unix, windows)))]
167    {
168        let _ = (cwd, workspace_root, banner);
169        bail!("interactive shell unsupported on this platform");
170    }
171}
172
173#[cfg(windows)]
174fn escape_for_cmd(s: &str) -> String {
175    // Escape characters that would otherwise be interpreted by cmd when echoed.
176    s.replace('^', "^^")
177        .replace('&', "^&")
178        .replace('|', "^|")
179        .replace('>', "^>")
180        .replace('<', "^<")
181}
182
183#[cfg(windows)]
184fn windows_banner_command(banner: &str, cwd: &GuardedPath) -> String {
185    let mut parts: Vec<String> = banner
186        .lines()
187        .map(|line| format!("echo {}", escape_for_cmd(line)))
188        .collect();
189    let cwd_path = oxdock_fs::command_path(cwd);
190    parts.push(format!(
191        "cd /d {}",
192        escape_for_cmd(&cwd_path.as_ref().display().to_string())
193    ));
194    parts.join(" && ")
195}
196
197#[cfg(test)]
198type ShellCmdHook = dyn FnMut(&crate::CommandSnapshot) -> Result<()> + Send;
199
200#[cfg(test)]
201thread_local! {
202    static SHELL_CMD_HOOK: std::cell::RefCell<Option<Box<ShellCmdHook>>> =
203        std::cell::RefCell::new(None);
204}
205
206#[cfg(test)]
207fn set_shell_command_hook<F>(hook: F)
208where
209    F: FnMut(&crate::CommandSnapshot) -> Result<()> + Send + 'static,
210{
211    SHELL_CMD_HOOK.with(|slot| {
212        *slot.borrow_mut() = Some(Box::new(hook));
213    });
214}
215
216#[cfg(test)]
217fn clear_shell_command_hook() {
218    SHELL_CMD_HOOK.with(|slot| {
219        *slot.borrow_mut() = None;
220    });
221}
222
223#[cfg(test)]
224fn try_shell_command_hook(cmd: &mut CommandBuilder) -> Result<bool> {
225    SHELL_CMD_HOOK.with(|slot| {
226        if let Some(hook) = slot.borrow_mut().as_mut() {
227            let snap = cmd.snapshot();
228            hook(&snap)?;
229            return Ok(true);
230        }
231        Ok(false)
232    })
233}
234
235#[cfg(not(test))]
236fn try_shell_command_hook(cmd: &mut CommandBuilder) -> Result<bool> {
237    let _ = cmd;
238    Ok(false)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::{ShellLauncher, direct_cmd, shell_cmd, shell_program};
244    use crate::TestEnvGuard;
245
246    use std::ffi::OsStr;
247    use std::sync::Mutex;
248
249    // For Windows, fixing COMSPEC override test race condition
250    pub(super) static ENV_LOCK: Mutex<()> = Mutex::new(());
251
252    #[test]
253    fn shell_program_prefers_env_override() {
254        let _lock = ENV_LOCK.lock().expect("env lock");
255        #[cfg(windows)]
256        let _guard = TestEnvGuard::set("COMSPEC", "custom-cmd");
257        #[cfg(not(windows))]
258        let _guard = TestEnvGuard::set("SHELL", "custom-sh");
259        let program = shell_program();
260        #[cfg(windows)]
261        assert_eq!(program, "custom-cmd");
262        #[cfg(not(windows))]
263        assert_eq!(program, "custom-sh");
264    }
265
266    #[cfg_attr(
267        miri,
268        ignore = "spawns shell command; Miri does not support process execution"
269    )]
270    #[test]
271    fn shell_launcher_run_with_output_captures_stdout() {
272        let _lock = ENV_LOCK.lock().expect("env lock");
273        let launcher = ShellLauncher;
274        let mut cmd = shell_cmd("echo hello");
275        let (status, stdout, _stderr) = launcher.run_with_output(&mut cmd).expect("run output");
276        assert!(status.success());
277        let out = String::from_utf8_lossy(&stdout);
278        assert!(out.contains("hello"));
279    }
280
281    #[test]
282    fn shell_launcher_program_arg_tracks_program() {
283        let launcher = ShellLauncher;
284        let cmd = launcher.program_arg("echo");
285        assert_eq!(cmd.get_program(), OsStr::new("echo"));
286    }
287
288    #[test]
289    fn shell_program_falls_back_to_default_without_env() {
290        let _lock = ENV_LOCK.lock().expect("env lock");
291        #[cfg(windows)]
292        {
293            let _guard = TestEnvGuard::remove("COMSPEC");
294            assert_eq!(shell_program(), "cmd");
295        }
296        #[cfg(not(windows))]
297        {
298            let _guard = TestEnvGuard::remove("SHELL");
299            assert_eq!(shell_program(), "sh");
300        }
301    }
302
303    #[test]
304    fn shell_cmd_applies_platform_flag_and_script() {
305        let cmd = shell_cmd("echo hi");
306        let args: Vec<String> = cmd
307            .get_args()
308            .map(|arg| arg.to_string_lossy().into_owned())
309            .collect();
310        #[cfg(windows)]
311        assert_eq!(args, vec!["/C".to_string(), "echo hi".to_string()]);
312        #[cfg(not(windows))]
313        assert_eq!(args, vec!["-c".to_string(), "echo hi".to_string()]);
314    }
315
316    #[test]
317    fn direct_cmd_builds_program_and_args_without_shell() {
318        let argv = vec!["prog".to_string(), "a".to_string(), "b c".to_string()];
319        let cmd = direct_cmd(&argv).expect("direct_cmd");
320        assert_eq!(cmd.get_program(), OsStr::new("prog"));
321        let args: Vec<String> = cmd
322            .get_args()
323            .map(|arg| arg.to_string_lossy().into_owned())
324            .collect();
325        assert_eq!(args, vec!["a".to_string(), "b c".to_string()]);
326    }
327
328    #[test]
329    fn direct_cmd_rejects_empty_argv() {
330        let err = direct_cmd(&[]).expect_err("empty argv must fail");
331        assert!(
332            err.to_string().contains("at least one argument"),
333            "unexpected error: {err:#}"
334        );
335        let err = direct_cmd(&[String::new()]).expect_err("empty program must fail");
336        assert!(
337            err.to_string().contains("non-empty executable"),
338            "unexpected error: {err:#}"
339        );
340    }
341
342    #[cfg_attr(
343        miri,
344        ignore = "spawns shell command; Miri does not support process execution"
345    )]
346    #[test]
347    fn shell_launcher_run_succeeds_on_zero_exit() {
348        let launcher = ShellLauncher;
349        let mut cmd = shell_cmd("exit 0");
350        launcher.run(&mut cmd).expect("zero exit should succeed");
351    }
352
353    #[cfg_attr(
354        miri,
355        ignore = "spawns shell command; Miri does not support process execution"
356    )]
357    #[test]
358    fn shell_launcher_run_reports_nonzero_status() {
359        let launcher = ShellLauncher;
360        let mut cmd = shell_cmd("exit 3");
361        let err = launcher.run(&mut cmd).expect_err("nonzero exit must fail");
362        let msg = err.to_string();
363        assert!(
364            msg.contains("failed with status"),
365            "unexpected error message: {msg}"
366        );
367    }
368
369    #[cfg_attr(
370        miri,
371        ignore = "spawns shell command; Miri does not support process execution"
372    )]
373    #[test]
374    fn shell_launcher_spawn_smoke() {
375        let launcher = ShellLauncher;
376        let mut cmd = shell_cmd("exit 0");
377        launcher.spawn(&mut cmd).expect("spawn should succeed");
378    }
379
380    #[test]
381    fn shell_launcher_with_stdins_none_passthrough_keeps_builder_usable() {
382        let launcher = ShellLauncher;
383        let mut cmd = launcher.program_arg("echo");
384        let same = launcher.with_stdins(&mut cmd, None);
385        assert_eq!(same.get_program(), OsStr::new("echo"));
386    }
387}
388
389#[cfg(test)]
390mod interactive_shell_tests {
391    use super::tests::ENV_LOCK;
392    use super::{clear_shell_command_hook, set_shell_command_hook, spawn_interactive_shell};
393    use crate::CommandSnapshot;
394    use anyhow::Result;
395    use oxdock_fs::GuardedPath;
396    use std::sync::{Arc, Mutex};
397
398    #[cfg(any(unix, windows))]
399    #[test]
400    fn spawn_interactive_shell_builds_command_for_platform() -> Result<()> {
401        // Serializes against sibling tests that mutate SHELL/COMSPEC: the
402        // snapshot below bakes in `shell_program()` at build time and
403        // re-reads it at assert time, so a concurrent mutation fails it.
404        let _lock = ENV_LOCK.lock().expect("env lock");
405        let workspace = GuardedPath::tempdir()?;
406        let workspace_root = workspace.as_guarded_path().clone();
407        let cwd = workspace_root.join("subdir")?;
408        #[cfg(not(miri))]
409        {
410            let resolver =
411                oxdock_fs::PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
412            resolver.create_dir_all(&cwd)?;
413        }
414
415        let captured = Arc::new(Mutex::new(None::<CommandSnapshot>));
416        let guard = captured.clone();
417        set_shell_command_hook(move |cmd| {
418            *guard.lock().unwrap() = Some(cmd.clone());
419            Ok(())
420        });
421        spawn_interactive_shell(&cwd, &workspace_root, "test banner")?;
422        clear_shell_command_hook();
423
424        let snap = captured
425            .lock()
426            .unwrap()
427            .clone()
428            .expect("hook should capture snapshot");
429        let cwd_path = snap.cwd.expect("cwd should be set");
430        assert!(
431            cwd_path.ends_with("subdir"),
432            "expected cwd to include subdir, got {}",
433            cwd_path.display()
434        );
435        assert!(
436            snap.envs
437                .iter()
438                .any(|(k, v)| k == "OXDOCK_BANNER" && v == "test banner"),
439            "expected OXDOCK_BANNER env injection, got {:?}",
440            snap.envs
441        );
442
443        #[cfg(unix)]
444        {
445            let program = snap.program.to_string_lossy();
446            assert_eq!(
447                program,
448                super::shell_program(),
449                "expected shell program name"
450            );
451            let args: Vec<_> = snap
452                .args
453                .iter()
454                .map(|s| s.to_string_lossy().to_string())
455                .collect();
456            assert_eq!(
457                args.len(),
458                4,
459                "expected four args (-c script sh shell), got {:?}",
460                args
461            );
462            assert_eq!(args[0], "-c");
463            assert!(
464                args[1].contains("$OXDOCK_BANNER"),
465                "expected env-based banner reference, got {:?}",
466                args[1]
467            );
468            assert!(
469                args[1].contains("exec \"$1\""),
470                "expected positional shell exec, got {:?}",
471                args[1]
472            );
473            assert!(
474                !args[1].contains("test banner"),
475                "banner must not be interpolated into the script, got {:?}",
476                args[1]
477            );
478            assert_eq!(args[2], "sh", "expected $0 placeholder");
479            assert_eq!(args[3], super::shell_program(), "expected shell path as $1");
480        }
481
482        #[cfg(windows)]
483        {
484            use super::windows_banner_command;
485            let program = snap.program.to_string_lossy().to_string();
486            assert_eq!(program, "cmd", "expected cmd.exe launcher");
487            let args: Vec<_> = snap
488                .args
489                .iter()
490                .map(|s| s.to_string_lossy().to_string())
491                .collect();
492            let banner_cmd = windows_banner_command("test banner", &cwd);
493            let expected = vec![
494                "/C".to_string(),
495                "start".to_string(),
496                "oxdock shell".to_string(),
497                "cmd".to_string(),
498                "/K".to_string(),
499                banner_cmd,
500            ];
501            assert_eq!(args, expected, "expected exact windows shell argv");
502        }
503
504        Ok(())
505    }
506
507    #[cfg(windows)]
508    #[test]
509    fn windows_banner_command_emits_all_lines() {
510        let banner = "line1\nline2\nline3";
511        let workspace = GuardedPath::tempdir().expect("tempdir");
512        let cwd = workspace.as_guarded_path().clone();
513        let cmd = super::windows_banner_command(banner, &cwd);
514        assert!(cmd.contains("line1"));
515        assert!(cmd.contains("line2"));
516        assert!(cmd.contains("line3"));
517        assert!(cmd.contains("cd /d "));
518    }
519}