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