Skip to main content

zoi_hooks/
global.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::Result;
7use colored::Colorize;
8use glob::Pattern;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use zoi_core::{sysroot, types, utils};
12
13include!(concat!(env!("OUT_DIR"), "/generated_builtin_hooks.rs"));
14
15/// Manages system-wide "Global Transaction Hooks".
16///
17/// Unlike package-specific hooks, global hooks are triggered based on the
18/// file paths modified during a transaction. For example, if any package
19/// touches a file in `/usr/share/fonts`, a global hook can automatically
20/// run `fc-cache` exactly once at the end of the transaction.
21///
22/// Hooks are verified against a local trust database (`trusted_hashes.json`)
23/// before execution to prevent unauthorized arbitrary command execution.
24
25#[derive(Debug, Serialize, Deserialize, Clone)]
26pub struct GlobalHook {
27    /// The unique name of the hook.
28    pub name: String,
29    /// A human-readable description of what the hook does.
30    pub description: String,
31    /// Optional list of compatible platforms (e.g. `["linux", "macos"]`).
32    pub platforms: Option<Vec<String>>,
33    /// The conditions that trigger this hook.
34    pub trigger: HookTrigger,
35    /// The action to perform when the hook is triggered.
36    pub action: HookAction,
37    /// Whether this is a builtin hook provided by Zoi.
38    #[serde(skip)]
39    pub is_builtin: bool
40}
41
42/// Defines when a global hook should be triggered.
43#[derive(Debug, Serialize, Deserialize, Clone)]
44pub struct HookTrigger {
45    /// Glob patterns for file paths that trigger the hook.
46    #[serde(default)]
47    pub paths: Vec<String>,
48    /// Directories that trigger the hook if any file within them is modified.
49    #[serde(default)]
50    pub dirs: Vec<String>,
51    /// The operation types (e.g. "install", "upgrade", "remove") that trigger
52    /// the hook.
53    #[serde(default)]
54    pub operation: Vec<String>,
55    /// Specific package names that trigger the hook when modified.
56    #[serde(default)]
57    pub packages: Vec<String>
58}
59
60/// Defines the action to take when a global hook is triggered.
61#[derive(Debug, Serialize, Deserialize, Clone)]
62pub struct HookAction {
63    /// When the hook should run relative to the transaction.
64    pub when: HookWhen,
65    /// The shell command to execute.
66    pub exec: String
67}
68
69/// When a global hook should run.
70#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
71pub enum HookWhen {
72    /// Runs before any package operations in the transaction.
73    #[serde(rename = "PreTransaction")]
74    PreTransaction,
75    /// Runs after all package operations in the transaction have completed.
76    #[serde(rename = "PostTransaction")]
77    PostTransaction
78}
79
80/// Gets the directory where user-specific hooks are stored.
81///
82/// # Errors
83///
84/// Returns an error if the user home directory cannot be found or if creating
85/// the hooks directory fails.
86pub fn get_user_hooks_dir() -> Result<PathBuf> {
87    let dir = utils::get_user_data_dir()?.join("hooks");
88    if !dir.exists() {
89        fs::create_dir_all(&dir)?;
90    }
91    Ok(dir)
92}
93
94/// Gets the directory where system-wide hooks are stored.
95///
96/// # Errors
97///
98/// This function is currently infallible but returns a `Result` for
99/// consistency.
100pub fn get_system_hooks_dir() -> Result<PathBuf> {
101    Ok(utils::get_system_config_dir().join("hooks"))
102}
103
104/// Loads all available global hooks from builtin, system, user, and package
105/// store locations.
106///
107/// # Errors
108///
109/// Returns an error if loading hooks from system, user, or package store
110/// directories fails.
111pub fn load_all_hooks() -> Result<Vec<GlobalHook>> {
112    let mut hook_map = HashMap::new();
113
114    for (name, content) in BUILTIN_HOOKS {
115        if let Ok(mut hook) = serde_yaml::from_str::<GlobalHook>(content) {
116            hook.is_builtin = true;
117            hook_map.insert(hook.name.clone(), hook);
118        } else {
119            eprintln!(
120                "{}: Failed to parse builtin hook '{}'.",
121                "Warning".yellow().bold(),
122                name
123            );
124        }
125    }
126
127    let mut dirs = vec![get_system_hooks_dir()?, get_user_hooks_dir()?];
128
129    // Scan the package store for bundled hooks
130    for scope in [
131        types::Scope::System,
132        types::Scope::User,
133        types::Scope::Project
134    ] {
135        if let Ok(store_root) = utils::get_store_base_dir(scope) {
136            if !store_root.exists() {
137                continue;
138            }
139            // Each package has a directory: {hash}-{name}/{version}/hooks/
140            if let Ok(pkg_dirs) = fs::read_dir(store_root) {
141                for pkg_dir_entry in pkg_dirs.flatten() {
142                    let pkg_dir = pkg_dir_entry.path();
143                    if !pkg_dir.is_dir() {
144                        continue;
145                    }
146                    // Iterate over version directories
147                    if let Ok(version_dirs) = fs::read_dir(&pkg_dir) {
148                        for version_dir_entry in version_dirs.flatten() {
149                            let version_dir = version_dir_entry.path();
150                            if !version_dir.is_dir()
151                                || version_dir
152                                    .file_name()
153                                    .and_then(|s| s.to_str())
154                                    == Some("latest")
155                                || version_dir
156                                    .file_name()
157                                    .and_then(|s| s.to_str())
158                                    == Some("dependents")
159                            {
160                                continue;
161                            }
162                            let hooks_dir = version_dir.join("hooks");
163                            if hooks_dir.exists() && hooks_dir.is_dir() {
164                                dirs.push(hooks_dir);
165                            }
166                        }
167                    }
168                }
169            }
170        }
171    }
172
173    for dir in dirs {
174        if !dir.exists() {
175            continue;
176        }
177        let mut hook_paths = Vec::new();
178        if let Ok(entries) = fs::read_dir(dir) {
179            for entry in entries.flatten() {
180                hook_paths.push(entry.path());
181            }
182        }
183        hook_paths.sort();
184        for path in hook_paths {
185            if path.is_file() {
186                let is_hook = path.to_string_lossy().ends_with(".hook.yaml")
187                    || path.extension().and_then(|s| s.to_str())
188                        == Some("yaml");
189
190                if is_hook
191                    && let Ok(content) = fs::read_to_string(&path)
192                    && let Ok(hook) =
193                        serde_yaml::from_str::<GlobalHook>(&content)
194                {
195                    hook_map.insert(hook.name.clone(), hook);
196                }
197            }
198        }
199    }
200
201    let mut hooks: Vec<GlobalHook> = hook_map.into_values().collect();
202    hooks.sort_by(|a, b| a.name.cmp(&b.name));
203    Ok(hooks)
204}
205
206/// Normalizes a file path to be relative to the sysroot and uses forward
207/// slashes.
208fn normalized_relative_path(file: &str, sysroot: Option<&Path>) -> String {
209    let file_path = Path::new(file);
210    let relative_file = if let Some(root) = sysroot {
211        file_path.strip_prefix(root).unwrap_or(file_path)
212    } else if file_path.is_absolute() {
213        let mut components = file_path.components();
214        components.next();
215        components.as_path()
216    } else {
217        file_path
218    };
219
220    relative_file
221        .to_string_lossy()
222        .replace('\\', "/")
223        .trim_start_matches("./")
224        .trim_start_matches('/')
225        .to_string()
226}
227
228/// Normalizes a hook trigger path.
229fn normalized_hook_path(path: &str) -> String {
230    path.replace('\\', "/")
231        .trim_start_matches("./")
232        .trim_start_matches('/')
233        .trim_end_matches('/')
234        .to_string()
235}
236
237/// Checks if a modified file matches a trigger directory.
238fn matches_trigger_dir(dir: &str, modified_file: &str) -> bool {
239    let dir = normalized_hook_path(dir);
240    !dir.is_empty()
241        && (modified_file == dir
242            || modified_file
243                .strip_prefix(&dir)
244                .is_some_and(|suffix| suffix.starts_with('/')))
245}
246
247/// Checks if a hook trigger matches any of the modified files or packages.
248pub fn trigger_matches_modified_files(
249    trigger: &HookTrigger,
250    modified_files: &[String],
251    modified_packages: &[String]
252) -> bool {
253    let sysroot = sysroot::get_sysroot();
254
255    // Check package name triggers first (fastest)
256    for pkg in modified_packages {
257        for pkg_pattern in &trigger.packages {
258            if pkg == pkg_pattern {
259                return true;
260            }
261        }
262        // Backward compatibility: some hooks might use 'paths' for package
263        // names
264        for path_pattern in &trigger.paths {
265            if pkg == path_pattern {
266                return true;
267            }
268        }
269    }
270
271    for file in modified_files {
272        let relative_file = normalized_relative_path(file, sysroot.as_deref());
273
274        for dir in &trigger.dirs {
275            if matches_trigger_dir(dir, &relative_file) {
276                return true;
277            }
278        }
279
280        for path_pattern in &trigger.paths {
281            // Support treating paths ending in / as directory triggers (Arch
282            // style)
283            if path_pattern.ends_with('/') {
284                let dir_pattern = path_pattern.trim_end_matches('/');
285                if matches_trigger_dir(dir_pattern, &relative_file) {
286                    return true;
287                }
288            }
289
290            let Ok(pattern) = Pattern::new(path_pattern) else {
291                continue;
292            };
293
294            if pattern.matches_path(Path::new(&relative_file))
295                || pattern.matches(&relative_file)
296                || pattern.matches(file)
297            {
298                return true;
299            }
300        }
301    }
302
303    false
304}
305
306/// Checks if a hook is trusted by comparing its hash against the trusted
307/// database.
308fn is_hook_trusted(hook: &GlobalHook) -> Result<bool> {
309    let mut hasher = Sha256::new();
310    hasher.update(hook.action.exec.as_bytes());
311    let hash = hex::encode(hasher.finalize());
312
313    let trusted_path = get_user_hooks_dir()?.join("trusted_hashes.json");
314    let mut trusted: HashMap<String, String> = if trusted_path.exists() {
315        let content = fs::read_to_string(&trusted_path)?;
316        serde_json::from_str(&content).unwrap_or_default()
317    } else {
318        HashMap::new()
319    };
320
321    if let Some(known_hash) = trusted.get(&hook.name)
322        && known_hash == &hash
323    {
324        return Ok(true);
325    }
326
327    println!(
328        "\n{}: Untrusted global hook detected: {}",
329        "SECURITY WARNING".yellow().bold(),
330        hook.name.cyan()
331    );
332    println!("Description: {}", hook.description);
333    println!("Execution: {}", hook.action.exec.dimmed());
334    println!(
335        "Hooks can execute arbitrary commands with your user's permissions."
336    );
337
338    if utils::ask_for_confirmation(
339        "Do you trust this hook and want to execute it?",
340        false
341    ) {
342        trusted.insert(hook.name.clone(), hash);
343        let content = serde_json::to_string_pretty(&trusted)?;
344        fs::write(trusted_path, content)?;
345        Ok(true)
346    } else {
347        Ok(false)
348    }
349}
350
351/// Runs all global hooks that match the given criteria.
352///
353/// # Arguments
354///
355/// * `when` - Whether to run pre-transaction or post-transaction hooks.
356/// * `modified_files` - List of files modified during the transaction.
357/// * `modified_packages` - List of packages modified during the transaction.
358/// * `operation` - The type of operation being performed (e.g. "install").
359/// * `scope` - The installation scope.
360///
361/// # Errors
362///
363/// Returns an error if loading hooks, getting the current platform, or
364/// executing a hook command fails.
365pub fn run_global_hooks(
366    when: HookWhen,
367    modified_files: &[String],
368    modified_packages: &[String],
369    operation: &str,
370    scope: types::Scope
371) -> Result<()> {
372    let all_hooks = load_all_hooks()?;
373    let mut triggered_hooks = HashSet::new();
374    let current_platform = utils::get_platform()?;
375
376    let scope_str = format!("{scope:?}").to_lowercase();
377
378    for hook in all_hooks {
379        if hook.action.when != when {
380            continue;
381        }
382
383        if let Some(platforms) = &hook.platforms
384            && !utils::is_platform_compatible(&current_platform, platforms)
385        {
386            continue;
387        }
388
389        if !hook.trigger.operation.is_empty()
390            && !hook.trigger.operation.iter().any(|op| op == operation)
391        {
392            continue;
393        }
394
395        if trigger_matches_modified_files(
396            &hook.trigger,
397            modified_files,
398            modified_packages
399        ) && triggered_hooks.insert(hook.name.clone())
400        {
401            if !hook.is_builtin && !is_hook_trusted(&hook)? {
402                println!("Skipping untrusted hook: {}", hook.name);
403                continue;
404            }
405
406            println!(
407                "{} Running global hook: {} ({})",
408                "::".blue().bold(),
409                hook.name.cyan(),
410                hook.description.dimmed()
411            );
412
413            #[cfg(target_os = "linux")]
414            let mut command = {
415                let sysroot = zoi_core::sysroot::get_sysroot();
416                if let Some(root) = sysroot {
417                    let mut envs = HashMap::new();
418                    envs.insert("ZOI_SCOPE".to_string(), scope_str.clone());
419                    // Hooks usually expect a basic PATH inside the root
420                    envs.insert(
421                        "PATH".to_string(),
422                        "/usr/bin:/bin:/usr/sbin:/sbin".to_string()
423                    );
424
425                    zoi_sandbox::wrap_command_in_root(
426                        &root,
427                        Path::new(
428                            &hook
429                                .action
430                                .exec
431                                .split_whitespace()
432                                .next()
433                                .unwrap_or("")
434                        ),
435                        &hook
436                            .action
437                            .exec
438                            .split_whitespace()
439                            .skip(1)
440                            .map(std::string::ToString::to_string)
441                            .collect::<Vec<_>>(),
442                        &envs,
443                        &[], // No extra binds for standard hooks
444                        false
445                    )?
446                } else {
447                    let mut c = Command::new("bash");
448                    c.arg("-c").arg(&hook.action.exec);
449                    c.env("ZOI_SCOPE", &scope_str);
450                    c
451                }
452            };
453
454            #[cfg(not(target_os = "linux"))]
455            let mut command = {
456                let mut c = if cfg!(target_os = "windows") {
457                    let mut cmd = Command::new("pwsh");
458                    cmd.arg("-Command").arg(&hook.action.exec);
459                    cmd
460                } else {
461                    let mut cmd = Command::new("bash");
462                    cmd.arg("-c").arg(&hook.action.exec);
463                    cmd
464                };
465                c.env("ZOI_SCOPE", &scope_str);
466                c
467            };
468
469            let status = command.status()?;
470
471            if !status.success() {
472                eprintln!(
473                    "{}: Global hook '{}' failed.",
474                    "Warning".yellow().bold(),
475                    hook.name
476                );
477            }
478        }
479    }
480
481    Ok(())
482}