feat: enhance bootstrap script handling and improve command parsing logic

This commit is contained in:
2026-04-04 18:17:29 -05:00
parent ef001600c1
commit 09bb4667b4
2 changed files with 365 additions and 23 deletions
+4
View File
@@ -346,9 +346,13 @@ mod tests {
args: vec!["--component".into(), "runtime".into()],
})?;
if !crate::fakeroot::is_root() {
let fakeroot_output = fs::read_to_string(&fakeroot_log)?;
assert!(fakeroot_output.contains("--"));
assert!(fakeroot_output.contains("cmake"));
} else {
assert!(!fakeroot_log.exists());
}
let cmake_output = fs::read_to_string(&cmake_log)?;
assert!(cmake_output.contains(&format!("DESTDIR={}", destdir.display())));
+358 -20
View File
@@ -3,6 +3,7 @@
use crate::fakeroot;
use crate::package::PackageSpec;
use anyhow::{Context, Result, bail};
use std::collections::BTreeSet;
use std::fs;
use std::io::{BufRead, Write};
use std::path::{Component, Path, PathBuf};
@@ -12,6 +13,7 @@ use walkdir::WalkDir;
const STAGED_SCRIPTS_DIR: &str = "scripts";
const DEFERRED_HOOKS_FILE_REL: &str = "var/lib/depot/deferred-hooks.tsv";
const BOOTSTRAP_BIN_DIR_REL: &str = "var/lib/depot/bootstrap/bin";
const ALL_HOOKS: [Hook; 6] = [
Hook::PreInstall,
Hook::PostInstall,
@@ -371,7 +373,18 @@ fn should_bootstrap_host_shell(should_chroot: bool, is_root: bool, shell_exists:
should_chroot && is_root && !shell_exists
}
fn mount_chroot_filesystems(rootfs: &Path, bootstrap_host_shell: bool) -> Result<ChrootMountGuard> {
fn bootstrap_hook_path_env() -> String {
format!(
"/{}:{}",
BOOTSTRAP_BIN_DIR_REL,
crate::runtime_env::safe_script_path()
)
}
fn mount_chroot_filesystems(
rootfs: &Path,
bootstrap_script_path: Option<&Path>,
) -> Result<ChrootMountGuard> {
let proc_dir = rootfs.join("proc");
let dev_dir = rootfs.join("dev");
let dev_pts_dir = dev_dir.join("pts");
@@ -418,8 +431,8 @@ fn mount_chroot_filesystems(rootfs: &Path, bootstrap_host_shell: bool) -> Result
)?;
}
if bootstrap_host_shell {
bind_host_shell_into_chroot(&mut guard, rootfs)?;
if let Some(script_path) = bootstrap_script_path {
bind_host_shell_into_chroot(&mut guard, rootfs, script_path)?;
}
maybe_bind_host_file_into_chroot(&mut guard, rootfs, Path::new("/etc/resolv.conf"))?;
@@ -492,12 +505,28 @@ fn prepare_host_file_bind_target(rootfs: &Path, target_rel: &Path) -> Result<Pat
Ok(target)
}
fn bind_host_shell_into_chroot(guard: &mut ChrootMountGuard, rootfs: &Path) -> Result<()> {
fn bind_host_shell_into_chroot(
guard: &mut ChrootMountGuard,
rootfs: &Path,
script_path: &Path,
) -> Result<()> {
let shell_path =
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 mut dependency_paths = BTreeSet::new();
for dependency in collect_host_binary_dependencies(&shell_path)? {
dependency_paths.insert(dependency);
}
for tool in collect_bootstrap_tool_bindings(script_path)? {
bind_host_file_into_chroot_at_required(guard, rootfs, &tool.host_path, &tool.target_rel)?;
for dependency in collect_host_binary_dependencies(&tool.host_path)? {
dependency_paths.insert(dependency);
}
}
for dependency in dependency_paths {
let target_rel = dependency
.strip_prefix(Path::new("/"))
.with_context(|| format!("Expected absolute host path: {}", dependency.display()))?;
@@ -507,23 +536,31 @@ fn bind_host_shell_into_chroot(guard: &mut ChrootMountGuard, rootfs: &Path) -> R
Ok(())
}
fn collect_host_shell_dependencies(shell_path: &Path) -> Result<Vec<PathBuf>> {
fn collect_host_binary_dependencies(binary_path: &Path) -> Result<Vec<PathBuf>> {
let output = Command::new("ldd")
.arg(shell_path)
.arg(binary_path)
.output()
.with_context(|| {
format!(
"Failed to inspect shared-library dependencies for {}",
shell_path.display()
binary_path.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("ldd failed for {}: {}", shell_path.display(), stderr.trim());
bail!(
"ldd failed for {}: {}",
binary_path.display(),
stderr.trim()
);
}
let stdout = String::from_utf8(output.stdout)
.with_context(|| format!("ldd returned non-UTF-8 output for {}", shell_path.display()))?;
let stdout = String::from_utf8(output.stdout).with_context(|| {
format!(
"ldd returned non-UTF-8 output for {}",
binary_path.display()
)
})?;
parse_ldd_dependency_paths(&stdout)
}
@@ -560,6 +597,273 @@ fn parse_ldd_dependency_paths(output: &str) -> Result<Vec<PathBuf>> {
Ok(deps)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct BootstrapToolBinding {
host_path: PathBuf,
target_rel: PathBuf,
}
fn collect_bootstrap_tool_bindings(script_path: &Path) -> Result<Vec<BootstrapToolBinding>> {
let script = fs::read_to_string(script_path).with_context(|| {
format!(
"Failed to read lifecycle hook for bootstrap tool detection: {}",
script_path.display()
)
})?;
let mut bindings = BTreeSet::new();
for command in parse_hook_command_candidates(&script) {
if is_shell_builtin(&command) {
continue;
}
if command.starts_with('/') {
let host_path = fs::canonicalize(&command).with_context(|| {
format!(
"Failed to resolve hook command path for bootstrap: {}",
command
)
})?;
let target_rel = Path::new(&command)
.strip_prefix(Path::new("/"))
.with_context(|| format!("Expected absolute hook command path: {}", command))?
.to_path_buf();
bindings.insert(BootstrapToolBinding {
host_path,
target_rel,
});
continue;
}
if let Some(host_path) = resolve_host_tool_path(&command)? {
bindings.insert(BootstrapToolBinding {
host_path,
target_rel: Path::new(BOOTSTRAP_BIN_DIR_REL).join(&command),
});
}
}
Ok(bindings.into_iter().collect())
}
fn parse_hook_command_candidates(script: &str) -> Vec<String> {
let mut commands = BTreeSet::new();
for line in script.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let tokens = tokenize_hook_script_line(trimmed);
let mut expect_command = true;
for token in tokens {
if token.is_empty() {
continue;
}
if matches!(token.as_str(), "&&" | "||" | ";" | "|") {
expect_command = true;
continue;
}
if !expect_command {
continue;
}
if looks_like_env_assignment(&token) {
continue;
}
if is_shell_reserved_word(&token) {
continue;
}
if is_shell_builtin(&token) {
expect_command = false;
continue;
}
commands.insert(token);
expect_command = false;
}
}
commands.into_iter().collect()
}
fn tokenize_hook_script_line(line: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = line.chars().peekable();
let mut quote: Option<char> = None;
while let Some(ch) = chars.next() {
if let Some(active_quote) = quote {
current.push(ch);
if ch == active_quote {
quote = None;
} else if ch == '\\'
&& active_quote == '"'
&& let Some(next) = chars.next()
{
current.push(next);
}
continue;
}
match ch {
'\'' | '"' => {
current.push(ch);
quote = Some(ch);
}
'\\' => {
current.push(ch);
if let Some(next) = chars.next() {
current.push(next);
}
}
'#' => break,
'&' | '|' => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
if chars.peek() == Some(&ch) {
let _ = chars.next();
tokens.push(format!("{ch}{ch}"));
} else {
tokens.push(ch.to_string());
}
}
';' => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
tokens.push(ch.to_string());
}
c if c.is_whitespace() => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn is_shell_reserved_word(token: &str) -> bool {
matches!(
token,
"!" | "{"
| "}"
| "("
| ")"
| "then"
| "do"
| "done"
| "else"
| "elif"
| "fi"
| "if"
| "case"
| "esac"
| "for"
| "in"
| "while"
| "until"
| "function"
| "select"
)
}
fn looks_like_env_assignment(token: &str) -> bool {
let Some((key, _value)) = token.split_once('=') else {
return false;
};
!key.is_empty()
&& key
.chars()
.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
&& !key.chars().next().is_some_and(|ch| ch.is_ascii_digit())
}
fn is_shell_builtin(token: &str) -> bool {
matches!(
token,
"." | ":"
| "["
| "alias"
| "bg"
| "break"
| "cd"
| "command"
| "continue"
| "echo"
| "eval"
| "exec"
| "exit"
| "export"
| "false"
| "fg"
| "getopts"
| "hash"
| "jobs"
| "printf"
| "pwd"
| "read"
| "readonly"
| "return"
| "set"
| "shift"
| "test"
| "times"
| "trap"
| "true"
| "type"
| "ulimit"
| "umask"
| "unalias"
| "unset"
| "wait"
)
}
fn resolve_host_tool_path(command: &str) -> Result<Option<PathBuf>> {
for dir in crate::runtime_env::safe_script_path().split(':') {
if dir.is_empty() {
continue;
}
let candidate = Path::new(dir).join(command);
if !candidate.is_file() {
continue;
}
let metadata = candidate.metadata().with_context(|| {
format!(
"Failed to inspect host tool candidate {}",
candidate.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 == 0 {
continue;
}
}
return Ok(Some(fs::canonicalize(&candidate).with_context(|| {
format!("Failed to resolve host tool path {}", candidate.display())
})?));
}
Ok(None)
}
fn run_script_with_rootfs_context(
script_path: &Path,
rootfs: &Path,
@@ -582,7 +886,14 @@ fn run_script_with_rootfs_context(
}
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)
run_hook_script_in_chroot(
rootfs,
rel,
pkg_name,
hook,
false,
bootstrap_host_shell.then_some(script_path),
)
} else {
run_hook_script_contents_in_chroot(
rootfs,
@@ -590,7 +901,7 @@ fn run_script_with_rootfs_context(
pkg_name,
hook,
false,
bootstrap_host_shell,
bootstrap_host_shell.then_some(script_path),
)
};
@@ -657,10 +968,15 @@ fn run_hook_script_in_chroot(
pkg_name: &str,
hook: Hook,
quiet: bool,
bootstrap_host_shell: bool,
bootstrap_script_path: Option<&Path>,
) -> Result<std::process::ExitStatus> {
let _mounts = mount_chroot_filesystems(rootfs, bootstrap_host_shell)?;
let _mounts = mount_chroot_filesystems(rootfs, bootstrap_script_path)?;
let rel_script = format!("./{}", rel_script.to_string_lossy());
let path_env = if bootstrap_script_path.is_some() {
bootstrap_hook_path_env()
} else {
crate::runtime_env::safe_script_path().to_string()
};
let mut cmd = Command::new("chroot");
cmd.arg(rootfs)
.arg("/bin/sh")
@@ -672,7 +988,7 @@ fn run_hook_script_in_chroot(
.env("DEPOT_ROOTFS", "/")
.env("DEPOT_ACTION", hook.action())
.env("DEPOT_PHASE", hook.phase())
.env("PATH", crate::runtime_env::safe_script_path());
.env("PATH", &path_env);
if quiet {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
@@ -691,9 +1007,14 @@ fn run_hook_script_contents_in_chroot(
pkg_name: &str,
hook: Hook,
quiet: bool,
bootstrap_host_shell: bool,
bootstrap_script_path: Option<&Path>,
) -> Result<std::process::ExitStatus> {
let _mounts = mount_chroot_filesystems(rootfs, bootstrap_host_shell)?;
let _mounts = mount_chroot_filesystems(rootfs, bootstrap_script_path)?;
let path_env = if bootstrap_script_path.is_some() {
bootstrap_hook_path_env()
} else {
crate::runtime_env::safe_script_path().to_string()
};
let mut cmd = Command::new("chroot");
cmd.arg(rootfs)
.arg("/bin/sh")
@@ -702,7 +1023,7 @@ fn run_hook_script_contents_in_chroot(
.env("DEPOT_ROOTFS", "/")
.env("DEPOT_ACTION", hook.action())
.env("DEPOT_PHASE", hook.phase())
.env("PATH", crate::runtime_env::safe_script_path())
.env("PATH", &path_env)
.stdin(Stdio::piped());
if quiet {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
@@ -875,7 +1196,7 @@ pub fn run_deferred_hooks_if_possible(rootfs: &Path) -> Result<()> {
&item.pkg_name,
item.hook,
true,
false,
None,
) {
Ok(status) if status.success() => {}
Ok(status) => {
@@ -1480,6 +1801,23 @@ mod tests {
);
}
#[test]
fn parse_hook_command_candidates_finds_commands_after_assignments_and_operators() {
let commands = parse_hook_command_candidates(
"PATH=/tmp:$PATH grep -q foo etc/shells || echo foo >> etc/shells\ncat \"$DEPOT_ROOTFS/usr/bin/find\" | sed 's/x/y/'\n",
);
assert_eq!(
commands,
vec!["cat".to_string(), "grep".to_string(), "sed".to_string()]
);
}
#[test]
fn parse_hook_command_candidates_ignores_builtins_and_control_words() {
let commands = parse_hook_command_candidates("if true; then export FOO=bar; echo hi; fi\n");
assert!(commands.is_empty());
}
#[test]
fn deferred_hooks_file_roundtrip() {
let tmp = tempfile::tempdir().unwrap();