Skip to main content

prick_exec/
error.rs

1//! Why a launch failed, and what the shell would have exited with.
2//!
3//! The exit codes here are **not** from the API taxonomy in
4//! [`prick_core::classify`]. They are the shell convention for a command that
5//! could not be started, and they are what a caller of `prk run` already
6//! branches on:
7//!
8//! | Code | Meaning |
9//! |---|---|
10//! | 126 | Found, but could not be executed |
11//! | 127 | Not found |
12//!
13//! Reproducing them exactly is the point of `prk run`: a script must not be
14//! able to tell whether a command ran under `prk run` or directly.
15
16use std::ffi::OsStr;
17use std::io;
18
19use prick_core::keyname::KeyNameError;
20
21use crate::guard::GuardError;
22
23/// The status a shell reports when a command was found but cannot be run.
24pub const EXIT_NOT_EXECUTABLE: i32 = 126;
25
26/// The status a shell reports when a command was not found.
27pub const EXIT_NOT_FOUND: i32 = 127;
28
29/// A launch that did not happen.
30#[derive(Debug, thiserror::Error)]
31#[non_exhaustive]
32pub enum LaunchError {
33    /// No program was given.
34    #[error("no command was given to run")]
35    NoProgram,
36
37    /// The program does not exist on `PATH`.
38    #[error("command not found: {program}")]
39    NotFound {
40        /// The program as the user wrote it.
41        program: String,
42    },
43
44    /// The program exists but is not executable by this user.
45    #[error("permission denied: {program}")]
46    PermissionDenied {
47        /// The resolved program path.
48        program: String,
49    },
50
51    /// The file exists and is executable but is not a program image.
52    ///
53    /// Almost always a script whose shebang line is missing, misspelled, or
54    /// carries a CRLF line ending that makes the interpreter path unresolvable.
55    #[error("{program} is not an executable format")]
56    NoExecFormat {
57        /// The resolved program path.
58        program: String,
59    },
60
61    /// A secret's name is not usable as an environment variable.
62    #[error("cannot inject `{key}` into the child environment: {source}")]
63    InvalidKey {
64        /// The offending name.
65        key: String,
66        /// Why it was rejected.
67        source: KeyNameError,
68    },
69
70    /// A secret's name is one the loader or a language runtime interprets.
71    #[error(transparent)]
72    Guard(#[from] GuardError),
73
74    /// An argument cannot be carried through `cmd.exe` to a batch shim.
75    #[error(transparent)]
76    CommandLine(#[from] crate::cmdline::CmdLineError),
77
78    /// The launch failed for a reason the operating system reported.
79    #[error("could not run {program}: {source}")]
80    Io {
81        /// The program that was being launched.
82        program: String,
83        /// The underlying failure.
84        source: io::Error,
85    },
86}
87
88impl LaunchError {
89    /// The status `prk` should exit with for this failure.
90    pub fn exit_code(&self) -> i32 {
91        match self {
92            Self::NotFound { .. } => EXIT_NOT_FOUND,
93            Self::PermissionDenied { .. } | Self::NoExecFormat { .. } => EXIT_NOT_EXECUTABLE,
94            // Refusing to launch is a rejection of the request, not a failure
95            // of the command, so it does not borrow the shell's codes.
96            Self::NoProgram
97            | Self::InvalidKey { .. }
98            | Self::Guard(_)
99            | Self::CommandLine(_)
100            | Self::Io { .. } => i32::from(prick_core::classify::EXIT_FAILURE),
101        }
102    }
103
104    /// The actionable next step, for the structured help channel.
105    pub fn hint(&self) -> Option<&'static str> {
106        match self {
107            Self::NotFound { .. } => Some(
108                "Check the spelling and that the program is on PATH. `prk run` never invokes a \
109                 shell, so shell builtins and aliases are not available; write `prk run -- sh -c \
110                 '...'` if you need one.",
111            ),
112            Self::PermissionDenied { .. } => {
113                Some("The file exists but is not executable. On Unix, check its mode bits.")
114            }
115            // The single most common cause, and invisible in a directory
116            // listing, so it is worth naming outright.
117            Self::NoExecFormat { .. } => Some(
118                "The file is not a program image. If it is a script, check that its first line is \
119                 a shebang such as `#!/usr/bin/env node` and that the file has Unix line endings \
120                 -- a CRLF makes the interpreter path unresolvable.",
121            ),
122            Self::Guard(_) => Some(
123                "Rename the secret, or pass --allow-unsafe-env if the child really is meant to be \
124                 configured this way.",
125            ),
126            Self::CommandLine(_) => Some(
127                "The program is a .cmd or .bat shim, so its arguments pass through cmd.exe, which \
128                 cannot carry a line break. Pass the value through a file or an environment \
129                 variable instead.",
130            ),
131            Self::NoProgram | Self::InvalidKey { .. } | Self::Io { .. } => None,
132        }
133    }
134
135    /// Classifies an [`io::Error`] from a failed launch.
136    ///
137    /// The three interesting cases each have a distinct exit code and a
138    /// distinct fix, and all three arrive as the same `io::Error` type.
139    pub fn from_io(program: &OsStr, source: io::Error) -> Self {
140        let program = program.to_string_lossy().into_owned();
141        match source.kind() {
142            io::ErrorKind::NotFound => Self::NotFound { program },
143            io::ErrorKind::PermissionDenied => Self::PermissionDenied { program },
144            _ => {
145                if is_exec_format_error(&source) {
146                    Self::NoExecFormat { program }
147                } else {
148                    Self::Io { program, source }
149                }
150            }
151        }
152    }
153}
154
155/// Whether an error is `ENOEXEC`, which has no `io::ErrorKind` of its own.
156#[cfg(unix)]
157fn is_exec_format_error(source: &io::Error) -> bool {
158    source.raw_os_error() == Some(libc::ENOEXEC)
159}
160
161/// Windows reports a non-image file as `ERROR_BAD_EXE_FORMAT`.
162#[cfg(windows)]
163fn is_exec_format_error(source: &io::Error) -> bool {
164    /// `ERROR_BAD_EXE_FORMAT`.
165    const ERROR_BAD_EXE_FORMAT: i32 = 193;
166    source.raw_os_error() == Some(ERROR_BAD_EXE_FORMAT)
167}
168
169#[cfg(not(any(unix, windows)))]
170fn is_exec_format_error(_source: &io::Error) -> bool {
171    false
172}
173
174#[cfg(test)]
175mod tests {
176    use std::ffi::OsString;
177
178    use super::*;
179
180    #[test]
181    fn a_missing_command_exits_127_like_a_shell() {
182        let err = LaunchError::from_io(
183            &OsString::from("nosuchprogram"),
184            io::Error::new(io::ErrorKind::NotFound, "not found"),
185        );
186        assert!(matches!(err, LaunchError::NotFound { .. }));
187        assert_eq!(err.exit_code(), 127);
188    }
189
190    #[test]
191    fn an_unexecutable_command_exits_126_like_a_shell() {
192        let err = LaunchError::from_io(
193            &OsString::from("/etc/hosts"),
194            io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
195        );
196        assert!(matches!(err, LaunchError::PermissionDenied { .. }));
197        assert_eq!(err.exit_code(), 126);
198    }
199
200    #[cfg(unix)]
201    #[test]
202    fn enoexec_is_recognised_and_points_at_the_shebang() {
203        let err = LaunchError::from_io(
204            &OsString::from("./script"),
205            io::Error::from_raw_os_error(libc::ENOEXEC),
206        );
207        assert!(matches!(err, LaunchError::NoExecFormat { .. }));
208        assert_eq!(err.exit_code(), 126);
209        assert!(err.hint().is_some_and(|h| h.contains("shebang")));
210    }
211
212    #[cfg(windows)]
213    #[test]
214    fn a_non_image_file_is_recognised_and_points_at_the_shebang() {
215        let err =
216            LaunchError::from_io(&OsString::from("script.txt"), io::Error::from_raw_os_error(193));
217        assert!(matches!(err, LaunchError::NoExecFormat { .. }));
218        assert_eq!(err.exit_code(), 126);
219        assert!(err.hint().is_some_and(|h| h.contains("shebang")));
220    }
221
222    #[test]
223    fn the_message_names_the_program_but_never_a_value() {
224        let err = LaunchError::NotFound { program: "npm".to_owned() };
225        assert!(err.to_string().contains("npm"));
226    }
227
228    #[test]
229    fn a_guard_refusal_keeps_its_own_message_and_hint() {
230        let err = LaunchError::from(GuardError::LoaderControlled { name: "LD_PRELOAD".to_owned() });
231        assert!(err.to_string().contains("LD_PRELOAD"));
232        assert!(err.hint().is_some_and(|h| h.contains("--allow-unsafe-env")));
233        assert_eq!(err.exit_code(), 1);
234    }
235
236    #[test]
237    fn an_unrepresentable_argument_reports_which_one() {
238        let err = LaunchError::from(crate::cmdline::CmdLineError::LineBreak { index: 2 });
239        assert!(err.to_string().contains("argument 2"));
240        assert!(err.hint().is_some());
241    }
242
243    #[test]
244    fn an_invalid_key_names_the_key_and_the_reason() {
245        let err = LaunchError::InvalidKey {
246            key: "A-B".to_owned(),
247            source: KeyNameError::InvalidCharacter { name: "A-B".to_owned(), ch: '-' },
248        };
249        let message = err.to_string();
250        assert!(message.contains("A-B"));
251        assert!(message.contains('-'));
252    }
253}