896 lines
26 KiB
Rust
896 lines
26 KiB
Rust
//! Transaction hook loading and execution.
|
|
//!
|
|
//! Hook files live under `<rootfs>/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<Self> {
|
|
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<Self> {
|
|
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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TransactionHook {
|
|
file_path: PathBuf,
|
|
name: Option<String>,
|
|
phase: HookPhase,
|
|
operations: Vec<HookOperation>,
|
|
packages: Vec<String>,
|
|
paths: Vec<String>,
|
|
negations: Vec<String>,
|
|
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("<unnamed-hook>")
|
|
.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<usize> {
|
|
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<usize> {
|
|
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::<Result<Vec<_>>>()?;
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct ScheduledHookRun {
|
|
hook_idx: usize,
|
|
operation: HookOperation,
|
|
first_ctx_idx: usize,
|
|
}
|
|
|
|
fn push_unique_normalized_paths(
|
|
out: &mut Vec<String>,
|
|
seen: &mut HashSet<String>,
|
|
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<Vec<PathBuf>> {
|
|
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<TransactionHook> {
|
|
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::<Result<Vec<_>>>()?;
|
|
|
|
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<String, toml::Value>,
|
|
keys: &[&str],
|
|
) -> Option<&'a toml::map::Map<String, toml::Value>> {
|
|
keys.iter()
|
|
.find_map(|key| root.get(*key).and_then(toml::Value::as_table))
|
|
}
|
|
|
|
fn get_optional_string(
|
|
table: &toml::map::Map<String, toml::Value>,
|
|
keys: &[&str],
|
|
) -> Option<String> {
|
|
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<String, toml::Value>,
|
|
keys: &[&str],
|
|
) -> Result<String> {
|
|
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<String, toml::Value>,
|
|
keys: &[&str],
|
|
) -> Result<Vec<String>> {
|
|
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<String, toml::Value>,
|
|
keys: &[&str],
|
|
) -> Result<Option<bool>> {
|
|
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<ExitStatus> {
|
|
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<String> {
|
|
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");
|
|
}
|
|
}
|