feat: enhance chroot handling and add host shell bootstrap logic

This commit is contained in:
2026-04-04 18:05:20 -05:00
parent d54e6d56e8
commit ef001600c1
+201 -31
View File
@@ -367,7 +367,11 @@ fn should_use_chroot(rootfs: &Path) -> bool {
} }
} }
fn mount_chroot_filesystems(rootfs: &Path) -> Result<ChrootMountGuard> { fn should_bootstrap_host_shell(should_chroot: bool, is_root: bool, shell_exists: bool) -> bool {
should_chroot && is_root && !shell_exists
}
fn mount_chroot_filesystems(rootfs: &Path, bootstrap_host_shell: bool) -> Result<ChrootMountGuard> {
let proc_dir = rootfs.join("proc"); let proc_dir = rootfs.join("proc");
let dev_dir = rootfs.join("dev"); let dev_dir = rootfs.join("dev");
let dev_pts_dir = dev_dir.join("pts"); let dev_pts_dir = dev_dir.join("pts");
@@ -414,6 +418,10 @@ fn mount_chroot_filesystems(rootfs: &Path) -> Result<ChrootMountGuard> {
)?; )?;
} }
if bootstrap_host_shell {
bind_host_shell_into_chroot(&mut guard, rootfs)?;
}
maybe_bind_host_file_into_chroot(&mut guard, rootfs, Path::new("/etc/resolv.conf"))?; maybe_bind_host_file_into_chroot(&mut guard, rootfs, Path::new("/etc/resolv.conf"))?;
Ok(guard) Ok(guard)
} }
@@ -423,14 +431,54 @@ fn maybe_bind_host_file_into_chroot(
rootfs: &Path, rootfs: &Path,
host_path: &Path, host_path: &Path,
) -> Result<()> { ) -> Result<()> {
if !host_path.exists() {
return Ok(());
}
let rel = host_path let rel = host_path
.strip_prefix(Path::new("/")) .strip_prefix(Path::new("/"))
.with_context(|| format!("Expected absolute host path: {}", host_path.display()))?; .with_context(|| format!("Expected absolute host path: {}", host_path.display()))?;
let target = rootfs.join(rel); bind_host_file_into_chroot_at(guard, rootfs, host_path, rel)
}
fn bind_host_file_into_chroot_at(
guard: &mut ChrootMountGuard,
rootfs: &Path,
host_path: &Path,
target_rel: &Path,
) -> Result<()> {
if !host_path.exists() {
return Ok(());
}
prepare_host_file_bind_target(rootfs, target_rel).map(|target| {
if let Err(err) = guard.mount_path(host_path, &target, None, MountFlags::BIND, None) {
crate::log_warn!(
"Failed to bind-mount {} into chroot at {}: {}",
host_path.display(),
target.display(),
err
);
}
})
}
fn bind_host_file_into_chroot_at_required(
guard: &mut ChrootMountGuard,
rootfs: &Path,
host_path: &Path,
target_rel: &Path,
) -> Result<()> {
let target = prepare_host_file_bind_target(rootfs, target_rel)?;
guard
.mount_path(host_path, &target, None, MountFlags::BIND, None)
.with_context(|| {
format!(
"Failed to bind-mount {} into chroot at {}",
host_path.display(),
target.display()
)
})?;
Ok(())
}
fn prepare_host_file_bind_target(rootfs: &Path, target_rel: &Path) -> Result<PathBuf> {
let target = rootfs.join(target_rel);
if let Some(parent) = target.parent() { if let Some(parent) = target.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
@@ -441,15 +489,75 @@ fn maybe_bind_host_file_into_chroot(
.with_context(|| format!("Failed to create {}", target.display()))?; .with_context(|| format!("Failed to create {}", target.display()))?;
} }
if let Err(err) = guard.mount_path(host_path, &target, None, MountFlags::BIND, None) { Ok(target)
crate::log_warn!( }
"Failed to bind-mount {} into chroot at {}: {}",
host_path.display(), fn bind_host_shell_into_chroot(guard: &mut ChrootMountGuard, rootfs: &Path) -> Result<()> {
target.display(), let shell_path =
err fs::canonicalize("/bin/sh").context("Failed to resolve host /bin/sh for hook bootstrap")?;
bind_host_file_into_chroot_at_required(guard, rootfs, &shell_path, Path::new("bin/sh"))?;
for dependency in collect_host_shell_dependencies(&shell_path)? {
let target_rel = dependency
.strip_prefix(Path::new("/"))
.with_context(|| format!("Expected absolute host path: {}", dependency.display()))?;
bind_host_file_into_chroot_at_required(guard, rootfs, &dependency, target_rel)?;
}
Ok(())
}
fn collect_host_shell_dependencies(shell_path: &Path) -> Result<Vec<PathBuf>> {
let output = Command::new("ldd")
.arg(shell_path)
.output()
.with_context(|| {
format!(
"Failed to inspect shared-library dependencies for {}",
shell_path.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("ldd failed for {}: {}", shell_path.display(), stderr.trim());
}
let stdout = String::from_utf8(output.stdout)
.with_context(|| format!("ldd returned non-UTF-8 output for {}", shell_path.display()))?;
parse_ldd_dependency_paths(&stdout)
}
fn parse_ldd_dependency_paths(output: &str) -> Result<Vec<PathBuf>> {
let mut deps = Vec::new();
for raw_line in output.lines() {
let line = raw_line.trim();
if line.is_empty() || line == "statically linked" {
continue;
}
let candidate = if let Some((_, rest)) = line.split_once("=>") {
let token = rest.split_whitespace().next().unwrap_or_default();
if token == "not" {
bail!(
"Missing shared-library dependency reported by ldd: {}",
line
); );
} }
Ok(()) token
} else {
line.split_whitespace().next().unwrap_or_default()
};
if candidate.starts_with('/') {
let path = PathBuf::from(candidate);
if !deps.iter().any(|existing| existing == &path) {
deps.push(path);
}
}
}
Ok(deps)
} }
fn run_script_with_rootfs_context( fn run_script_with_rootfs_context(
@@ -459,17 +567,36 @@ fn run_script_with_rootfs_context(
hook: Hook, hook: Hook,
allow_defer: bool, allow_defer: bool,
) -> Result<HookRunOutcome> { ) -> Result<HookRunOutcome> {
if should_use_chroot(rootfs) && fakeroot::is_root() { let should_chroot = should_use_chroot(rootfs);
if rootfs.join("bin/sh").exists() { let is_root = fakeroot::is_root();
let status = if let Ok(rel) = script_path.strip_prefix(rootfs) { let shell_exists = rootfs.join("bin/sh").exists();
run_hook_script_in_chroot(rootfs, rel, pkg_name, hook, false)? let bootstrap_host_shell = should_bootstrap_host_shell(should_chroot, is_root, shell_exists);
} else {
run_hook_script_contents_in_chroot(rootfs, script_path, pkg_name, hook, false)? if should_chroot && is_root {
}; if bootstrap_host_shell {
return Ok(HookRunOutcome::Ran(status)); crate::log_info!(
"Temporarily binding host /bin/sh into {} for lifecycle hook {}",
rootfs.display(),
hook.canonical_name()
);
} }
if allow_defer { let run_result = if let Ok(rel) = script_path.strip_prefix(rootfs) {
run_hook_script_in_chroot(rootfs, rel, pkg_name, hook, false, bootstrap_host_shell)
} else {
run_hook_script_contents_in_chroot(
rootfs,
script_path,
pkg_name,
hook,
false,
bootstrap_host_shell,
)
};
match run_result {
Ok(status) => return Ok(HookRunOutcome::Ran(status)),
Err(err) if allow_defer && bootstrap_host_shell => {
let rel = script_path.strip_prefix(rootfs).with_context(|| { let rel = script_path.strip_prefix(rootfs).with_context(|| {
format!( format!(
"Cannot defer lifecycle hook {} for {} because {} is outside {}", "Cannot defer lifecycle hook {} for {} because {} is outside {}",
@@ -479,15 +606,16 @@ fn run_script_with_rootfs_context(
rootfs.display() rootfs.display()
) )
})?; })?;
return Ok(HookRunOutcome::Deferred(rel.to_path_buf())); crate::log_warn!(
} "Deferring lifecycle hook {} for {} because host shell bootstrap failed: {}",
anyhow::bail!(
"Lifecycle hook {} for {} requires {}/bin/sh before it can run",
hook.canonical_name(), hook.canonical_name(),
pkg_name, pkg_name,
rootfs.display() err
); );
return Ok(HookRunOutcome::Deferred(rel.to_path_buf()));
}
Err(err) => return Err(err),
}
} }
// Live-root installs can execute directly with the host shell. // Live-root installs can execute directly with the host shell.
@@ -529,8 +657,9 @@ fn run_hook_script_in_chroot(
pkg_name: &str, pkg_name: &str,
hook: Hook, hook: Hook,
quiet: bool, quiet: bool,
bootstrap_host_shell: bool,
) -> Result<std::process::ExitStatus> { ) -> Result<std::process::ExitStatus> {
let _mounts = mount_chroot_filesystems(rootfs)?; let _mounts = mount_chroot_filesystems(rootfs, bootstrap_host_shell)?;
let rel_script = format!("./{}", rel_script.to_string_lossy()); let rel_script = format!("./{}", rel_script.to_string_lossy());
let mut cmd = Command::new("chroot"); let mut cmd = Command::new("chroot");
cmd.arg(rootfs) cmd.arg(rootfs)
@@ -562,8 +691,9 @@ fn run_hook_script_contents_in_chroot(
pkg_name: &str, pkg_name: &str,
hook: Hook, hook: Hook,
quiet: bool, quiet: bool,
bootstrap_host_shell: bool,
) -> Result<std::process::ExitStatus> { ) -> Result<std::process::ExitStatus> {
let _mounts = mount_chroot_filesystems(rootfs)?; let _mounts = mount_chroot_filesystems(rootfs, bootstrap_host_shell)?;
let mut cmd = Command::new("chroot"); let mut cmd = Command::new("chroot");
cmd.arg(rootfs) cmd.arg(rootfs)
.arg("/bin/sh") .arg("/bin/sh")
@@ -739,7 +869,14 @@ pub fn run_deferred_hooks_if_possible(rootfs: &Path) -> Result<()> {
continue; continue;
} }
match run_hook_script_in_chroot(rootfs, &item.script_rel, &item.pkg_name, item.hook, true) { match run_hook_script_in_chroot(
rootfs,
&item.script_rel,
&item.pkg_name,
item.hook,
true,
false,
) {
Ok(status) if status.success() => {} Ok(status) if status.success() => {}
Ok(status) => { Ok(status) => {
crate::log_info!( crate::log_info!(
@@ -1310,6 +1447,39 @@ mod tests {
); );
} }
#[test]
fn should_bootstrap_host_shell_only_for_chrooted_root_installs_without_shell() {
assert!(should_bootstrap_host_shell(true, true, false));
assert!(!should_bootstrap_host_shell(true, true, true));
assert!(!should_bootstrap_host_shell(true, false, false));
assert!(!should_bootstrap_host_shell(false, true, false));
}
#[test]
fn parse_ldd_dependency_paths_extracts_absolute_paths() {
let parsed = parse_ldd_dependency_paths(
"linux-vdso.so.1 (0x0000)\nlibc.so.6 => /lib/libc.so.6 (0x0000)\n/lib64/ld-linux-x86-64.so.2 (0x0000)\nlibc.so.6 => /lib/libc.so.6 (0x0001)\n",
)
.unwrap();
assert_eq!(
parsed,
vec![
PathBuf::from("/lib/libc.so.6"),
PathBuf::from("/lib64/ld-linux-x86-64.so.2")
]
);
}
#[test]
fn parse_ldd_dependency_paths_rejects_missing_dependencies() {
let err = parse_ldd_dependency_paths("libedit.so.0 => not found\n")
.expect_err("expected ldd parse to fail when a dependency is missing");
assert!(
err.to_string()
.contains("Missing shared-library dependency")
);
}
#[test] #[test]
fn deferred_hooks_file_roundtrip() { fn deferred_hooks_file_roundtrip() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();