use std::ffi::OsStr;
use std::io;
use prick_core::keyname::KeyNameError;
use crate::guard::GuardError;
pub const EXIT_NOT_EXECUTABLE: i32 = 126;
pub const EXIT_NOT_FOUND: i32 = 127;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LaunchError {
#[error("no command was given to run")]
NoProgram,
#[error("command not found: {program}")]
NotFound {
program: String,
},
#[error("permission denied: {program}")]
PermissionDenied {
program: String,
},
#[error("{program} is not an executable format")]
NoExecFormat {
program: String,
},
#[error("cannot inject `{key}` into the child environment: {source}")]
InvalidKey {
key: String,
source: KeyNameError,
},
#[error(transparent)]
Guard(#[from] GuardError),
#[error(transparent)]
CommandLine(#[from] crate::cmdline::CmdLineError),
#[error("could not run {program}: {source}")]
Io {
program: String,
source: io::Error,
},
}
impl LaunchError {
pub fn exit_code(&self) -> i32 {
match self {
Self::NotFound { .. } => EXIT_NOT_FOUND,
Self::PermissionDenied { .. } | Self::NoExecFormat { .. } => EXIT_NOT_EXECUTABLE,
Self::NoProgram
| Self::InvalidKey { .. }
| Self::Guard(_)
| Self::CommandLine(_)
| Self::Io { .. } => i32::from(prick_core::classify::EXIT_FAILURE),
}
}
pub fn hint(&self) -> Option<&'static str> {
match self {
Self::NotFound { .. } => Some(
"Check the spelling and that the program is on PATH. `prk run` never invokes a \
shell, so shell builtins and aliases are not available; write `prk run -- sh -c \
'...'` if you need one.",
),
Self::PermissionDenied { .. } => {
Some("The file exists but is not executable. On Unix, check its mode bits.")
}
Self::NoExecFormat { .. } => Some(
"The file is not a program image. If it is a script, check that its first line is \
a shebang such as `#!/usr/bin/env node` and that the file has Unix line endings \
-- a CRLF makes the interpreter path unresolvable.",
),
Self::Guard(_) => Some(
"Rename the secret, or pass --allow-unsafe-env if the child really is meant to be \
configured this way.",
),
Self::CommandLine(_) => Some(
"The program is a .cmd or .bat shim, so its arguments pass through cmd.exe, which \
cannot carry a line break. Pass the value through a file or an environment \
variable instead.",
),
Self::NoProgram | Self::InvalidKey { .. } | Self::Io { .. } => None,
}
}
pub fn from_io(program: &OsStr, source: io::Error) -> Self {
let program = program.to_string_lossy().into_owned();
match source.kind() {
io::ErrorKind::NotFound => Self::NotFound { program },
io::ErrorKind::PermissionDenied => Self::PermissionDenied { program },
_ => {
if is_exec_format_error(&source) {
Self::NoExecFormat { program }
} else {
Self::Io { program, source }
}
}
}
}
}
#[cfg(unix)]
fn is_exec_format_error(source: &io::Error) -> bool {
source.raw_os_error() == Some(libc::ENOEXEC)
}
#[cfg(windows)]
fn is_exec_format_error(source: &io::Error) -> bool {
const ERROR_BAD_EXE_FORMAT: i32 = 193;
source.raw_os_error() == Some(ERROR_BAD_EXE_FORMAT)
}
#[cfg(not(any(unix, windows)))]
fn is_exec_format_error(_source: &io::Error) -> bool {
false
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use super::*;
#[test]
fn a_missing_command_exits_127_like_a_shell() {
let err = LaunchError::from_io(
&OsString::from("nosuchprogram"),
io::Error::new(io::ErrorKind::NotFound, "not found"),
);
assert!(matches!(err, LaunchError::NotFound { .. }));
assert_eq!(err.exit_code(), 127);
}
#[test]
fn an_unexecutable_command_exits_126_like_a_shell() {
let err = LaunchError::from_io(
&OsString::from("/etc/hosts"),
io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
);
assert!(matches!(err, LaunchError::PermissionDenied { .. }));
assert_eq!(err.exit_code(), 126);
}
#[cfg(unix)]
#[test]
fn enoexec_is_recognised_and_points_at_the_shebang() {
let err = LaunchError::from_io(
&OsString::from("./script"),
io::Error::from_raw_os_error(libc::ENOEXEC),
);
assert!(matches!(err, LaunchError::NoExecFormat { .. }));
assert_eq!(err.exit_code(), 126);
assert!(err.hint().is_some_and(|h| h.contains("shebang")));
}
#[cfg(windows)]
#[test]
fn a_non_image_file_is_recognised_and_points_at_the_shebang() {
let err =
LaunchError::from_io(&OsString::from("script.txt"), io::Error::from_raw_os_error(193));
assert!(matches!(err, LaunchError::NoExecFormat { .. }));
assert_eq!(err.exit_code(), 126);
assert!(err.hint().is_some_and(|h| h.contains("shebang")));
}
#[test]
fn the_message_names_the_program_but_never_a_value() {
let err = LaunchError::NotFound { program: "npm".to_owned() };
assert!(err.to_string().contains("npm"));
}
#[test]
fn a_guard_refusal_keeps_its_own_message_and_hint() {
let err = LaunchError::from(GuardError::LoaderControlled { name: "LD_PRELOAD".to_owned() });
assert!(err.to_string().contains("LD_PRELOAD"));
assert!(err.hint().is_some_and(|h| h.contains("--allow-unsafe-env")));
assert_eq!(err.exit_code(), 1);
}
#[test]
fn an_unrepresentable_argument_reports_which_one() {
let err = LaunchError::from(crate::cmdline::CmdLineError::LineBreak { index: 2 });
assert!(err.to_string().contains("argument 2"));
assert!(err.hint().is_some());
}
#[test]
fn an_invalid_key_names_the_key_and_the_reason() {
let err = LaunchError::InvalidKey {
key: "A-B".to_owned(),
source: KeyNameError::InvalidCharacter { name: "A-B".to_owned(), ch: '-' },
};
let message = err.to_string();
assert!(message.contains("A-B"));
assert!(message.contains('-'));
}
}