//! Transaction hook loading and execution. //! //! Hook files live under `/usr/share/depot.d/hooks/*.toml` and follow a //! Starpack-inspired format: //! //! ```toml //! [hook] //! name = "refresh cache" //! description = "Refreshes cache after updates" //! //! [when] //! phase = "post" //! operation = ["install", "update"] //! packages = ["glibc", "linux*"] //! paths = ["usr/lib/*"] //! negation = ["usr/lib/debug/*"] //! //! [exec] //! command = "ldconfig" //! needs_paths = true //! ``` //! //! The legacy section/key names `[Hook]`, `[When]`, and `[Exec]` are accepted. use crate::fakeroot; use anyhow::{Context, Result, bail}; use std::collections::HashSet; use std::fs; use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; const TRANSACTION_HOOKS_DIR_REL: &str = "usr/share/depot.d/hooks"; /// Hook phase within an install/update/remove transaction. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HookPhase { /// Before files are changed. Pre, /// After files are changed. Post, } impl HookPhase { fn parse(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "pre" | "pretransaction" | "pre_transaction" | "pre-transaction" => { Some(HookPhase::Pre) } "post" | "posttransaction" | "post_transaction" | "post-transaction" => { Some(HookPhase::Post) } _ => None, } } fn as_str(self) -> &'static str { match self { HookPhase::Pre => "pre", HookPhase::Post => "post", } } } /// Transaction operation for hooks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HookOperation { /// Package install. Install, /// Package update/upgrade. Update, /// Package removal. Remove, } impl HookOperation { fn parse(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "install" => Some(HookOperation::Install), "update" | "upgrade" => Some(HookOperation::Update), "remove" | "uninstall" => Some(HookOperation::Remove), _ => None, } } fn as_str(self) -> &'static str { match self { HookOperation::Install => "install", HookOperation::Update => "update", HookOperation::Remove => "remove", } } } /// Runtime context for transaction hook matching/execution. #[derive(Debug, Clone)] pub struct HookExecutionContext<'a> { /// Current phase (`pre` or `post`). pub phase: HookPhase, /// Current operation (`install`, `update`, `remove`). pub operation: HookOperation, /// Package being processed. pub package: &'a str, /// Filesystem paths affected by this package action. pub affected_paths: &'a [String], } /// Owned transaction hook context used for batched execution. #[derive(Debug, Clone)] pub struct HookExecutionContextOwned { /// Current operation (`install`, `update`, `remove`). pub operation: HookOperation, /// Package being processed. pub package: String, /// Filesystem paths affected by this package action. pub affected_paths: Vec, } #[derive(Debug, Clone)] struct TransactionHook { file_path: PathBuf, name: Option, phase: HookPhase, operations: Vec, packages: Vec, paths: Vec, negations: Vec, command: String, needs_paths: bool, } impl TransactionHook { fn display_name(&self) -> String { self.name.clone().unwrap_or_else(|| { self.file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_string() }) } fn matches(&self, ctx: &HookExecutionContext<'_>, normalized_paths: &[String]) -> bool { if self.phase != ctx.phase { return false; } if !self.operations.is_empty() && !self.operations.contains(&ctx.operation) { return false; } if !self.packages.is_empty() { let package = ctx.package.trim(); let package_match = self .packages .iter() .any(|pattern| wildcard_match(pattern.trim(), package)); if !package_match { return false; } } if !self.paths.is_empty() { let positive_match = normalized_paths.iter().any(|path| { self.paths.iter().any(|pattern| { wildcard_match( &normalize_match_target(pattern), &normalize_match_target(path), ) }) }); if !positive_match { return false; } } if !self.negations.is_empty() { let negated = normalized_paths.iter().any(|path| { self.negations.iter().any(|pattern| { wildcard_match( &normalize_match_target(pattern), &normalize_match_target(path), ) }) }); if negated { return false; } } true } } /// Return the transaction hook directory for a rootfs. pub fn transaction_hooks_dir(rootfs: &Path) -> PathBuf { rootfs.join(TRANSACTION_HOOKS_DIR_REL) } /// Load and execute transaction hooks that match `ctx`. /// /// Returns the number of hooks that were executed. pub fn run_transaction_hooks(rootfs: &Path, ctx: &HookExecutionContext<'_>) -> Result { let hook_dir = transaction_hooks_dir(rootfs); let hook_files = discover_hook_files(&hook_dir)?; if hook_files.is_empty() { return Ok(0); } let normalized_paths = normalize_affected_paths(ctx.affected_paths); let mut executed = 0usize; for hook_file in hook_files { let hook = parse_hook_file(&hook_file)?; if !hook.matches(ctx, &normalized_paths) { continue; } run_hook_command(rootfs, &hook, ctx, &normalized_paths, None)?; executed += 1; } Ok(executed) } /// Load and execute hooks for a batch of transaction contexts in stable order. /// /// Hooks are discovered and parsed once, then matched against each context in /// input order. Each matching hook runs once per hook file and operation, with /// affected paths aggregated across all matching contexts for that batch. /// /// Returns the number of hook commands executed. pub fn run_transaction_hooks_batch( rootfs: &Path, phase: HookPhase, contexts: &[HookExecutionContextOwned], ) -> Result { if contexts.is_empty() { return Ok(0); } let hook_dir = transaction_hooks_dir(rootfs); let hook_files = discover_hook_files(&hook_dir)?; if hook_files.is_empty() { return Ok(0); } let hooks = hook_files .iter() .map(|hook_file| parse_hook_file(hook_file)) .collect::>>()?; #[derive(Clone, Copy)] struct ScheduledHookRun { hook_idx: usize, operation: HookOperation, first_ctx_idx: usize, } fn push_unique_normalized_paths( out: &mut Vec, seen: &mut HashSet, paths: &[String], ) { for path in normalize_affected_paths(paths) { if seen.insert(path.clone()) { out.push(path); } } } let mut scheduled = Vec::new(); for (hook_idx, hook) in hooks.iter().enumerate() { let mut matched_operations = Vec::new(); for (ctx_idx, ctx_owned) in contexts.iter().enumerate() { let normalized_paths = normalize_affected_paths(&ctx_owned.affected_paths); let ctx = HookExecutionContext { phase, operation: ctx_owned.operation, package: &ctx_owned.package, affected_paths: &ctx_owned.affected_paths, }; if hook.matches(&ctx, &normalized_paths) && !matched_operations.contains(&ctx_owned.operation) { matched_operations.push(ctx_owned.operation); scheduled.push(ScheduledHookRun { hook_idx, operation: ctx_owned.operation, first_ctx_idx: ctx_idx, }); } } } let total = scheduled.len(); for (run_idx, run) in scheduled.into_iter().enumerate() { let primary_ctx = &contexts[run.first_ctx_idx]; let mut normalized_paths = Vec::new(); let mut seen_paths = HashSet::new(); push_unique_normalized_paths( &mut normalized_paths, &mut seen_paths, &primary_ctx.affected_paths, ); for ctx_owned in contexts.iter().skip(run.first_ctx_idx + 1) { if ctx_owned.operation != run.operation { continue; } let ctx = HookExecutionContext { phase, operation: ctx_owned.operation, package: &ctx_owned.package, affected_paths: &ctx_owned.affected_paths, }; let ctx_paths = normalize_affected_paths(&ctx_owned.affected_paths); if hooks[run.hook_idx].matches(&ctx, &ctx_paths) { push_unique_normalized_paths( &mut normalized_paths, &mut seen_paths, &ctx_owned.affected_paths, ); } } let ctx = HookExecutionContext { phase, operation: primary_ctx.operation, package: &primary_ctx.package, affected_paths: &primary_ctx.affected_paths, }; run_hook_command( rootfs, &hooks[run.hook_idx], &ctx, &normalized_paths, Some((run_idx + 1, total)), )?; } Ok(total) } fn discover_hook_files(hook_dir: &Path) -> Result> { if !hook_dir.exists() { return Ok(Vec::new()); } if !hook_dir.is_dir() { bail!( "Transaction hooks path exists but is not a directory: {}", hook_dir.display() ); } let mut files = Vec::new(); for entry in fs::read_dir(hook_dir).with_context(|| format!("Failed to read {}", hook_dir.display()))? { let path = entry .with_context(|| format!("Failed to list {}", hook_dir.display()))? .path(); if !path.is_file() { continue; } let is_toml = path .extension() .and_then(|e| e.to_str()) .map(|e| e.eq_ignore_ascii_case("toml")) .unwrap_or(false); if is_toml { files.push(path); } } files.sort(); Ok(files) } fn parse_hook_file(path: &Path) -> Result { let content = fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; let root: toml::Value = toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?; let root_table = root .as_table() .with_context(|| format!("Hook file is not a TOML table: {}", path.display()))?; let hook_table = select_table(root_table, &["hook", "Hook"]); let when_table = select_table(root_table, &["when", "When"]) .with_context(|| format!("Missing [when] table in {}", path.display()))?; let exec_table = select_table(root_table, &["exec", "Exec"]) .with_context(|| format!("Missing [exec] table in {}", path.display()))?; let phase = get_required_string(when_table, &["phase", "Phase"]) .with_context(|| format!("Missing when.phase in {}", path.display())) .and_then(|raw| { HookPhase::parse(&raw) .with_context(|| format!("Invalid when.phase '{}' in {}", raw, path.display())) })?; let operations = get_string_list(when_table, &["operation", "operations", "Operation"]) .with_context(|| format!("Invalid when.operation in {}", path.display()))? .into_iter() .map(|raw| { HookOperation::parse(&raw) .with_context(|| format!("Invalid operation '{}' in {}", raw, path.display())) }) .collect::>>()?; let packages = get_string_list(when_table, &["package", "packages", "Package", "Packages"]) .with_context(|| format!("Invalid when.packages in {}", path.display()))?; let paths = get_string_list(when_table, &["path", "paths", "Paths"]) .with_context(|| format!("Invalid when.paths in {}", path.display()))?; let negations = get_string_list( when_table, &[ "negation", "negations", "Negation", "exclude_paths", "not_paths", ], ) .with_context(|| format!("Invalid when.negation in {}", path.display()))?; let command = get_required_string(exec_table, &["command", "Command"]) .with_context(|| format!("Missing exec.command in {}", path.display()))?; let needs_paths = get_optional_bool(exec_table, &["needs_paths", "NeedsPaths"]) .with_context(|| format!("Invalid exec.needs_paths in {}", path.display()))? .unwrap_or(false); Ok(TransactionHook { file_path: path.to_path_buf(), name: hook_table.and_then(|t| get_optional_string(t, &["name", "Name"])), phase, operations, packages, paths, negations, command, needs_paths, }) } fn select_table<'a>( root: &'a toml::map::Map, keys: &[&str], ) -> Option<&'a toml::map::Map> { keys.iter() .find_map(|key| root.get(*key).and_then(toml::Value::as_table)) } fn get_optional_string( table: &toml::map::Map, keys: &[&str], ) -> Option { keys.iter().find_map(|key| { table .get(*key) .and_then(toml::Value::as_str) .map(str::to_string) }) } fn get_required_string( table: &toml::map::Map, keys: &[&str], ) -> Result { get_optional_string(table, keys) .filter(|value| !value.trim().is_empty()) .with_context(|| format!("Missing required key '{}'", keys[0])) } fn get_string_list( table: &toml::map::Map, keys: &[&str], ) -> Result> { for key in keys { let Some(value) = table.get(*key) else { continue; }; return match value { toml::Value::String(s) => Ok(vec![s.to_string()]), toml::Value::Array(arr) => { let mut out = Vec::new(); for item in arr { let value = item .as_str() .with_context(|| format!("Expected string elements for key '{}'", key))?; out.push(value.to_string()); } Ok(out) } _ => bail!("Expected string or string array for key '{}'", key), }; } Ok(Vec::new()) } fn get_optional_bool( table: &toml::map::Map, keys: &[&str], ) -> Result> { for key in keys { let Some(value) = table.get(*key) else { continue; }; return value .as_bool() .map(Some) .with_context(|| format!("Expected boolean for key '{}'", key)); } Ok(None) } fn run_hook_command( rootfs: &Path, hook: &TransactionHook, ctx: &HookExecutionContext<'_>, normalized_paths: &[String], sequence: Option<(usize, usize)>, ) -> Result<()> { let hook_name = hook.display_name(); if let Some((index, total)) = sequence { crate::log_info!( "Running transaction hook ({}/{}) '{}' for {}:{}:{}", index, total, hook_name, ctx.operation.as_str(), ctx.phase.as_str(), ctx.package ); } else { crate::log_info!( "Running transaction hook '{}' for {}:{}:{}", hook_name, ctx.operation.as_str(), ctx.phase.as_str(), ctx.package ); } let stdin_payload = if hook.needs_paths { let mut payload = normalized_paths.join("\n"); if !payload.is_empty() { payload.push('\n'); } Some(payload) } else { None }; let use_chroot = fakeroot::is_root() && rootfs.join("bin/sh").exists(); let _mounts = if should_mount_chroot_filesystems(rootfs, use_chroot) { Some(super::scripts::mount_chroot_filesystems(rootfs, None)?) } else { None }; let mut command = if use_chroot { let mut cmd = Command::new("chroot"); cmd.arg(rootfs).arg("/bin/sh").arg("-lc").arg(&hook.command); cmd } else { let mut cmd = Command::new("/bin/sh"); cmd.arg("-lc").arg(&hook.command).current_dir(rootfs); cmd }; command .env("DEPOT_ACTION", ctx.operation.as_str()) .env("DEPOT_PHASE", ctx.phase.as_str()) .env("DEPOT_PACKAGE", ctx.package) .env("DEPOT_ROOTFS", rootfs) .env("DEPOT_HOOK_FILE", &hook.file_path) .env("DEPOT_HOOK_NAME", &hook_name) .env("PATH", crate::runtime_env::safe_script_path()); let status = run_command_with_optional_stdin(&mut command, stdin_payload.as_deref()) .with_context(|| { format!( "Failed to execute transaction hook '{}' ({})", hook_name, hook.file_path.display() ) })?; if !status.success() { bail!( "Transaction hook '{}' failed with status {}", hook_name, status ); } Ok(()) } fn should_mount_chroot_filesystems(rootfs: &Path, use_chroot: bool) -> bool { use_chroot && super::scripts::should_use_chroot(rootfs) } fn run_command_with_optional_stdin( command: &mut Command, stdin_payload: Option<&str>, ) -> Result { if let Some(payload) = stdin_payload { command.stdin(Stdio::piped()); let mut child = command.spawn()?; if let Some(mut stdin) = child.stdin.take() { match stdin.write_all(payload.as_bytes()) { Ok(()) => {} Err(err) if err.kind() == ErrorKind::BrokenPipe => {} Err(err) => return Err(err.into()), } } Ok(child.wait()?) } else { Ok(command.status()?) } } fn normalize_affected_paths(paths: &[String]) -> Vec { paths.iter().map(|p| normalize_match_target(p)).collect() } fn normalize_match_target(raw: &str) -> String { raw.trim() .trim_start_matches("./") .trim_start_matches('/') .to_string() } fn wildcard_match(pattern: &str, text: &str) -> bool { let p = pattern.as_bytes(); let t = text.as_bytes(); let mut dp = vec![vec![false; t.len() + 1]; p.len() + 1]; dp[0][0] = true; for i in 1..=p.len() { if p[i - 1] == b'*' { dp[i][0] = dp[i - 1][0]; } } for i in 1..=p.len() { for j in 1..=t.len() { dp[i][j] = match p[i - 1] { b'*' => dp[i - 1][j] || dp[i][j - 1], b'?' => dp[i - 1][j - 1], ch => dp[i - 1][j - 1] && ch == t[j - 1], }; } } dp[p.len()][t.len()] } #[cfg(test)] mod tests { use super::*; fn write_hook(rootfs: &Path, name: &str, content: &str) { let dir = transaction_hooks_dir(rootfs); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(name), content).unwrap(); } #[test] fn wildcard_match_supports_star_and_question() { assert!(wildcard_match("usr/lib/*", "usr/lib/libc.so")); assert!(wildcard_match("lib??", "lib32")); assert!(!wildcard_match("usr/bin/*", "usr/lib/libc.so")); } #[test] fn transaction_hooks_mount_filesystems_only_for_target_chroots() { let rootfs = tempfile::tempdir().unwrap(); assert!(should_mount_chroot_filesystems(rootfs.path(), true)); assert!(!should_mount_chroot_filesystems(rootfs.path(), false)); assert!(!should_mount_chroot_filesystems(Path::new("/"), true)); } #[test] fn parse_hook_accepts_starpack_style_sections() { let tmp = tempfile::tempdir().unwrap(); write_hook( tmp.path(), "demo.toml", r#" [Hook] Name = "demo" [When] Phase = "PreTransaction" Operation = "Install" Package = "foo" [Exec] Command = "true" NeedsPaths = false "#, ); let path = transaction_hooks_dir(tmp.path()).join("demo.toml"); let hook = parse_hook_file(&path).unwrap(); assert_eq!(hook.name.as_deref(), Some("demo")); assert_eq!(hook.phase, HookPhase::Pre); assert_eq!(hook.operations, vec![HookOperation::Install]); assert_eq!(hook.packages, vec!["foo".to_string()]); } #[test] fn run_transaction_hooks_executes_matching_hook() { let tmp = tempfile::tempdir().unwrap(); write_hook( tmp.path(), "record.toml", r#" [hook] name = "record" [when] phase = "pre" operation = ["install"] packages = ["foo*"] paths = ["usr/bin/*"] [exec] command = "printf '%s:%s:%s' \"$DEPOT_ACTION\" \"$DEPOT_PHASE\" \"$DEPOT_PACKAGE\" > \"$DEPOT_ROOTFS/hook.out\"" needs_paths = true "#, ); let affected = vec!["usr/bin/foo".to_string()]; let ctx = HookExecutionContext { phase: HookPhase::Pre, operation: HookOperation::Install, package: "foo", affected_paths: &affected, }; let ran = run_transaction_hooks(tmp.path(), &ctx).unwrap(); assert_eq!(ran, 1); let out = std::fs::read_to_string(tmp.path().join("hook.out")).unwrap(); assert_eq!(out, "install:pre:foo"); } #[test] fn run_transaction_hooks_ignores_broken_pipe_for_needs_paths_hooks() { let tmp = tempfile::tempdir().unwrap(); write_hook( tmp.path(), "noop.toml", r#" [hook] name = "noop" [when] phase = "pre" operation = ["install"] packages = ["foo"] [exec] command = "true" needs_paths = true "#, ); let affected = vec!["usr/bin/foo".to_string()]; let ctx = HookExecutionContext { phase: HookPhase::Pre, operation: HookOperation::Install, package: "foo", affected_paths: &affected, }; let ran = run_transaction_hooks(tmp.path(), &ctx).unwrap(); assert_eq!(ran, 1); } #[test] fn run_transaction_hooks_respects_negation_and_filters_out() { let tmp = tempfile::tempdir().unwrap(); write_hook( tmp.path(), "skip.toml", r#" [hook] name = "skip" [when] phase = "pre" operation = "remove" paths = ["usr/lib/*"] negation = ["usr/lib/debug/*"] [exec] command = "touch \"$DEPOT_ROOTFS/should_not_exist\"" "#, ); let affected = vec!["usr/lib/debug/foo".to_string()]; let ctx = HookExecutionContext { phase: HookPhase::Pre, operation: HookOperation::Remove, package: "foo", affected_paths: &affected, }; let ran = run_transaction_hooks(tmp.path(), &ctx).unwrap(); assert_eq!(ran, 0); assert!(!tmp.path().join("should_not_exist").exists()); } #[test] fn run_transaction_hooks_batch_executes_in_context_order() { let tmp = tempfile::tempdir().unwrap(); write_hook( tmp.path(), "batch.toml", r#" [hook] name = "batch" [when] phase = "post" operation = ["install"] [exec] command = "cat >> \"$DEPOT_ROOTFS/batch.out\"" needs_paths = true "#, ); let contexts = vec![ HookExecutionContextOwned { operation: HookOperation::Install, package: "foo".to_string(), affected_paths: vec!["usr/bin/foo".to_string()], }, HookExecutionContextOwned { operation: HookOperation::Install, package: "bar".to_string(), affected_paths: vec!["usr/share/doc/bar".to_string()], }, ]; let ran = run_transaction_hooks_batch(tmp.path(), HookPhase::Post, &contexts).unwrap(); assert_eq!(ran, 1); let out = std::fs::read_to_string(tmp.path().join("batch.out")).unwrap(); assert_eq!(out, "usr/bin/foo\nusr/share/doc/bar\n"); } #[test] fn run_transaction_hooks_batch_runs_once_per_operation() { let tmp = tempfile::tempdir().unwrap(); write_hook( tmp.path(), "batch.toml", r#" [hook] name = "batch" [when] phase = "post" operation = ["install", "update"] [exec] command = "printf '%s:%s\n' \"$DEPOT_ACTION\" \"$DEPOT_PACKAGE\" >> \"$DEPOT_ROOTFS/batch.out\"" "#, ); let contexts = vec![ HookExecutionContextOwned { operation: HookOperation::Install, package: "foo".to_string(), affected_paths: vec!["usr/bin/foo".to_string()], }, HookExecutionContextOwned { operation: HookOperation::Install, package: "bar".to_string(), affected_paths: vec!["usr/share/doc/bar".to_string()], }, HookExecutionContextOwned { operation: HookOperation::Update, package: "baz".to_string(), affected_paths: vec!["usr/bin/baz".to_string()], }, ]; let ran = run_transaction_hooks_batch(tmp.path(), HookPhase::Post, &contexts).unwrap(); assert_eq!(ran, 2); let out = std::fs::read_to_string(tmp.path().join("batch.out")).unwrap(); assert_eq!(out, "install:foo\nupdate:baz\n"); } }