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