use std::convert::Infallible;
use std::ffi::{OsStr, OsString};
use std::io::Write as _;
use std::path::Path;
use secrecy::{ExposeSecret as _, SecretString};
use prick_core::keyname;
use crate::error::LaunchError;
use crate::guard::EnvGuard;
pub const BATCH_EXTENSIONS: [&str; 2] = ["bat", "cmd"];
pub fn is_batch_target(program: &OsStr) -> bool {
Path::new(program)
.extension()
.and_then(OsStr::to_str)
.is_some_and(|ext| BATCH_EXTENSIONS.iter().any(|b| ext.eq_ignore_ascii_case(b)))
}
#[derive(Debug)]
pub struct LaunchSpec {
argv: Vec<OsString>,
env: Vec<(String, SecretString)>,
}
impl LaunchSpec {
pub fn new(argv: Vec<OsString>) -> Result<Self, LaunchError> {
if argv.is_empty() {
return Err(LaunchError::NoProgram);
}
Ok(Self { argv, env: Vec::new() })
}
pub fn with_secrets(
mut self,
guard: EnvGuard,
secrets: impl IntoIterator<Item = (String, SecretString)>,
) -> Result<Self, LaunchError> {
for (key, value) in secrets {
keyname::validate(&key)
.map_err(|source| LaunchError::InvalidKey { key: key.clone(), source })?;
guard.check(&key)?;
self.env.push((key, value));
}
Ok(self)
}
pub fn program(&self) -> &OsStr {
self.argv.first().map_or(OsStr::new(""), OsString::as_os_str)
}
pub fn args(&self) -> &[OsString] {
self.argv.get(1..).unwrap_or(&[])
}
pub fn env_names(&self) -> impl Iterator<Item = &str> {
self.env.iter().map(|(key, _)| key.as_str())
}
fn apply_env(&self, command: &mut std::process::Command) {
for (key, value) in &self.env {
command.env(key, value.expose_secret());
}
}
}
fn flush_streams() {
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
}
pub fn run(spec: &LaunchSpec) -> Result<Infallible, LaunchError> {
flush_streams();
run_platform(spec)
}
#[cfg(unix)]
fn run_platform(spec: &LaunchSpec) -> Result<Infallible, LaunchError> {
use std::os::unix::process::CommandExt as _;
let mut command = std::process::Command::new(spec.program());
command.args(spec.args());
spec.apply_env(&mut command);
unsafe {
command.pre_exec(crate::signal::restore_default_dispositions);
}
let failure = command.exec();
Err(LaunchError::from_io(spec.program(), failure))
}
#[cfg(windows)]
fn run_platform(spec: &LaunchSpec) -> Result<Infallible, LaunchError> {
use std::os::windows::io::AsRawHandle as _;
use std::os::windows::process::CommandExt as _;
let program = spec.program();
let resolved = which::which(program)
.map_err(|_| LaunchError::NotFound { program: program.to_string_lossy().into_owned() })?;
let mut command = if is_batch_target(resolved.as_os_str()) {
let line = batch_command_line(&resolved, spec.args())?;
let mut command = std::process::Command::new(comspec());
command.raw_arg(&line);
command
} else {
let mut command = std::process::Command::new(&resolved);
command.args(spec.args());
command
};
spec.apply_env(&mut command);
crate::winjob::install_console_ctrl_handler()
.map_err(|source| LaunchError::Io { program: "prk".to_owned(), source })?;
let job = crate::winjob::Job::create_kill_on_close()
.map_err(|source| LaunchError::Io { program: "prk".to_owned(), source })?;
let mut child =
command.spawn().map_err(|source| LaunchError::from_io(resolved.as_os_str(), source))?;
let assigned = unsafe { job.assign(child.as_raw_handle()) };
if let Err(source) = assigned {
let _ = child.kill();
return Err(LaunchError::Io { program: "prk".to_owned(), source });
}
let status =
child.wait().map_err(|source| LaunchError::from_io(resolved.as_os_str(), source))?;
drop(job);
flush_streams();
#[allow(
clippy::exit,
reason = "the Unix path reaches this point via execvp, which likewise never returns"
)]
std::process::exit(crate::signal::child_exit_status(status.code(), None));
}
#[cfg(windows)]
fn comspec() -> OsString {
std::env::var_os("SystemRoot").map_or_else(
|| OsString::from(r"C:\Windows\System32\cmd.exe"),
|mut path| {
path.push(r"\System32\cmd.exe");
path
},
)
}
#[cfg(windows)]
fn batch_command_line(script: &Path, args: &[OsString]) -> Result<OsString, LaunchError> {
use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _};
let script: Vec<u16> = script.as_os_str().encode_wide().collect();
let args: Vec<Vec<u16>> = args.iter().map(|arg| arg.encode_wide().collect()).collect();
let line = crate::cmdline::batch_command_line(&script, &args)?;
Ok(OsString::from_wide(&line))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_shims_are_detected() {
for program in [r"C:\Program Files\nodejs\npm.cmd", r"C:\tools\build.bat", "pnpm.CMD"] {
assert!(is_batch_target(&OsString::from(program)), "{program} not detected");
}
}
#[test]
fn real_executables_are_not() {
for program in [r"C:\Windows\System32\where.exe", "/usr/bin/node", "node", "npm"] {
assert!(!is_batch_target(&OsString::from(program)), "{program} falsely detected");
}
}
#[test]
fn detection_is_case_insensitive_like_the_filesystem() {
assert!(is_batch_target(&OsString::from("npm.CMD")));
assert!(is_batch_target(&OsString::from("npm.Cmd")));
assert!(is_batch_target(&OsString::from("build.BAT")));
}
#[test]
fn a_dot_in_a_directory_name_does_not_trigger_detection() {
assert!(!is_batch_target(&OsString::from("/opt/my.cmd.tools/node")));
}
#[test]
fn an_empty_argv_is_refused_rather_than_producing_an_empty_program() {
assert!(matches!(LaunchSpec::new(Vec::new()), Err(LaunchError::NoProgram)));
}
#[test]
fn argv_is_split_into_a_program_and_its_arguments() {
let spec =
LaunchSpec::new(vec!["npm".into(), "test".into(), "--json".into()]).expect("non-empty");
assert_eq!(spec.program(), OsStr::new("npm"));
assert_eq!(spec.args(), [OsString::from("test"), OsString::from("--json")]);
}
#[test]
fn a_program_with_no_arguments_has_an_empty_argument_slice() {
let spec = LaunchSpec::new(vec!["true".into()]).expect("non-empty");
assert!(spec.args().is_empty());
}
#[test]
fn secrets_reach_the_environment_by_name() {
let spec = LaunchSpec::new(vec!["true".into()])
.expect("non-empty")
.with_secrets(
EnvGuard::strict(),
[
("DATABASE_URL".to_owned(), SecretString::from("postgres://x")),
("API_KEY".to_owned(), SecretString::from("k")),
],
)
.expect("both names are safe");
assert_eq!(spec.env_names().collect::<Vec<_>>(), ["DATABASE_URL", "API_KEY"]);
}
#[test]
fn a_loader_controlled_name_fails_the_whole_launch() {
let err = LaunchSpec::new(vec!["true".into()])
.expect("non-empty")
.with_secrets(
EnvGuard::strict(),
[
("SAFE".to_owned(), SecretString::from("a")),
("LD_PRELOAD".to_owned(), SecretString::from("/tmp/evil.so")),
],
)
.expect_err("LD_PRELOAD must be refused");
assert!(matches!(err, LaunchError::Guard(_)));
assert!(err.to_string().contains("LD_PRELOAD"));
}
#[test]
fn the_opt_in_lets_a_loader_controlled_name_through() {
let spec = LaunchSpec::new(vec!["true".into()])
.expect("non-empty")
.with_secrets(
EnvGuard::permissive(),
[("LD_PRELOAD".to_owned(), SecretString::from("/tmp/x.so"))],
)
.expect("permissive guard allows it");
assert_eq!(spec.env_names().collect::<Vec<_>>(), ["LD_PRELOAD"]);
}
#[test]
fn a_name_a_shell_could_not_use_is_refused_before_the_guard_sees_it() {
let err = LaunchSpec::new(vec!["true".into()])
.expect("non-empty")
.with_secrets(
EnvGuard::permissive(),
[("NOT A NAME".to_owned(), SecretString::from("v"))],
)
.expect_err("an invalid name must be refused even when the guard is permissive");
assert!(matches!(err, LaunchError::InvalidKey { .. }));
}
#[test]
fn the_debug_rendering_never_contains_a_value() {
let spec = LaunchSpec::new(vec!["true".into()])
.expect("non-empty")
.with_secrets(EnvGuard::strict(), [("TOKEN".to_owned(), SecretString::from("hunter2"))])
.expect("safe name");
let rendered = format!("{spec:?}");
assert!(rendered.contains("TOKEN"), "the key is plaintext and should be visible");
assert!(!rendered.contains("hunter2"), "a value leaked through Debug: {rendered}");
}
#[cfg(windows)]
#[test]
fn the_interpreter_comes_from_the_system_directory_not_comspec() {
let resolved = comspec().to_string_lossy().to_lowercase();
assert!(resolved.ends_with(r"\system32\cmd.exe"), "unexpected interpreter: {resolved}");
}
}