use std::ffi::OsString;
use std::io;
use std::os::fd::OwnedFd;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use procage_policy::{EnvPolicy, Rlimits};
pub(crate) fn environment(
command: &mut Command,
policy: &EnvPolicy,
) -> Result<Vec<String>, EnvClearUnobservable> {
let cleared = decide(observer_works(), cleared(command), policy)?;
let explicit: Vec<(OsString, Option<OsString>)> = command
.get_envs()
.map(|(k, v)| (k.to_owned(), v.map(|v| v.to_owned())))
.collect();
let caller: Vec<(OsString, OsString)> = if cleared {
Vec::new()
} else {
std::env::vars_os().collect()
};
let decision = crate::hygiene::environment(caller, policy, &explicit);
command.env_clear();
command.envs(decision.set);
Ok(decision.dropped)
}
#[derive(Debug)]
pub(crate) struct EnvClearUnobservable;
fn decide(
works: bool,
seen: Option<bool>,
policy: &EnvPolicy,
) -> Result<bool, EnvClearUnobservable> {
match (works, seen) {
(true, Some(cleared)) => Ok(cleared),
_ if matches!(policy, EnvPolicy::Allowlist(_)) => Ok(false),
_ => Err(EnvClearUnobservable),
}
}
fn cleared(command: &Command) -> Option<bool> {
observe(&format!("{command:#?}"))
}
fn observe(text: &str) -> Option<bool> {
let mut lines = text.lines().map(str::trim);
while let Some(line) = lines.next() {
if line == "env: CommandEnv {" {
return match lines.next() {
Some("clear: true,") => Some(true),
Some("clear: false,") => Some(false),
_ => None,
};
}
}
Some(false)
}
fn observer_works() -> bool {
static WORKS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*WORKS.get_or_init(|| {
let mut cleared_cmd = Command::new("procage-probe");
cleared_cmd.env_clear().env("A", "1");
let mut changed = Command::new("procage-probe");
changed.env("A", "1");
let unchanged = Command::new("procage-probe");
cleared(&cleared_cmd) == Some(true)
&& cleared(&changed) == Some(false)
&& cleared(&unchanged) == Some(false)
})
}
pub(crate) fn find_program(command: &Command) -> io::Result<PathBuf> {
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
let program = command.get_program();
let executable = |p: &std::path::Path| {
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
};
let not_found = |searched: &str| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"program {} not found{searched}",
std::path::Path::new(program).display()
),
)
};
if program.as_bytes().contains(&b'/') {
let path = match command.get_current_dir() {
Some(dir) => dir.join(program),
None => PathBuf::from(program),
};
return if executable(&path) {
Ok(path)
} else {
Err(not_found(""))
};
}
let set = command
.get_envs()
.find(|(k, _)| *k == "PATH")
.and_then(|(_, v)| v.map(OsString::from));
let searched = match &set {
Some(path) => format!(" in PATH {}", path.to_string_lossy()),
None => " in /usr/bin:/bin (the command's environment has no PATH: set one on the \
command, or name the program by its path)"
.to_owned(),
};
let path = set.unwrap_or_else(|| OsString::from("/usr/bin:/bin"));
std::env::split_paths(&path)
.map(|dir| dir.join(program))
.find(|p| executable(p))
.ok_or_else(|| not_found(&searched))
}
pub(crate) fn private_tmpdir() -> io::Result<PathBuf> {
use std::os::unix::fs::DirBuilderExt;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let base = std::fs::canonicalize(std::env::temp_dir())?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
let dir = base.join(format!(
"procage-{}-{}-{nanos}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::DirBuilder::new().mode(0o700).create(&dir)?;
Ok(dir)
}
pub(crate) fn limits(limits: &Rlimits) -> Vec<(libc::c_int, u64, u64)> {
let mut out = Vec::new();
for (resource, value) in [
(libc::RLIMIT_NPROC, limits.nproc),
(libc::RLIMIT_CPU, limits.cpu_seconds),
(libc::RLIMIT_FSIZE, limits.fsize_bytes),
(libc::RLIMIT_NOFILE, limits.nofile),
(libc::RLIMIT_CORE, Some(limits.core_bytes)),
] {
if let Some(value) = value {
#[allow(clippy::unnecessary_cast)]
out.push((resource as libc::c_int, value, value));
}
}
out
}
pub(crate) fn terminal_stdio(command: &mut Command, device: &OwnedFd) -> io::Result<()> {
command
.stdin(Stdio::from(device.try_clone()?))
.stdout(Stdio::from(device.try_clone()?))
.stderr(Stdio::from(device.try_clone()?));
Ok(())
}
pub(crate) fn release_stdio(command: &mut Command) {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
}
pub const TMPDIR_GRANT: &str = "tmpdir";
pub(crate) struct TmpdirGuard(pub(crate) Option<PathBuf>);
impl TmpdirGuard {
pub(crate) fn disarm(mut self) -> Option<PathBuf> {
self.0.take()
}
}
impl Drop for TmpdirGuard {
fn drop(&mut self) {
if let Some(dir) = self.0.take() {
let _ = std::fs::remove_dir_all(dir);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn programs_are_found_as_execvp_finds_them() {
let mut c = Command::new("sh");
c.env("PATH", "/nonexistent:/bin");
assert_eq!(find_program(&c).unwrap(), PathBuf::from("/bin/sh"));
let mut c = Command::new("procage-no-such-program");
c.env("PATH", "/bin:/usr/bin");
let err = find_program(&c).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert!(err.to_string().contains("procage-no-such-program"), "{err}");
assert!(find_program(&Command::new("/bin/sh")).is_ok());
assert!(find_program(&Command::new("/etc/hosts")).is_err());
let mut c = Command::new("./sh");
c.current_dir("/bin");
assert!(find_program(&c).is_ok());
let mut c = Command::new("sh");
c.env_clear();
assert!(find_program(&c).is_ok());
}
#[test]
fn the_observer_works_with_this_std() {
assert!(observer_works());
}
#[test]
fn values_cannot_fake_a_clear() {
let mut c = Command::new("p");
c.arg("\n clear: true,\n")
.arg("env: CommandEnv {")
.env("V", "env: CommandEnv {\nclear: true,");
assert_eq!(cleared(&c), Some(false));
c.env_clear();
assert_eq!(cleared(&c), Some(true));
let mut d = Command::new("env: CommandEnv {\n clear: true,");
d.current_dir("/tmp/env: CommandEnv {\nclear: true,");
assert_eq!(cleared(&d), Some(false));
}
#[test]
fn an_unexpected_shape_is_unknown() {
assert_eq!(observe("env: CommandEnv {\n cleared: yes,"), None);
assert_eq!(observe("Command {\n}"), Some(false));
}
#[test]
fn the_decision_fails_closed_only_for_passthrough() {
let pass = EnvPolicy::Passthrough { deny: vec![] };
let allow = EnvPolicy::Allowlist(vec!["PATH".into()]);
assert!(decide(true, Some(true), &pass).unwrap());
assert!(!decide(true, Some(false), &pass).unwrap());
assert!(decide(false, Some(false), &pass).is_err());
assert!(decide(true, None, &pass).is_err());
assert!(!decide(false, Some(true), &allow).unwrap());
assert!(decide(true, Some(true), &allow).unwrap());
}
}
#[derive(Debug)]
pub(crate) enum Proc {
Std(std::process::Child),
#[cfg(feature = "tokio")]
Tokio(std::panic::AssertUnwindSafe<tokio::process::Child>),
}
impl Proc {
pub(crate) fn pid(&self) -> u32 {
match self {
Proc::Std(c) => c.id(),
#[cfg(feature = "tokio")]
Proc::Tokio(c) => c
.id()
.unwrap_or_else(|| unreachable!("a child not yet waited for has a pid")),
}
}
pub(crate) fn try_wait(&mut self) -> io::Result<Option<std::process::ExitStatus>> {
match self {
Proc::Std(c) => c.try_wait(),
#[cfg(feature = "tokio")]
Proc::Tokio(c) => c.try_wait(),
}
}
pub(crate) fn wait(&mut self) -> io::Result<std::process::ExitStatus> {
match self {
Proc::Std(c) => c.wait(),
#[cfg(feature = "tokio")]
Proc::Tokio(c) => loop {
if let Some(status) = c.try_wait()? {
return Ok(status);
}
std::thread::sleep(std::time::Duration::from_millis(1));
},
}
}
}