refactor command flow and add signed package/hook safety

- split CLI definitions and command execution out of main.rs into new cli.rs and commands.rs modules\n- add transaction hook support via /etc/depot.d/hooks/*.toml with pre/post phases, operation/package/path filters, wildcard negation, and optional affected-path stdin\n- execute transaction hooks around install/update/remove operations and add package-action prompts for install/build/remove flows\n- require and verify detached minisign signatures for binary package archives when allow_unsigned=false, including cache handling and tests\n- persist optional dependencies from package metadata into repo index data and include optional deps in produced metadata\n- update dependency/planner logic to ignore dependencies provided by local split outputs and stop pulling test deps into install planning\n- auto-disable tests when required test dependencies are missing instead of hard-failing build/install paths\n- improve autotools defaults by injecting standard install directory flags when supported and preserving user overrides\n- remove static .a archives during staging by default with new build.flags.no_delete_static escape hatch\n- preserve symlinks and file metadata (permissions/timestamps) in extractor fallback copy path and add regression tests\n- refresh README docs for detached package signatures, optional dependencies, and transaction hooks\n- adjust dependency set: add filetime, disable reqwest default features, and simplify git2/OpenSSL linkage
This commit is contained in:
2026-02-28 00:54:08 -06:00
parent e045ae78fd
commit ce5fefa833
19 changed files with 3863 additions and 2923 deletions
+615
View File
@@ -0,0 +1,615 @@
//! Transaction hook loading and execution.
//!
//! Hook files live under `<rootfs>/etc/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::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
const TRANSACTION_HOOKS_DIR_REL: &str = "etc/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],
}
#[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: Vec<String> = ctx
.affected_paths
.iter()
.map(|p| normalize_match_target(p))
.collect();
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)?;
executed += 1;
}
Ok(executed)
}
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],
) -> Result<()> {
let hook_name = hook.display_name();
crate::log_info!(
"Running transaction hook '{}' ({}) for {}:{}:{}",
hook_name,
hook.file_path.display(),
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 mut command = if fakeroot::is_root() && rootfs.join("bin/sh").exists() {
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);
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 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() {
stdin.write_all(payload.as_bytes())?;
}
Ok(child.wait()?)
} else {
Ok(command.status()?)
}
}
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 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_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());
}
}
+1
View File
@@ -1,3 +1,4 @@
//! Installation helpers.
pub mod hooks;
pub mod scripts;