Skip to main content

usage/
sh.rs

1//! Running the shell scripts a spec embeds in `run=`.
2
3use std::io;
4use std::process::Command;
5use std::string::FromUtf8Error;
6use xx::process::check_status;
7use xx::XXError;
8
9use crate::error::Result;
10
11/// The interpreter a `run=` script is handed to.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13enum ShellKind {
14    /// `sh -c`, what `run=` is written for everywhere in the spec format.
15    Posix,
16    /// `cmd /c`. Windows only, and only when there is no POSIX shell to be had:
17    /// it cannot run a shebang script, a pipeline, or `a; b`.
18    Cmd,
19}
20
21fn shell_argv(kind: ShellKind) -> (&'static str, &'static str) {
22    match kind {
23        ShellKind::Posix => ("sh", "-c"),
24        ShellKind::Cmd => ("cmd", "/c"),
25    }
26}
27
28/// Which interpreter to try next after `kind` failed to start, if any.
29///
30/// Only a missing executable falls back. Anything else — a `sh` that exists but cannot be
31/// executed, say — is reported as-is: quietly demoting a broken shell to a different one is
32/// the same class of silent wrong behavior this fallback exists to get rid of.
33fn fallback_for(kind: ShellKind, err: io::ErrorKind) -> Option<ShellKind> {
34    match (kind, err) {
35        (ShellKind::Posix, io::ErrorKind::NotFound) if cfg!(windows) => Some(ShellKind::Cmd),
36        _ => None,
37    }
38}
39
40/// The first line of a script, for putting in an error message.
41///
42/// A `run=` can be a whole multi-line `case … esac`, and these messages surface in a shell
43/// completion, where a wall of text buries the prompt.
44fn script_excerpt(script: &str) -> String {
45    let first_line = script.lines().next().unwrap_or_default();
46    match script.lines().nth(1) {
47        Some(_) => format!("{first_line} …"),
48        None => first_line.to_string(),
49    }
50}
51
52fn no_shell_message(script: &str) -> String {
53    format!(
54        "failed to run `run=` script: neither `sh` nor `cmd` could be started\n  \
55         script: {}\n  \
56         `run=` is executed with `sh -c`, falling back to `cmd /c` on Windows. \
57         Install a POSIX shell (Git for Windows ships sh.exe) and make sure it is on PATH.",
58        script_excerpt(script)
59    )
60}
61
62fn non_utf8_message(shell: &str, flag: &str, script: &str, err: &FromUtf8Error) -> String {
63    format!(
64        "`run=` script produced output that is not valid UTF-8: {err}\n  \
65         script: {}\n  \
66         shell: {shell} {flag}",
67        script_excerpt(script)
68    )
69}
70
71/// Run a `run=` script and return its stdout.
72///
73/// Executed with `sh -c`, which is the language the spec format's `run=` is written in — the
74/// reference examples use pipelines, `;` sequences and shebang scripts. On Windows, where a
75/// POSIX shell is not guaranteed, a missing `sh` falls back to `cmd /c`; that runs a plain
76/// command invocation but none of the above, so a spec meant to work there should keep `run=`
77/// to a single command.
78///
79/// stdin is closed and stderr is inherited, so a script cannot stall a completion waiting for
80/// input but can still say why it failed. `__USAGE` is set to the usage version, letting a
81/// script tell that it was invoked by usage.
82///
83/// Output that is not valid UTF-8 is an error, not a panic and not a lossy conversion. The
84/// `cmd /c` fallback in particular emits the console code page, which is not UTF-8 outside
85/// English locales, and a mount's output is parsed as a spec — replacement characters there
86/// would resurface as a baffling KDL syntax error instead of an encoding one.
87pub fn sh(script: &str) -> Result<String> {
88    let mut kind = ShellKind::Posix;
89    let output = loop {
90        let (shell, flag) = shell_argv(kind);
91        let err = match run(shell, flag, script) {
92            Ok(output) => break output,
93            Err(err) => err,
94        };
95        match fallback_for(kind, err.kind()) {
96            Some(next) => kind = next,
97            None if err.kind() == io::ErrorKind::NotFound && cfg!(windows) => {
98                return Err(XXError::Error(no_shell_message(script)).into());
99            }
100            None => {
101                return Err(XXError::ProcessError(err, format!("{shell} {flag} {script}")).into());
102            }
103        }
104    };
105
106    let (shell, flag) = shell_argv(kind);
107    check_status(output.status)
108        .map_err(|err| XXError::ProcessError(err, format!("{shell} {flag} {script}")))?;
109    String::from_utf8(output.stdout)
110        .map_err(|err| XXError::Error(non_utf8_message(shell, flag, script, &err)).into())
111}
112
113fn run(shell: &str, flag: &str, script: &str) -> io::Result<std::process::Output> {
114    Command::new(shell)
115        .arg(flag)
116        .arg(script)
117        .stdin(std::process::Stdio::null())
118        .stderr(std::process::Stdio::inherit())
119        .env("__USAGE", env!("CARGO_PKG_VERSION"))
120        .output()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn shell_argv_maps_each_kind() {
129        assert_eq!(shell_argv(ShellKind::Posix), ("sh", "-c"));
130        assert_eq!(shell_argv(ShellKind::Cmd), ("cmd", "/c"));
131    }
132
133    #[test]
134    fn a_missing_posix_shell_falls_back_only_on_windows() {
135        let fallback = fallback_for(ShellKind::Posix, io::ErrorKind::NotFound);
136        if cfg!(windows) {
137            assert_eq!(fallback, Some(ShellKind::Cmd));
138        } else {
139            assert_eq!(fallback, None);
140        }
141    }
142
143    #[test]
144    fn a_shell_that_exists_but_fails_is_not_demoted() {
145        // Falling back here would hide a broken `sh` behind a shell that silently
146        // mis-executes the script, which is the failure mode this is meant to remove.
147        assert_eq!(
148            fallback_for(ShellKind::Posix, io::ErrorKind::PermissionDenied),
149            None
150        );
151    }
152
153    #[test]
154    fn cmd_is_the_last_resort() {
155        assert_eq!(fallback_for(ShellKind::Cmd, io::ErrorKind::NotFound), None);
156    }
157
158    #[test]
159    fn no_shell_message_names_both_shells_and_the_script() {
160        let msg = no_shell_message("echo hello");
161        assert!(msg.contains("`sh`"), "{msg}");
162        assert!(msg.contains("`cmd`"), "{msg}");
163        assert!(msg.contains("echo hello"), "{msg}");
164    }
165
166    #[test]
167    fn no_shell_message_truncates_a_multi_line_script() {
168        let msg = no_shell_message("case $cur in\n  a) echo a ;;\nesac");
169        assert!(msg.contains("case $cur in …"), "{msg}");
170        assert!(!msg.contains("esac"), "{msg}");
171    }
172
173    #[test]
174    fn non_utf8_message_names_the_script_and_the_shell() {
175        let err = String::from_utf8(vec![0xff]).unwrap_err();
176        let msg = non_utf8_message("cmd", "/c", "chcp 932 && dir", &err);
177        assert!(msg.contains("chcp 932 && dir"), "{msg}");
178        assert!(msg.contains("cmd /c"), "{msg}");
179        assert!(msg.contains("not valid UTF-8"), "{msg}");
180    }
181
182    #[cfg(unix)]
183    #[test]
184    fn sh_reports_non_utf8_output_instead_of_panicking() {
185        // A `run=` that emits raw bytes used to take the whole process down. `cmd /c` on a
186        // non-English Windows reaches this through its console code page.
187        let err = sh(r"printf '\377'").unwrap_err();
188        assert!(
189            err.to_string().contains("not valid UTF-8"),
190            "{}",
191            err.to_string()
192        );
193    }
194
195    #[test]
196    fn sh_returns_stdout() {
197        // `echo` behaves the same under `sh -c` and `cmd /c`, so this runs anywhere.
198        assert!(sh("echo hello").unwrap().contains("hello"));
199    }
200
201    #[test]
202    fn sh_fails_on_a_nonzero_exit() {
203        assert!(sh("exit 1").is_err());
204    }
205
206    #[cfg(unix)]
207    #[test]
208    fn sh_exposes_the_usage_version() {
209        assert_eq!(
210            sh("echo $__USAGE").unwrap().trim(),
211            env!("CARGO_PKG_VERSION")
212        );
213    }
214}