fix(chroot): resolve bare command names through PATH before exec
CI / Check (push) Successful in 1m11s
CI / Format (push) Successful in 15s
CI / Clippy (push) Successful in 1m8s
CI / Test (push) Successful in 1m23s

execve(2) does not search PATH, so `ecr alpine -- echo hi` failed with
ENOENT: the old code only used PATH as an existence check and then
still exec'd the bare name, which the kernel resolved relative to the
working directory.  Pre-dates the crate split, but bare names are the
natural CLI usage so it needs to work.

Programs are now resolved execvp-style: a bare name is looked up in
the caller-composed PATH (empty components skipped rather than treated
as the cwd), paths containing '/' are used as-is with a friendlier
error than a raw ENOENT.
This commit is contained in:
2026-09-21 00:16:25 +02:00
parent af06264e60
commit de507682c1
+85 -16
View File
@@ -1,7 +1,8 @@
use crate::veprintln;
use anyhow::{anyhow, Context, Result};
use nix::unistd::{chroot, execve};
use std::path::Path;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
/// Compose the default environment for a rootfs: HOME, USER, SHELL (detected
/// inside `rootfs`), TERM (inherited from the host process) and PATH.
@@ -92,32 +93,51 @@ pub fn run_chroot(
veprintln!("Entering chroot at {}", rootfs.display());
veprintln!("Working directory: {}", working_dir.display());
// Check if the program exists
if !Path::new(&program).exists() {
// Try to find it in the caller-composed PATH
let found = env.iter().find(|(k, _)| k == "PATH").and_then(|(_, path)| {
path.split(':')
.map(|p| std::path::PathBuf::from(p).join(&program))
.find(|p| p.exists())
});
if found.is_none() {
return Err(anyhow!("Program not found: {}", program));
}
}
// execve(2) does not search PATH: resolve the program through the
// caller-composed environment like execvp(3) would.
let program_path = resolve_program(&program, env)?;
// Exec the program directly with the caller-composed, isolated environment.
// execve never returns on success.
let program_cstr = std::ffi::CString::new(program.as_str()).context("Invalid program name")?;
let program_cstr = std::ffi::CString::new(program_path.as_os_str().as_bytes())
.with_context(|| format!("Invalid program path: {}", program_path.display()))?;
let result = execve(&program_cstr, &args, &env_cstrings);
match result {
Ok(_) => Ok(()), // Never reached
Err(e) => Err(anyhow!("Failed to exec {}: {}", program, e)),
Err(e) => Err(anyhow!("Failed to exec {}: {}", program_path.display(), e)),
}
}
/// Resolve the program to exec. Commands containing a '/' are used as-is
/// (existing check gives a clearer error than a raw ENOENT); a bare command
/// name is looked up in the caller-composed PATH, like execvp(3) would.
fn resolve_program(program: &str, env: &[(String, String)]) -> Result<PathBuf> {
if program.contains('/') {
let path = PathBuf::from(program);
if !path.exists() {
return Err(anyhow!("Program not found: {}", program));
}
return Ok(path);
}
let path_var = env
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.as_str())
.unwrap_or("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
// Empty PATH components are skipped rather than treated as the current
// directory (execvp semantics) — cwd-relative lookup is a footgun here.
path_var
.split(':')
.filter(|dir| !dir.is_empty())
.map(|dir| PathBuf::from(dir).join(program))
.find(|candidate| candidate.exists())
.ok_or_else(|| anyhow!("Program not found: {} (PATH={})", program, path_var))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -126,6 +146,55 @@ mod tests {
env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
#[test]
fn resolve_program_bare_name_via_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("true"), b"binary").unwrap();
let env = vec![(
"PATH".to_string(),
format!("/nonexistent:{}", dir.path().display()),
)];
assert_eq!(
resolve_program("true", &env).unwrap(),
dir.path().join("true")
);
}
#[test]
fn resolve_program_bare_name_defaults_to_standard_path() {
// No PATH in the composed env: the standard fallback must still
// resolve a program present on any Linux host.
let resolved = resolve_program("sh", &[]).unwrap();
assert!(resolved.exists());
}
#[test]
fn resolve_program_bare_name_missing() {
let env = vec![("PATH".to_string(), "/nonexistent".to_string())];
assert!(resolve_program("definitely-missing", &env).is_err());
}
#[test]
fn resolve_program_absolute_path_passthrough() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("tool"), b"binary").unwrap();
let path = dir.path().join("tool");
assert_eq!(resolve_program(path.to_str().unwrap(), &[]).unwrap(), path);
}
#[test]
fn resolve_program_absolute_path_missing() {
assert!(resolve_program("/nonexistent/tool", &[]).is_err());
}
#[test]
fn resolve_program_skips_empty_path_components() {
// An empty PATH component must not mean "current directory";
// Cargo.toml exists in the test process cwd and must not resolve.
let env = vec![("PATH".to_string(), ":".to_string())];
assert!(resolve_program("Cargo.toml", &env).is_err());
}
#[test]
fn test_default_env_entries() {
let dir = tempfile::tempdir().unwrap();