diff --git a/crates/ecr/src/chroot.rs b/crates/ecr/src/chroot.rs index 678f1be..255e067 100644 --- a/crates/ecr/src/chroot.rs +++ b/crates/ecr/src/chroot.rs @@ -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 { + 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();