anodizer_core/user_command.rs
1//! Spawn a user-supplied command (e.g. `publisher.cmd`) with a clean,
2//! whitelisted environment.
3//!
4//! Centralised here so the `Command::new(<arbitrary>)` shell-out lives
5//! inside the module-boundaries allow-list. Inlining this in the CLI
6//! crate would put `Command::new` outside the allow-list and counts
7//! as a boundary violation.
8
9use std::ffi::OsStr;
10use std::process::Command;
11
12use anyhow::Result;
13
14/// Environment variables that are inherited from the parent process
15/// when constructing a sandboxed `Command`. Anything else must be
16/// explicitly added via `Command::env`.
17///
18/// This whitelist exists to prevent accidental leakage of release
19/// credentials (`GITHUB_TOKEN`, `COSIGN_*`, signing keys, etc.) into
20/// arbitrary user-supplied commands.
21pub const ENV_WHITELIST: &[&str] = &[
22 "HOME",
23 "USER",
24 "USERPROFILE",
25 "TMPDIR",
26 "TMP",
27 "TEMP",
28 "PATH",
29 "SYSTEMROOT",
30];
31
32/// Construct a `Command` whose argv is `argv` and whose environment is
33/// reset to the [`ENV_WHITELIST`] subset of the parent's env. The first
34/// element of `argv` is the program; the rest are arguments. The caller
35/// is responsible for adding any further env vars / cwd / I/O config
36/// before invoking `output()`.
37///
38/// Returns `Err` when `argv` is empty — surfacing a clear error at the
39/// allow-listed boundary is preferable to deferring failure to the
40/// kernel via an empty `program` path.
41pub fn whitelisted<S: AsRef<OsStr>>(argv: &[S]) -> Result<Command> {
42 whitelisted_with_env(argv, &crate::ProcessEnvSource)
43}
44
45/// [`EnvSource`](crate::EnvSource)-injecting form of [`whitelisted`].
46///
47/// The [`ENV_WHITELIST`] subset is copied from `env` rather than the process
48/// environment, so tests can prove the forward-whitelisted / drop-everything-
49/// else contract against a closed fixture map without mutating global env.
50pub fn whitelisted_with_env<S: AsRef<OsStr>, E: crate::EnvSource + ?Sized>(
51 argv: &[S],
52 env: &E,
53) -> Result<Command> {
54 anyhow::ensure!(!argv.is_empty(), "user command argv cannot be empty");
55 let program = argv[0].as_ref();
56 let mut cmd = Command::new(program);
57 if argv.len() > 1 {
58 cmd.args(&argv[1..]);
59 }
60 cmd.env_clear();
61 for key in ENV_WHITELIST {
62 if let Some(val) = env.var(key) {
63 cmd.env(key, val);
64 }
65 }
66 Ok(cmd)
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use std::collections::HashMap;
73 use std::ffi::OsString;
74
75 /// Collect the `Command`'s configured env overrides into a map. A
76 /// `None` value means the key is explicitly removed; `Some(v)` means
77 /// it is set to `v`. After `env_clear`, an unset whitelist key never
78 /// appears at all (no override entry is added in the loop above).
79 fn env_map(cmd: &Command) -> HashMap<OsString, Option<OsString>> {
80 cmd.get_envs()
81 .map(|(k, v)| (k.to_owned(), v.map(|v| v.to_owned())))
82 .collect()
83 }
84
85 #[test]
86 fn empty_argv_is_rejected() {
87 let argv: &[&str] = &[];
88 let err = whitelisted(argv).unwrap_err();
89 assert!(
90 err.to_string().contains("argv cannot be empty"),
91 "unexpected error: {err}"
92 );
93 }
94
95 #[test]
96 fn single_element_argv_sets_program_with_no_args() {
97 let cmd = whitelisted(&["echo"]).expect("single-element argv is valid");
98 assert_eq!(cmd.get_program(), OsStr::new("echo"));
99 assert_eq!(cmd.get_args().count(), 0);
100 }
101
102 #[test]
103 fn multi_element_argv_splits_program_and_args() {
104 let cmd =
105 whitelisted(&["git", "tag", "-a", "v1.0.0"]).expect("multi-element argv is valid");
106 assert_eq!(cmd.get_program(), OsStr::new("git"));
107 let args: Vec<_> = cmd.get_args().collect();
108 assert_eq!(
109 args,
110 vec![OsStr::new("tag"), OsStr::new("-a"), OsStr::new("v1.0.0")]
111 );
112 }
113
114 #[test]
115 fn whitelisted_env_is_inherited_non_whitelisted_is_dropped() {
116 // A closed fixture env proves the forwarding contract without touching
117 // process env: PATH is whitelisted and must forward verbatim; the
118 // credential-shaped key is not whitelisted and must be dropped.
119 let env = crate::MapEnvSource::new()
120 .with("PATH", "/fixture/bin")
121 .with("ANODIZER_SECRET_TOKEN", "leak-me");
122 let cmd = whitelisted_with_env(&["true"], &env).expect("valid argv");
123 let envs = env_map(&cmd);
124
125 // A whitelisted key present in the parent env is forwarded verbatim.
126 assert_eq!(
127 envs.get(OsStr::new("PATH")),
128 Some(&Some(OsString::from("/fixture/bin"))),
129 "PATH should be inherited from the whitelist verbatim"
130 );
131 // A non-whitelisted key (credential-shaped) must not leak through.
132 assert!(
133 !envs.contains_key(OsStr::new("ANODIZER_SECRET_TOKEN")),
134 "non-whitelisted env must be dropped, got: {envs:?}"
135 );
136 }
137
138 #[test]
139 fn unset_whitelist_key_adds_no_override_entry() {
140 // A fixture env with no USERPROFILE entry: the whitelist key is unset.
141 let env = crate::MapEnvSource::new();
142 let cmd = whitelisted_with_env(&["true"], &env).expect("valid argv");
143 let envs = env_map(&cmd);
144 // An unset whitelist key is skipped entirely (the loop only adds an
145 // override when the source returns a value), so it must not appear
146 // even as a removal entry.
147 assert!(
148 !envs.contains_key(OsStr::new("USERPROFILE")),
149 "unset whitelist key should add no override entry"
150 );
151 }
152}