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