use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::collections::HashMap;
use color_eyre::Result;
use color_eyre::eyre::{Context, eyre};
use crate::models::{InstalledPackageInfo, PACKAGE_CACHE, PackageFormat};
use std::sync::Arc;
use crate::plan::InstallationPlan;
use crate::hooks::{Hook, HookWhen};
use crate::lfs;
pub const TRIGGERSDIR: &str = "var/lib/dpkg/triggers";
pub const TRIGGERSDEFERREDFILE: &str = "Unincorp";
#[derive(Debug, Clone)]
pub(crate) struct TriggerEntry {
name: String,
await_mode: bool,
}
fn get_triggers_dir(env_root: &Path) -> PathBuf {
env_root.join(TRIGGERSDIR)
}
fn get_unincorp_path(env_root: &Path) -> PathBuf {
get_triggers_dir(env_root).join(TRIGGERSDEFERREDFILE)
}
fn trigger_name_to_filename(name: &str) -> String {
if name.starts_with('/') {
name.replace('/', "__").to_string()
} else {
name.to_string()
}
}
#[cfg(unix)]
pub fn ensure_triggers_dir(env_root: &Path) -> Result<()> {
let triggers_dir = get_triggers_dir(env_root);
fs::create_dir_all(&triggers_dir)
.with_context(|| format!("Failed to create triggers directory: {}", triggers_dir.display()))?;
Ok(())
}
fn load_deb_package_triggers(
plan: &mut InstallationPlan,
pkgkey: &str,
pkgline: &str,
) -> Result<()> {
if pkgline.is_empty() {
return Ok(());
}
let package_dir = plan.store_root.join(pkgline);
let (interest_triggers, activate_triggers) = read_package_triggers(&package_dir)?;
add_interest_triggers_to_maps(plan, pkgkey, interest_triggers);
add_activate_triggers_to_maps(plan, pkgkey, activate_triggers);
Ok(())
}
fn add_triggers_to_bidirectional_maps(
pkgkey: &str,
trigger_names: Vec<String>,
pkg_to_triggers: &mut HashMap<String, Vec<String>>,
trigger_to_pkgs: &mut HashMap<String, Vec<String>>,
) {
let pkgkey_string = pkgkey.to_string();
for trigger_name in trigger_names {
let pkg_entry = pkg_to_triggers
.entry(pkgkey_string.clone())
.or_insert_with(Vec::new);
if !pkg_entry.contains(&trigger_name) {
pkg_entry.push(trigger_name.clone());
}
let name_entry = trigger_to_pkgs
.entry(trigger_name.clone())
.or_insert_with(Vec::new);
if !name_entry.contains(&pkgkey_string) {
name_entry.push(pkgkey_string.clone());
}
}
}
fn add_interest_triggers_to_maps(
plan: &mut InstallationPlan,
pkgkey: &str,
triggers: Vec<TriggerEntry>,
) {
let trigger_names: Vec<String> = triggers.into_iter().map(|t| t.name).collect();
add_triggers_to_bidirectional_maps(
pkgkey,
trigger_names,
&mut plan.deb_explicit_triggers_by_pkg,
&mut plan.deb_explicit_triggers_by_name,
);
}
fn add_activate_triggers_to_maps(
plan: &mut InstallationPlan,
pkgkey: &str,
triggers: Vec<TriggerEntry>,
) {
let trigger_names: Vec<String> = triggers.into_iter().map(|t| t.name).collect();
add_triggers_to_bidirectional_maps(
pkgkey,
trigger_names,
&mut plan.deb_activate_triggers_by_pkg,
&mut plan.deb_activate_triggers_by_name,
);
}
fn read_unincorp_file(unincorp_path: &Path) -> Result<HashMap<String, Vec<String>>> {
let mut activations: HashMap<String, Vec<String>> = HashMap::new();
if !lfs::exists_on_host(&unincorp_path) {
return Ok(activations);
}
if let Ok(file) = fs::File::open(unincorp_path) {
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if !parts.is_empty() {
let trigger = parts[0].to_string();
let packages: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
activations.insert(trigger, packages);
}
}
}
Ok(activations)
}
fn write_unincorp_file(
unincorp_path: &Path,
activations: &HashMap<String, Vec<String>>,
) -> Result<()> {
let mut content = String::new();
for (trigger, packages) in activations {
if !packages.is_empty() {
content.push_str(trigger);
for pkg in packages {
content.push(' ');
content.push_str(pkg);
}
content.push('\n');
}
}
fs::write(unincorp_path, content)
.with_context(|| format!("Failed to write Unincorp file: {}", unincorp_path.display()))?;
Ok(())
}
#[cfg(unix)]
pub fn activate_trigger(
env_root: &Path,
trigger_name: &str,
activating_package: Option<&str>,
no_await: bool,
) -> Result<()> {
ensure_triggers_dir(env_root)?;
let unincorp_path = get_unincorp_path(env_root);
let mut existing_activations = read_unincorp_file(&unincorp_path)?;
let awaiter = if no_await {
"-".to_string()
} else {
activating_package.map(|s| s.to_string()).unwrap_or_else(|| "-".to_string())
};
let packages = existing_activations.entry(trigger_name.to_string())
.or_insert_with(Vec::new);
if !packages.contains(&awaiter) {
packages.push(awaiter);
}
write_unincorp_file(&unincorp_path, &existing_activations)?;
Ok(())
}
pub fn setup_deb_env_vars(
env_vars: &mut std::collections::HashMap<String, String>,
pkgkey: &str,
package_info: &InstalledPackageInfo,
scriptlet_type: crate::scriptlets::ScriptletType,
_env_root: &std::path::Path,
) {
use crate::package::{pkgkey2pkgname, pkgkey2version, pkgkey2arch};
let script_type = match scriptlet_type {
crate::scriptlets::ScriptletType::PreInstall | crate::scriptlets::ScriptletType::PreUpgrade => "preinst",
crate::scriptlets::ScriptletType::PostInstall | crate::scriptlets::ScriptletType::PostUpgrade => "postinst",
crate::scriptlets::ScriptletType::PreRemove => "prerm",
crate::scriptlets::ScriptletType::PostRemove => "postrm",
crate::scriptlets::ScriptletType::PreTrans | crate::scriptlets::ScriptletType::PostTrans |
crate::scriptlets::ScriptletType::PreUnTrans | crate::scriptlets::ScriptletType::PostUnTrans => {
return;
}
};
env_vars.insert("DPKG_MAINTSCRIPT_NAME".to_string(), script_type.to_string());
if let Ok(package_name) = pkgkey2pkgname(pkgkey) {
env_vars.insert("DPKG_MAINTSCRIPT_PACKAGE".to_string(), package_name);
}
if let Ok(arch) = pkgkey2arch(pkgkey) {
env_vars.insert("DPKG_MAINTSCRIPT_ARCH".to_string(), arch);
} else {
env_vars.insert("DPKG_MAINTSCRIPT_ARCH".to_string(), package_info.arch.clone());
}
if let Ok(version) = pkgkey2version(pkgkey) {
env_vars.insert("DPKG_MAINTSCRIPT_VERSION".to_string(), version);
}
env_vars.insert("DPKG_MAINTSCRIPT_PACKAGE_REFCOUNT".to_string(), "1".to_string());
env_vars.insert("DPKG_ADMINDIR".to_string(), "/var/lib/dpkg".to_string());
env_vars.insert("DPKG_RUNNING_VERSION".to_string(), "1.21.22".to_string());
if std::env::var("RUST_DEBUG").is_ok() {
env_vars.insert("DPKG_MAINTSCRIPT_DEBUG".to_string(), "1".to_string());
}
env_vars.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string());
env_vars.insert("DEBCONF_NONINTERACTIVE_SEEN".to_string(), "true".to_string());
}
pub fn read_package_triggers<P: AsRef<Path>>(
package_dir: P,
) -> Result<(Vec<TriggerEntry>, Vec<TriggerEntry>)> {
let triggers_path = crate::dirs::path_join(package_dir.as_ref(), &["info", "deb", "triggers"]);
if !lfs::exists_on_host(&triggers_path) {
return Ok((Vec::new(), Vec::new()));
}
let triggers_content = fs::read_to_string(&triggers_path)
.with_context(|| format!("Failed to read triggers file: {}", triggers_path.display()))?;
let mut interest_triggers: Vec<TriggerEntry> = Vec::new();
let mut activate_triggers: Vec<TriggerEntry> = Vec::new();
for (line_num, line) in triggers_content.lines().enumerate() {
let line = line.trim();
let line_num = line_num + 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
continue;
}
let directive = parts[0];
let trigger_name = parts[1..].join(" ");
match directive {
"interest" | "interest-await" => {
interest_triggers.push(TriggerEntry { name: trigger_name, await_mode: true });
}
"interest-noawait" => {
interest_triggers.push(TriggerEntry { name: trigger_name, await_mode: false });
}
"activate" | "activate-await" => {
activate_triggers.push(TriggerEntry { name: trigger_name, await_mode: true });
}
"activate-noawait" => {
activate_triggers.push(TriggerEntry { name: trigger_name, await_mode: false });
}
_ => {
return Err(eyre!(
"Unknown trigger directive '{}' in triggers file '{}' at line {}",
directive,
triggers_path.display(),
line_num
));
}
}
}
Ok((interest_triggers, activate_triggers))
}
pub fn write_deb_trigger_hooks<P: AsRef<Path>>(
interest_triggers: &[TriggerEntry],
activate_triggers: &[TriggerEntry],
store_tmp_dir: P,
) -> Result<()> {
use std::fmt::Write as FmtWrite;
let store_tmp_dir = store_tmp_dir.as_ref();
let install_dir = crate::dirs::path_join(store_tmp_dir, &["info", "install"]);
if interest_triggers.is_empty() && activate_triggers.is_empty() {
return Ok(());
}
fs::create_dir_all(&install_dir)?;
for entry in interest_triggers {
let name = entry.name.trim();
let mut buf = String::new();
let when_phase = if entry.await_mode {
"PostTransaction"
} else {
"PostInstall"
};
let (hook_type, target) = if name.starts_with('/') {
("Path", name)
} else {
("Package", name)
};
buf.push_str("[Trigger]\n");
buf.push_str("Operation = Install\n");
buf.push_str("Operation = Upgrade\n");
buf.push_str("Operation = Remove\n");
writeln!(buf, "Type = {}", hook_type)?;
writeln!(buf, "Target = {}", target)?;
buf.push_str("\n[Action]\n");
writeln!(buf, "When = {}", when_phase)?;
writeln!(
buf,
"Description = DEB {} trigger for {} (defer_mode={})",
if hook_type == "Path" { "file" } else { "explicit" },
target,
if entry.await_mode { "await" } else { "noawait" }
)?;
writeln!(buf, "Exec = %PKGINFO_DIR/deb/postinst triggered")?;
let hook_name = trigger_name_to_filename(name);
let hook_path = install_dir.join(format!("{}.hook", hook_name));
fs::write(&hook_path, buf)
.with_context(|| format!("Failed to write DEB hook file {}", hook_path.display()))?;
}
Ok(())
}
pub fn load_initial_deb_triggers(plan: &mut InstallationPlan) -> Result<()> {
if plan.package_format != PackageFormat::Deb {
return Ok(());
}
let installed = PACKAGE_CACHE.installed_packages.read().unwrap();
for (pkgkey, info) in installed.iter() {
load_deb_package_triggers(plan, pkgkey, &info.pkgline)?;
}
Ok(())
}
pub fn load_batch_deb_triggers(plan: &mut InstallationPlan) -> Result<()> {
if plan.package_format != PackageFormat::Deb {
return Ok(());
}
let pkgkeys: Vec<String> = plan.batch.new_pkgkeys.iter().cloned().collect();
for pkgkey in pkgkeys {
let pkgline = crate::plan::pkgkey2pkgline(plan, &pkgkey);
load_deb_package_triggers(plan, &pkgkey, &pkgline)?;
}
Ok(())
}
fn separate_unincorp_triggers(
trigger_activations: HashMap<String, Vec<String>>,
when: HookWhen,
) -> Result<(HashMap<String, Vec<String>>, HashMap<String, Vec<String>>)> {
let mut noawait_triggers: HashMap<String, Vec<String>> = HashMap::new();
let mut await_triggers: HashMap<String, Vec<String>> = HashMap::new();
for (trigger_name, activating_packages) in trigger_activations {
if activating_packages.iter().any(|pkg| pkg == "-") {
noawait_triggers.insert(trigger_name, activating_packages);
} else {
await_triggers.insert(trigger_name, activating_packages);
}
}
let (triggers_to_consume, triggers_remaining) = match when {
HookWhen::PostInstall => (noawait_triggers, await_triggers),
HookWhen::PostTransaction => (await_triggers, noawait_triggers),
_ => {
return Err(color_eyre::eyre::eyre!("Invalid HookWhen for unincorp triggers"));
}
};
Ok((triggers_to_consume, triggers_remaining))
}
* Trigger name → hook lookup for Unincorp execution
*
* Hooks are stored in plan.hooks_by_name under two shapes:
* - Global hook: key = base name (e.g. "update-ca-certificates").
* - Package hook (when no global exists): key = base_name + "-" + pkgkey
* (e.g. "update-ca-certificates-ca-certificates__20250419__all").
*
* So we look up by exact trigger-derived name first; if missing, we collect
* any key that equals the name or starts with "name-", and prefer the hook
* from a package in the current batch (newly installed).
*/
fn find_hook_for_trigger<'a>(
plan: &'a InstallationPlan,
hook_name: &str,
) -> Option<&'a Arc<Hook>> {
if let Some(hook) = plan.hooks_by_name.get(hook_name) {
return Some(hook);
}
let prefix = format!("{}-", hook_name);
let candidates: Vec<&Arc<Hook>> = plan
.hooks_by_name
.iter()
.filter(|(k, _)| k.as_str() == hook_name || k.starts_with(&prefix))
.map(|(_, h)| h)
.collect();
if candidates.is_empty() {
return None;
}
if candidates.len() == 1 {
return Some(candidates[0]);
}
candidates
.iter()
.find(|h| h.pkgkey.as_ref().map_or(false, |pk| plan.batch.new_pkgkeys.contains(pk)))
.copied()
.or_else(|| Some(candidates[0]))
}
fn run_unincorp_trigger_hooks(
plan: &InstallationPlan,
triggers_to_process: &HashMap<String, Vec<String>>,
) -> Result<()> {
for trigger_name in triggers_to_process.keys() {
let hook_name = trigger_name_to_filename(trigger_name);
if let Some(hook) = find_hook_for_trigger(plan, &hook_name) {
let matched_targets = vec![trigger_name.clone()];
crate::hooks::execute_hook(hook.as_ref(), plan, &matched_targets)?;
} else {
log::debug!("No hook found for trigger '{}' (no interested package installed)", trigger_name);
}
}
Ok(())
}
pub fn run_debian_unincorp_triggers(
plan: &mut InstallationPlan,
when: HookWhen,
) -> Result<()> {
if plan.package_format != PackageFormat::Deb {
return Ok(());
}
let unincorp_path = get_unincorp_path(&plan.env_root);
let trigger_activations = read_unincorp_file(&unincorp_path)?;
if trigger_activations.is_empty() {
return Ok(());
}
let (triggers_to_consume, triggers_remaining) = separate_unincorp_triggers(trigger_activations, when)?;
if triggers_to_consume.is_empty() {
return Ok(());
}
run_unincorp_trigger_hooks(plan, &triggers_to_consume)?;
write_unincorp_file(&unincorp_path, &triggers_remaining)?;
Ok(())
}