Skip to main content

prick_exec/
launch.rs

1//! Resolving the program, building the environment, and starting the child.
2//!
3//! # Unix replaces, Windows supervises
4//!
5//! On Unix `prk run` calls `execvp` and **becomes** the child. Nothing forwards
6//! signals, nothing translates exit codes, and `SIGTSTP`/`SIGCONT` job control
7//! works because there is no longer a `prk` in the process tree to get it
8//! wrong. The only correctness work is in what is inherited across the
9//! `exec` -- see [`crate::signal`].
10//!
11//! Windows has no `exec`, so there `prk` spawns and waits. That reintroduces
12//! the two problems Unix does not have, and both are handled explicitly:
13//! orphaned grandchildren (a job object with `KILL_ON_JOB_CLOSE`) and Ctrl-C
14//! arriving at `prk` rather than at the child (a console control handler).
15//!
16//! # Argv is never a string
17//!
18//! `clap`'s `trailing_var_arg` hands over a `Vec<OsString>` and it reaches
19//! `Command::args` unchanged, which passes it to `execvp` as a vector. There is
20//! no command line to quote and none to parse back, so the entire class of
21//! shell-quoting bugs is structurally absent and non-UTF-8 arguments survive
22//! byte for byte.
23//!
24//! The one exception is a Windows batch shim, where `cmd.exe` genuinely does
25//! interpose a string. That is [`crate::cmdline`], and it is the only place in
26//! this crate where an argument is escaped rather than passed.
27
28use 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
40/// Executable extensions that `cmd.exe` must interpret rather than the loader.
41pub const BATCH_EXTENSIONS: [&str; 2] = ["bat", "cmd"];
42
43/// Whether a resolved program path must be run through `cmd.exe`.
44///
45/// The comparison is case-insensitive: Windows filesystems are, and `NPM.CMD`
46/// is the same shim as `npm.cmd`.
47pub 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/// Everything needed to start a child process.
55///
56/// Secret values are held as [`SecretString`] right up to the moment they are
57/// handed to the process API, so no intermediate copy is formattable and the
58/// derived `Debug` prints the redaction rather than the value.
59#[derive(Debug)]
60pub struct LaunchSpec {
61    argv: Vec<OsString>,
62    env: Vec<(String, SecretString)>,
63}
64
65impl LaunchSpec {
66    /// Builds a spec from a captured argv.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`LaunchError::NoProgram`] if `argv` is empty.
71    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    /// Adds secrets to the child's environment, applying the guard first.
79    ///
80    /// Every name is validated with [`prick_core::keyname::validate`] and then
81    /// checked against `guard`. The whole launch fails on the first refusal
82    /// rather than dropping the offending variable: a child started with a
83    /// silently missing variable is a debugging problem, and a child started
84    /// with a silently *present* one is a breach.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`LaunchError::InvalidKey`] for a name that is not usable as an
89    /// environment variable, and [`LaunchError::Guard`] for one the dynamic
90    /// loader or a language runtime interprets.
91    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    /// The program to run, as the user wrote it.
106    pub fn program(&self) -> &OsStr {
107        // `new` rejects an empty argv, so this cannot be absent.
108        self.argv.first().map_or(OsStr::new(""), OsString::as_os_str)
109    }
110
111    /// The program's arguments, excluding the program itself.
112    pub fn args(&self) -> &[OsString] {
113        self.argv.get(1..).unwrap_or(&[])
114    }
115
116    /// The names of the variables that will be added to the environment.
117    ///
118    /// Names are plaintext by design and safe to show; values are not exposed.
119    pub fn env_names(&self) -> impl Iterator<Item = &str> {
120        self.env.iter().map(|(key, _)| key.as_str())
121    }
122
123    /// Applies the environment to a command.
124    ///
125    /// The single point at which a secret is exposed, and the last thing that
126    /// happens before the process API takes ownership of it.
127    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
134/// Flushes both standard streams.
135///
136/// On Unix the process is about to be replaced, so anything still sitting in a
137/// userspace buffer would be lost outright. On Windows the child is about to
138/// write to the same console, so an unflushed buffer would appear after the
139/// child's output rather than before it.
140fn flush_streams() {
141    let _ = std::io::stdout().flush();
142    let _ = std::io::stderr().flush();
143}
144
145/// Becomes the child. **Never returns on success.**
146///
147/// On Unix that is literally true: `execvp` replaces the process image, so the
148/// only way out is an error. On Windows there is no `exec`, so this waits for
149/// the child and then exits with its status -- which makes the two platforms
150/// indistinguishable to a caller, and is why the success type is
151/// [`Infallible`].
152///
153/// The alternative -- returning the status for the caller to propagate -- looks
154/// tidier and is not: the caller would have to reproduce the exit status
155/// exactly through its own error type, and any mapping that cannot represent
156/// 126, 127 or a status above 255 silently changes what a script sees.
157///
158/// # Errors
159///
160/// See [`LaunchError`]. The three cases a caller is expected to distinguish are
161/// [`LaunchError::NotFound`] (exit 127), [`LaunchError::PermissionDenied`] and
162/// [`LaunchError::NoExecFormat`] (both exit 126).
163pub 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    // SAFETY: the closure runs in the forked child, between `fork` and `exec`,
177    // where only async-signal-safe calls are permitted. It calls exactly
178    // `signal(2)` and `sigprocmask(2)`, both of which are on the POSIX
179    // async-signal-safe list, and it allocates nothing, locks nothing and
180    // formats nothing.
181    //
182    // The `io::Error` it can return is constructed from `errno` by
183    // `last_os_error`, which is a read of thread-local storage; std itself
184    // relies on that being safe in the same position.
185    unsafe {
186        command.pre_exec(crate::signal::restore_default_dispositions);
187    }
188
189    // `exec` only returns on failure.
190    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    // PATHEXT-aware, so `npm` resolves to `npm.cmd`. std's own resolution only
202    // ever appends `.exe`, which is why `Command::new("npm")` fails outright.
203    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        // Raw, because the string is already escaped for cmd.exe's parser and
210        // re-quoting it for the argv parser would break both.
211        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    // Installed before the spawn so a Ctrl-C during startup is already handled.
221    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    // There is a window between spawn and assignment in which the child could
231    // create a grandchild outside the job. Closing it needs CREATE_SUSPENDED
232    // and a handle to the initial thread, which std does not expose. The
233    // exposure is microseconds at process start, before the child's own `main`
234    // has run.
235    // SAFETY: `child` owns the process handle and is alive across the call, so
236    // the handle is live and carries the access rights a `spawn` produces.
237    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    // Dropping `job` closes the last handle, which terminates anything the
247    // child left behind. That is the whole point of KILL_ON_JOB_CLOSE.
248    drop(job);
249    flush_streams();
250
251    // Exiting here rather than returning the status is what makes this platform
252    // behave like the Unix one. `clippy::exit` is denied workspace-wide so that
253    // destructors run and a token buffer is zeroized on the way out -- and this
254    // is the one place that reasoning does not apply, because the Unix path
255    // reaches the same point by calling `execvp`, which does not run
256    // destructors either. Behaving differently on Windows would be the bug.
257    //
258    // A Windows exit status is a full u32 and can exceed 255, so it is passed
259    // through as the OS reported it rather than narrowed.
260    #[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/// The `cmd.exe` to run a batch shim with.
268///
269/// Deliberately not `%COMSPEC%`. That variable is writable by anything in the
270/// process's environment, and this is a secrets manager launching a child with
271/// secrets in its environment; taking the interpreter from an attacker-writable
272/// variable would hand over the whole point of the guard in [`crate::guard`].
273#[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/// Builds the raw `cmd.exe` argument string for a batch shim.
285#[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}