fix(chroot): resolve bare command names through PATH before exec
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:
+85
-16
@@ -1,7 +1,8 @@
|
|||||||
use crate::veprintln;
|
use crate::veprintln;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use nix::unistd::{chroot, execve};
|
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
|
/// Compose the default environment for a rootfs: HOME, USER, SHELL (detected
|
||||||
/// inside `rootfs`), TERM (inherited from the host process) and PATH.
|
/// 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!("Entering chroot at {}", rootfs.display());
|
||||||
veprintln!("Working directory: {}", working_dir.display());
|
veprintln!("Working directory: {}", working_dir.display());
|
||||||
|
|
||||||
// Check if the program exists
|
// execve(2) does not search PATH: resolve the program through the
|
||||||
if !Path::new(&program).exists() {
|
// caller-composed environment like execvp(3) would.
|
||||||
// Try to find it in the caller-composed PATH
|
let program_path = resolve_program(&program, env)?;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exec the program directly with the caller-composed, isolated environment.
|
// Exec the program directly with the caller-composed, isolated environment.
|
||||||
// execve never returns on success.
|
// 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);
|
let result = execve(&program_cstr, &args, &env_cstrings);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => Ok(()), // Never reached
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -126,6 +146,55 @@ mod tests {
|
|||||||
env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
|
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]
|
#[test]
|
||||||
fn test_default_env_entries() {
|
fn test_default_env_entries() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user