Compare commits

..

4 Commits

+352 -12
View File
@@ -1,10 +1,10 @@
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use std::collections::{BTreeMap, HashMap};
use std::ffi::OsStr;
use std::fs;
use std::io::Write;
use std::io::{ErrorKind, Write};
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::{Path, PathBuf};
@@ -22,6 +22,14 @@ const DEFAULT_JAVA_CACERTS_LINK: &str = "/etc/ssl/certs/java/cacerts";
const DEFAULT_PKI_TLS_CA_BUNDLE_CRT_LINK: &str = "/etc/pki/tls/certs/ca-bundle.crt";
const DEFAULT_PKI_TLS_CA_BUNDLE_TRUST_CRT_LINK: &str = "/etc/pki/tls/certs/ca-bundle.trust.crt";
const DEFAULT_PKI_TLS_JAVA_CACERTS_LINK: &str = "/etc/pki/tls/java/cacerts";
const TARGET_COMMAND_FALLBACK_DIRS: &[&str] = &[
"/usr/local/sbin",
"/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
"/bin",
];
const DEFAULT_CERTDATA_URL: &str =
"https://hg.mozilla.org/projects/nss/raw-file/tip/lib/ckfw/builtins/certdata.txt";
const DEFAULT_CERTDATA_OUTPUT: &str = "certdata.txt";
@@ -805,7 +813,9 @@ fn openssl_https_get_text(url: &str) -> Result<String> {
let mut cmd = Command::new("openssl");
cmd.args(openssl_s_client_args(&bootstrap_ca_path, host, port));
if Path::new(DEFAULT_SSL_CERTS_DIR).is_dir() {
cmd.args(["-verifyCApath", DEFAULT_SSL_CERTS_DIR]);
cmd.args(openssl_s_client_capath_args(Path::new(
DEFAULT_SSL_CERTS_DIR,
)));
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -855,6 +865,10 @@ fn openssl_s_client_args(bootstrap_ca_path: &Path, host: &str, port: u16) -> Vec
]
}
fn openssl_s_client_capath_args(ca_path: &Path) -> Vec<String> {
vec!["-CApath".to_string(), ca_path.display().to_string()]
}
fn write_bootstrap_ca_tempfile() -> Result<PathBuf> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -2067,19 +2081,22 @@ fn run_target_command(
dry_run: bool,
envs: &[(&str, &str)],
) -> Result<()> {
let resolved_program = resolve_target_program(root, program);
let resolved_program_display = resolved_program.display().to_string();
if dry_run {
print_command_preview(root, program, args, envs);
print_command_preview(root, &resolved_program_display, args, envs);
return Ok(());
}
// Execute directly on the host or via chroot for image builds.
let mut cmd = if is_root_fs(root) {
let mut cmd = Command::new(program);
let mut cmd = Command::new(&resolved_program);
cmd.args(args);
cmd
} else {
let mut cmd = Command::new("chroot");
cmd.arg(root).arg(program).args(args);
cmd.arg(root).arg(&resolved_program).args(args);
cmd
};
@@ -2088,12 +2105,12 @@ fn run_target_command(
cmd.env(k, v);
}
let output = cmd.output().with_context(|| {
if is_root_fs(root) {
format!("failed to execute `{}`", program)
} else {
format!("failed to execute `chroot {} {}`", root.display(), program)
}
// `Command::output()` defaults stdin to `/dev/null`. Minimal chroots may
// not have device nodes yet, so inherit stdin while still capturing output.
cmd.stdin(Stdio::inherit());
let output = cmd.output().map_err(|err| {
command_spawn_error(root, &resolved_program, &resolved_program_display, err)
})?;
if output.status.success() {
@@ -2132,6 +2149,241 @@ fn run_target_command(
);
}
fn command_spawn_error(
root: &Path,
resolved_program: &Path,
resolved_program_display: &str,
err: std::io::Error,
) -> anyhow::Error {
let mut message = if is_root_fs(root) {
format!("failed to execute `{resolved_program_display}`: {err}")
} else {
format!(
"failed to execute `chroot {} {}`: {err}",
root.display(),
resolved_program_display
)
};
if err.kind() == ErrorKind::NotFound {
message.push_str(&exec_not_found_diagnostics(root, resolved_program));
}
anyhow!(message)
}
fn exec_not_found_diagnostics(root: &Path, resolved_program: &Path) -> String {
let program_host_path = path_in_root(root, resolved_program);
let mut details = vec![
String::new(),
"exec diagnostics for ENOENT:".to_string(),
format!(" target program path: {}", resolved_program.display()),
format!(" host-visible path: {}", program_host_path.display()),
];
match fs::symlink_metadata(&program_host_path) {
Ok(meta) => {
details.push(format!(
" target exists: yes (mode {:o}, {} bytes)",
meta.permissions().mode() & 0o7777,
meta.len()
));
}
Err(stat_err) => {
details.push(format!(" target exists: no ({stat_err})"));
return details.join("\n");
}
}
match fs::read(&program_host_path) {
Ok(bytes) => {
if let Some(interpreter) = shebang_interpreter(&bytes) {
details.push(format!(" script interpreter: {interpreter}"));
details.push(format!(
" script interpreter exists: {}",
path_existence_description(root, Path::new(&interpreter))
));
} else if is_elf(&bytes) {
details.push(" file format: ELF".to_string());
match elf_interpreter(&bytes) {
Some(interpreter) => {
details.push(format!(" ELF interpreter: {interpreter}"));
details.push(format!(
" ELF interpreter exists: {}",
path_existence_description(root, Path::new(&interpreter))
));
}
None => details.push(" ELF interpreter: none found".to_string()),
}
} else {
details.push(" file format: not ELF and no shebang".to_string());
}
}
Err(read_err) => details.push(format!(" failed to read target: {read_err}")),
}
details.join("\n")
}
fn path_existence_description(root: &Path, target_path: &Path) -> String {
if !target_path.is_absolute() {
return "not checked (interpreter path is not absolute)".to_string();
}
let host_path = path_in_root(root, target_path);
match fs::symlink_metadata(&host_path) {
Ok(meta) => format!(
"yes at {} (mode {:o}, {} bytes)",
host_path.display(),
meta.permissions().mode() & 0o7777,
meta.len()
),
Err(err) => format!("no at {} ({err})", host_path.display()),
}
}
fn shebang_interpreter(bytes: &[u8]) -> Option<String> {
if !bytes.starts_with(b"#!") {
return None;
}
let line_end = bytes
.iter()
.position(|b| *b == b'\n')
.unwrap_or(bytes.len());
let line = std::str::from_utf8(&bytes[2..line_end]).ok()?.trim();
let interpreter = line.split_whitespace().next()?;
Some(interpreter.to_string())
}
fn is_elf(bytes: &[u8]) -> bool {
bytes.starts_with(b"\x7fELF")
}
fn elf_interpreter(bytes: &[u8]) -> Option<String> {
if !is_elf(bytes) || bytes.len() < 16 {
return None;
}
let class = bytes[4];
let data = bytes[5];
let little_endian = match data {
1 => true,
2 => false,
_ => return None,
};
let (phoff, phentsize, phnum) = match class {
1 => (
read_u32(bytes, 28, little_endian)? as usize,
read_u16(bytes, 42, little_endian)? as usize,
read_u16(bytes, 44, little_endian)? as usize,
),
2 => (
read_u64(bytes, 32, little_endian)? as usize,
read_u16(bytes, 54, little_endian)? as usize,
read_u16(bytes, 56, little_endian)? as usize,
),
_ => return None,
};
for index in 0..phnum {
let offset = phoff.checked_add(index.checked_mul(phentsize)?)?;
let header = bytes.get(offset..offset.checked_add(phentsize)?)?;
let p_type = read_u32(header, 0, little_endian)?;
if p_type != 3 {
continue;
}
let (interp_offset, interp_size) = match class {
1 => (
read_u32(header, 4, little_endian)? as usize,
read_u32(header, 16, little_endian)? as usize,
),
2 => (
read_u64(header, 8, little_endian)? as usize,
read_u64(header, 32, little_endian)? as usize,
),
_ => return None,
};
let interp_end = interp_offset.checked_add(interp_size)?;
let raw = bytes.get(interp_offset..interp_end)?;
let nul = raw.iter().position(|b| *b == 0).unwrap_or(raw.len());
return std::str::from_utf8(&raw[..nul]).ok().map(ToOwned::to_owned);
}
None
}
fn read_u16(bytes: &[u8], offset: usize, little_endian: bool) -> Option<u16> {
let raw: [u8; 2] = bytes.get(offset..offset.checked_add(2)?)?.try_into().ok()?;
Some(if little_endian {
u16::from_le_bytes(raw)
} else {
u16::from_be_bytes(raw)
})
}
fn read_u32(bytes: &[u8], offset: usize, little_endian: bool) -> Option<u32> {
let raw: [u8; 4] = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?;
Some(if little_endian {
u32::from_le_bytes(raw)
} else {
u32::from_be_bytes(raw)
})
}
fn read_u64(bytes: &[u8], offset: usize, little_endian: bool) -> Option<u64> {
let raw: [u8; 8] = bytes.get(offset..offset.checked_add(8)?)?.try_into().ok()?;
Some(if little_endian {
u64::from_le_bytes(raw)
} else {
u64::from_be_bytes(raw)
})
}
fn resolve_target_program(root: &Path, program: &str) -> PathBuf {
let path_env = std::env::var_os("PATH");
resolve_target_program_with_path(root, program, path_env.as_deref())
}
fn resolve_target_program_with_path(
root: &Path,
program: &str,
path_env: Option<&OsStr>,
) -> PathBuf {
if program.contains('/') {
return PathBuf::from(program);
}
for candidate in target_command_search_candidates(program, path_env) {
if path_in_root(root, &candidate).exists() {
return candidate;
}
}
PathBuf::from(program)
}
fn target_command_search_candidates(program: &str, path_env: Option<&OsStr>) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path_env) = path_env {
for dir in std::env::split_paths(path_env).filter(|dir| dir.is_absolute()) {
candidates.push(dir.join(program));
}
}
for dir in TARGET_COMMAND_FALLBACK_DIRS {
let candidate = Path::new(dir).join(program);
if !candidates.iter().any(|existing| existing == &candidate) {
candidates.push(candidate);
}
}
candidates
}
fn print_command_preview(root: &Path, program: &str, args: &[String], envs: &[(&str, &str)]) {
// Keep dry-run output close to the real shell command for easier debugging.
let env_prefix = envs
@@ -2272,6 +2524,86 @@ CKA_TRUST_SERVER_AUTH CK_TRUST CKT_NSS_TRUSTED_DELEGATOR
assert!(!is_root_fs(Path::new("/mnt/rootfs")));
}
#[test]
fn target_command_candidates_use_absolute_path_entries_and_fallbacks() {
let candidates = target_command_search_candidates(
"trust",
Some(std::ffi::OsStr::new("/custom/bin:relative:/usr/bin")),
);
assert_eq!(candidates[0], PathBuf::from("/custom/bin/trust"));
assert_eq!(candidates[1], PathBuf::from("/usr/bin/trust"));
assert!(candidates.contains(&PathBuf::from("/bin/trust")));
assert!(!candidates.contains(&PathBuf::from("relative/trust")));
}
#[test]
fn resolve_target_program_finds_binary_inside_selected_root() {
let root = unique_test_dir("target-command-root");
let trust_host_path = root.join("custom/bin/trust");
fs::create_dir_all(trust_host_path.parent().unwrap()).unwrap();
fs::write(&trust_host_path, b"").unwrap();
let resolved = resolve_target_program_with_path(
&root,
"trust",
Some(std::ffi::OsStr::new("/custom/bin")),
);
fs::remove_dir_all(&root).unwrap();
assert_eq!(resolved, PathBuf::from("/custom/bin/trust"));
}
#[test]
fn resolve_target_program_leaves_missing_program_unqualified() {
let root = unique_test_dir("target-command-missing");
fs::create_dir_all(&root).unwrap();
let resolved =
resolve_target_program_with_path(&root, "missing-tool", Some(std::ffi::OsStr::new("")));
fs::remove_dir_all(&root).unwrap();
assert_eq!(resolved, PathBuf::from("missing-tool"));
}
#[test]
fn shebang_interpreter_reads_first_command() {
let script = b"#!/usr/bin/env sh\nexit 0\n";
assert_eq!(shebang_interpreter(script).as_deref(), Some("/usr/bin/env"));
}
#[test]
fn elf_interpreter_reads_64_bit_pt_interp() {
let interpreter = b"/lib/ld-musl-x86_64.so.1\0";
let mut elf = vec![0u8; 160];
elf[0..4].copy_from_slice(b"\x7fELF");
elf[4] = 2; // 64-bit
elf[5] = 1; // little-endian
elf[32..40].copy_from_slice(&64u64.to_le_bytes()); // e_phoff
elf[54..56].copy_from_slice(&56u16.to_le_bytes()); // e_phentsize
elf[56..58].copy_from_slice(&1u16.to_le_bytes()); // e_phnum
elf[64..68].copy_from_slice(&3u32.to_le_bytes()); // PT_INTERP
elf[72..80].copy_from_slice(&128u64.to_le_bytes()); // p_offset
elf[96..104].copy_from_slice(&(interpreter.len() as u64).to_le_bytes()); // p_filesz
elf[128..128 + interpreter.len()].copy_from_slice(interpreter);
assert_eq!(
elf_interpreter(&elf).as_deref(),
Some("/lib/ld-musl-x86_64.so.1")
);
}
fn unique_test_dir(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!("ca-certs-{name}-{}-{nonce}", std::process::id()))
}
#[test]
fn certdata_parser_extracts_revision_and_objects() {
let doc = parse_certdata(SAMPLE_CERTDATA).unwrap();
@@ -2367,6 +2699,14 @@ CKA_TRUST_SERVER_AUTH CK_TRUST CKT_NSS_TRUSTED_DELEGATOR
let args = openssl_s_client_args(Path::new("/tmp/bootstrap-ca.pem"), "hg.mozilla.org", 443);
assert!(args.contains(&"-CAfile".to_string()));
assert!(!args.contains(&"-verifyCAfile".to_string()));
assert!(!args.contains(&"-verifyCApath".to_string()));
}
#[test]
fn openssl_s_client_capath_args_use_libressl_portable_flag() {
let args = openssl_s_client_capath_args(Path::new("/etc/ssl/certs"));
assert_eq!(args, vec!["-CApath", "/etc/ssl/certs"]);
assert!(!args.contains(&"-verifyCApath".to_string()));
}
#[test]