From 09bb4667b4d369eabca1e3db17e38df5baf39f7a Mon Sep 17 00:00:00 2001 From: SFG545 Date: Sat, 4 Apr 2026 18:17:29 -0500 Subject: [PATCH] feat: enhance bootstrap script handling and improve command parsing logic --- src/commands/misc/internal.rs | 10 +- src/install/scripts.rs | 378 ++++++++++++++++++++++++++++++++-- 2 files changed, 365 insertions(+), 23 deletions(-) diff --git a/src/commands/misc/internal.rs b/src/commands/misc/internal.rs index 106848d..9e31910 100644 --- a/src/commands/misc/internal.rs +++ b/src/commands/misc/internal.rs @@ -346,9 +346,13 @@ mod tests { args: vec!["--component".into(), "runtime".into()], })?; - let fakeroot_output = fs::read_to_string(&fakeroot_log)?; - assert!(fakeroot_output.contains("--")); - assert!(fakeroot_output.contains("cmake")); + 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()))); diff --git a/src/install/scripts.rs b/src/install/scripts.rs index 0d29606..71577cc 100644 --- a/src/install/scripts.rs +++ b/src/install/scripts.rs @@ -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 { +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 { 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 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> { +fn collect_host_binary_dependencies(binary_path: &Path) -> Result> { 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> { 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> { + 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 { + 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 { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut chars = line.chars().peekable(); + let mut quote: Option = 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> { + 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 { - 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 { - 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();