1use std::convert::Infallible;
29use std::ffi::{OsStr, OsString};
30use std::io::Write as _;
31use std::path::Path;
32
33use secrecy::{ExposeSecret as _, SecretString};
34
35use prick_core::keyname;
36
37use crate::error::LaunchError;
38use crate::guard::EnvGuard;
39
40pub const BATCH_EXTENSIONS: [&str; 2] = ["bat", "cmd"];
42
43pub fn is_batch_target(program: &OsStr) -> bool {
48 Path::new(program)
49 .extension()
50 .and_then(OsStr::to_str)
51 .is_some_and(|ext| BATCH_EXTENSIONS.iter().any(|b| ext.eq_ignore_ascii_case(b)))
52}
53
54#[derive(Debug)]
60pub struct LaunchSpec {
61 argv: Vec<OsString>,
62 env: Vec<(String, SecretString)>,
63}
64
65impl LaunchSpec {
66 pub fn new(argv: Vec<OsString>) -> Result<Self, LaunchError> {
72 if argv.is_empty() {
73 return Err(LaunchError::NoProgram);
74 }
75 Ok(Self { argv, env: Vec::new() })
76 }
77
78 pub fn with_secrets(
92 mut self,
93 guard: EnvGuard,
94 secrets: impl IntoIterator<Item = (String, SecretString)>,
95 ) -> Result<Self, LaunchError> {
96 for (key, value) in secrets {
97 keyname::validate(&key)
98 .map_err(|source| LaunchError::InvalidKey { key: key.clone(), source })?;
99 guard.check(&key)?;
100 self.env.push((key, value));
101 }
102 Ok(self)
103 }
104
105 pub fn program(&self) -> &OsStr {
107 self.argv.first().map_or(OsStr::new(""), OsString::as_os_str)
109 }
110
111 pub fn args(&self) -> &[OsString] {
113 self.argv.get(1..).unwrap_or(&[])
114 }
115
116 pub fn env_names(&self) -> impl Iterator<Item = &str> {
120 self.env.iter().map(|(key, _)| key.as_str())
121 }
122
123 fn apply_env(&self, command: &mut std::process::Command) {
128 for (key, value) in &self.env {
129 command.env(key, value.expose_secret());
130 }
131 }
132}
133
134fn flush_streams() {
141 let _ = std::io::stdout().flush();
142 let _ = std::io::stderr().flush();
143}
144
145pub fn run(spec: &LaunchSpec) -> Result<Infallible, LaunchError> {
164 flush_streams();
165 run_platform(spec)
166}
167
168#[cfg(unix)]
169fn run_platform(spec: &LaunchSpec) -> Result<Infallible, LaunchError> {
170 use std::os::unix::process::CommandExt as _;
171
172 let mut command = std::process::Command::new(spec.program());
173 command.args(spec.args());
174 spec.apply_env(&mut command);
175
176 unsafe {
186 command.pre_exec(crate::signal::restore_default_dispositions);
187 }
188
189 let failure = command.exec();
191 Err(LaunchError::from_io(spec.program(), failure))
192}
193
194#[cfg(windows)]
195fn run_platform(spec: &LaunchSpec) -> Result<Infallible, LaunchError> {
196 use std::os::windows::io::AsRawHandle as _;
197 use std::os::windows::process::CommandExt as _;
198
199 let program = spec.program();
200
201 let resolved = which::which(program)
204 .map_err(|_| LaunchError::NotFound { program: program.to_string_lossy().into_owned() })?;
205
206 let mut command = if is_batch_target(resolved.as_os_str()) {
207 let line = batch_command_line(&resolved, spec.args())?;
208 let mut command = std::process::Command::new(comspec());
209 command.raw_arg(&line);
212 command
213 } else {
214 let mut command = std::process::Command::new(&resolved);
215 command.args(spec.args());
216 command
217 };
218 spec.apply_env(&mut command);
219
220 crate::winjob::install_console_ctrl_handler()
222 .map_err(|source| LaunchError::Io { program: "prk".to_owned(), source })?;
223
224 let job = crate::winjob::Job::create_kill_on_close()
225 .map_err(|source| LaunchError::Io { program: "prk".to_owned(), source })?;
226
227 let mut child =
228 command.spawn().map_err(|source| LaunchError::from_io(resolved.as_os_str(), source))?;
229
230 let assigned = unsafe { job.assign(child.as_raw_handle()) };
238 if let Err(source) = assigned {
239 let _ = child.kill();
240 return Err(LaunchError::Io { program: "prk".to_owned(), source });
241 }
242
243 let status =
244 child.wait().map_err(|source| LaunchError::from_io(resolved.as_os_str(), source))?;
245
246 drop(job);
249 flush_streams();
250
251 #[allow(
261 clippy::exit,
262 reason = "the Unix path reaches this point via execvp, which likewise never returns"
263 )]
264 std::process::exit(crate::signal::child_exit_status(status.code(), None));
265}
266
267#[cfg(windows)]
274fn comspec() -> OsString {
275 std::env::var_os("SystemRoot").map_or_else(
276 || OsString::from(r"C:\Windows\System32\cmd.exe"),
277 |mut path| {
278 path.push(r"\System32\cmd.exe");
279 path
280 },
281 )
282}
283
284#[cfg(windows)]
286fn batch_command_line(script: &Path, args: &[OsString]) -> Result<OsString, LaunchError> {
287 use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _};
288
289 let script: Vec<u16> = script.as_os_str().encode_wide().collect();
290 let args: Vec<Vec<u16>> = args.iter().map(|arg| arg.encode_wide().collect()).collect();
291 let line = crate::cmdline::batch_command_line(&script, &args)?;
292 Ok(OsString::from_wide(&line))
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn batch_shims_are_detected() {
301 for program in [r"C:\Program Files\nodejs\npm.cmd", r"C:\tools\build.bat", "pnpm.CMD"] {
302 assert!(is_batch_target(&OsString::from(program)), "{program} not detected");
303 }
304 }
305
306 #[test]
307 fn real_executables_are_not() {
308 for program in [r"C:\Windows\System32\where.exe", "/usr/bin/node", "node", "npm"] {
309 assert!(!is_batch_target(&OsString::from(program)), "{program} falsely detected");
310 }
311 }
312
313 #[test]
314 fn detection_is_case_insensitive_like_the_filesystem() {
315 assert!(is_batch_target(&OsString::from("npm.CMD")));
316 assert!(is_batch_target(&OsString::from("npm.Cmd")));
317 assert!(is_batch_target(&OsString::from("build.BAT")));
318 }
319
320 #[test]
321 fn a_dot_in_a_directory_name_does_not_trigger_detection() {
322 assert!(!is_batch_target(&OsString::from("/opt/my.cmd.tools/node")));
323 }
324
325 #[test]
326 fn an_empty_argv_is_refused_rather_than_producing_an_empty_program() {
327 assert!(matches!(LaunchSpec::new(Vec::new()), Err(LaunchError::NoProgram)));
328 }
329
330 #[test]
331 fn argv_is_split_into_a_program_and_its_arguments() {
332 let spec =
333 LaunchSpec::new(vec!["npm".into(), "test".into(), "--json".into()]).expect("non-empty");
334 assert_eq!(spec.program(), OsStr::new("npm"));
335 assert_eq!(spec.args(), [OsString::from("test"), OsString::from("--json")]);
336 }
337
338 #[test]
339 fn a_program_with_no_arguments_has_an_empty_argument_slice() {
340 let spec = LaunchSpec::new(vec!["true".into()]).expect("non-empty");
341 assert!(spec.args().is_empty());
342 }
343
344 #[test]
345 fn secrets_reach_the_environment_by_name() {
346 let spec = LaunchSpec::new(vec!["true".into()])
347 .expect("non-empty")
348 .with_secrets(
349 EnvGuard::strict(),
350 [
351 ("DATABASE_URL".to_owned(), SecretString::from("postgres://x")),
352 ("API_KEY".to_owned(), SecretString::from("k")),
353 ],
354 )
355 .expect("both names are safe");
356
357 assert_eq!(spec.env_names().collect::<Vec<_>>(), ["DATABASE_URL", "API_KEY"]);
358 }
359
360 #[test]
361 fn a_loader_controlled_name_fails_the_whole_launch() {
362 let err = LaunchSpec::new(vec!["true".into()])
363 .expect("non-empty")
364 .with_secrets(
365 EnvGuard::strict(),
366 [
367 ("SAFE".to_owned(), SecretString::from("a")),
368 ("LD_PRELOAD".to_owned(), SecretString::from("/tmp/evil.so")),
369 ],
370 )
371 .expect_err("LD_PRELOAD must be refused");
372
373 assert!(matches!(err, LaunchError::Guard(_)));
374 assert!(err.to_string().contains("LD_PRELOAD"));
375 }
376
377 #[test]
378 fn the_opt_in_lets_a_loader_controlled_name_through() {
379 let spec = LaunchSpec::new(vec!["true".into()])
380 .expect("non-empty")
381 .with_secrets(
382 EnvGuard::permissive(),
383 [("LD_PRELOAD".to_owned(), SecretString::from("/tmp/x.so"))],
384 )
385 .expect("permissive guard allows it");
386 assert_eq!(spec.env_names().collect::<Vec<_>>(), ["LD_PRELOAD"]);
387 }
388
389 #[test]
390 fn a_name_a_shell_could_not_use_is_refused_before_the_guard_sees_it() {
391 let err = LaunchSpec::new(vec!["true".into()])
392 .expect("non-empty")
393 .with_secrets(
394 EnvGuard::permissive(),
395 [("NOT A NAME".to_owned(), SecretString::from("v"))],
396 )
397 .expect_err("an invalid name must be refused even when the guard is permissive");
398 assert!(matches!(err, LaunchError::InvalidKey { .. }));
399 }
400
401 #[test]
402 fn the_debug_rendering_never_contains_a_value() {
403 let spec = LaunchSpec::new(vec!["true".into()])
404 .expect("non-empty")
405 .with_secrets(EnvGuard::strict(), [("TOKEN".to_owned(), SecretString::from("hunter2"))])
406 .expect("safe name");
407
408 let rendered = format!("{spec:?}");
409 assert!(rendered.contains("TOKEN"), "the key is plaintext and should be visible");
410 assert!(!rendered.contains("hunter2"), "a value leaked through Debug: {rendered}");
411 }
412
413 #[cfg(windows)]
414 #[test]
415 fn the_interpreter_comes_from_the_system_directory_not_comspec() {
416 let resolved = comspec().to_string_lossy().to_lowercase();
417 assert!(resolved.ends_with(r"\system32\cmd.exe"), "unexpected interpreter: {resolved}");
418 }
419}