zoi-rs 1.18.3

Advanced Package Manager & Environment Orchestrator
Documentation
use crate::pkg::sysroot;
use crate::utils;
use anyhow::{Result, anyhow};
use colored::*;
use glob::Pattern;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

include!(concat!(env!("OUT_DIR"), "/generated_builtin_hooks.rs"));

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GlobalHook {
    pub name: String,
    pub description: String,
    pub platforms: Option<Vec<String>>,
    pub trigger: HookTrigger,
    pub action: HookAction,
    #[serde(skip)]
    pub is_builtin: bool,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HookTrigger {
    #[serde(default)]
    pub paths: Vec<String>,
    #[serde(default)]
    pub dirs: Vec<String>,
    #[serde(default)]
    pub operation: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HookAction {
    pub when: HookWhen,
    pub exec: String,
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub enum HookWhen {
    #[serde(rename = "PreTransaction")]
    PreTransaction,
    #[serde(rename = "PostTransaction")]
    PostTransaction,
}

pub fn get_user_hooks_dir() -> Result<PathBuf> {
    let home = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?;
    let dir = home.join(".zoi").join("hooks");
    if !dir.exists() {
        fs::create_dir_all(&dir)?;
    }
    Ok(dir)
}

pub fn get_system_hooks_dir() -> Result<PathBuf> {
    if cfg!(windows) {
        Ok(sysroot::apply_sysroot(PathBuf::from(
            "C:\\ProgramData\\zoi\\hooks",
        )))
    } else {
        Ok(sysroot::apply_sysroot(PathBuf::from("/etc/zoi/hooks")))
    }
}

pub fn load_all_hooks() -> Result<Vec<GlobalHook>> {
    let mut hook_map = HashMap::new();

    for (name, content) in BUILTIN_HOOKS {
        if let Ok(mut hook) = serde_yaml::from_str::<GlobalHook>(content) {
            hook.is_builtin = true;
            hook_map.insert(hook.name.clone(), hook);
        } else {
            eprintln!(
                "{}: Failed to parse builtin hook '{}'.",
                "Warning".yellow().bold(),
                name
            );
        }
    }

    let dirs = vec![get_system_hooks_dir()?, get_user_hooks_dir()?];

    for dir in dirs {
        if !dir.exists() {
            continue;
        }
        let mut hook_paths = Vec::new();
        for entry in fs::read_dir(dir)? {
            let entry = entry?;
            hook_paths.push(entry.path());
        }
        hook_paths.sort();
        for path in hook_paths {
            if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("yaml") {
                let content = fs::read_to_string(&path)?;
                if let Ok(hook) = serde_yaml::from_str::<GlobalHook>(&content) {
                    hook_map.insert(hook.name.clone(), hook);
                }
            }
        }
    }

    let mut hooks: Vec<GlobalHook> = hook_map.into_values().collect();
    hooks.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(hooks)
}

fn normalized_relative_path(file: &str, sysroot: Option<&Path>) -> String {
    let file_path = Path::new(file);
    let relative_file = if let Some(root) = sysroot {
        file_path.strip_prefix(root).unwrap_or(file_path)
    } else if file_path.is_absolute() {
        let mut components = file_path.components();
        components.next();
        components.as_path()
    } else {
        file_path
    };

    relative_file
        .to_string_lossy()
        .replace('\\', "/")
        .trim_start_matches("./")
        .trim_start_matches('/')
        .to_string()
}

fn normalized_hook_path(path: &str) -> String {
    path.replace('\\', "/")
        .trim_start_matches("./")
        .trim_start_matches('/')
        .trim_end_matches('/')
        .to_string()
}

fn matches_trigger_dir(dir: &str, modified_file: &str) -> bool {
    let dir = normalized_hook_path(dir);
    !dir.is_empty()
        && (modified_file == dir
            || modified_file
                .strip_prefix(&dir)
                .is_some_and(|suffix| suffix.starts_with('/')))
}

pub fn trigger_matches_modified_files(trigger: &HookTrigger, modified_files: &[String]) -> bool {
    let sysroot = sysroot::get_sysroot();

    for file in modified_files {
        let relative_file = normalized_relative_path(file, sysroot.as_deref());

        for dir in &trigger.dirs {
            if matches_trigger_dir(dir, &relative_file) {
                return true;
            }
        }

        for path_pattern in &trigger.paths {
            let pattern = match Pattern::new(path_pattern) {
                Ok(p) => p,
                Err(_) => continue,
            };

            if pattern.matches_path(Path::new(&relative_file))
                || pattern.matches(&relative_file)
                || pattern.matches(file)
            {
                return true;
            }
        }
    }

    false
}

fn is_hook_trusted(hook: &GlobalHook) -> Result<bool> {
    let mut hasher = Sha256::new();
    hasher.update(hook.action.exec.as_bytes());
    let hash = hex::encode(hasher.finalize());

    let trusted_path = get_user_hooks_dir()?.join("trusted_hashes.json");
    let mut trusted: HashMap<String, String> = if trusted_path.exists() {
        let content = fs::read_to_string(&trusted_path)?;
        serde_json::from_str(&content).unwrap_or_default()
    } else {
        HashMap::new()
    };

    if let Some(known_hash) = trusted.get(&hook.name)
        && known_hash == &hash
    {
        return Ok(true);
    }

    println!(
        "\n{}: Untrusted global hook detected: {}",
        "SECURITY WARNING".yellow().bold(),
        hook.name.cyan()
    );
    println!("Description: {}", hook.description);
    println!("Execution: {}", hook.action.exec.dimmed());
    println!("Hooks can execute arbitrary commands with your user's permissions.");

    if crate::utils::ask_for_confirmation("Do you trust this hook and want to execute it?", false) {
        trusted.insert(hook.name.clone(), hash);
        let content = serde_json::to_string_pretty(&trusted)?;
        fs::write(trusted_path, content)?;
        Ok(true)
    } else {
        Ok(false)
    }
}

pub fn run_global_hooks(when: HookWhen, modified_files: &[String], operation: &str) -> Result<()> {
    let all_hooks = load_all_hooks()?;
    let mut triggered_hooks = HashSet::new();
    let current_platform = utils::get_platform()?;

    for hook in all_hooks {
        if hook.action.when != when {
            continue;
        }

        if let Some(platforms) = &hook.platforms
            && !utils::is_platform_compatible(&current_platform, platforms)
        {
            continue;
        }

        if !hook.trigger.operation.is_empty()
            && !hook.trigger.operation.iter().any(|op| op == operation)
        {
            continue;
        }

        if trigger_matches_modified_files(&hook.trigger, modified_files)
            && triggered_hooks.insert(hook.name.clone())
        {
            if !hook.is_builtin && !is_hook_trusted(&hook)? {
                println!("Skipping untrusted hook: {}", hook.name);
                continue;
            }

            println!(
                "{} Running global hook: {} ({})",
                "::".blue().bold(),
                hook.name.cyan(),
                hook.description.dimmed()
            );

            let status = if cfg!(target_os = "windows") {
                Command::new("pwsh")
                    .arg("-Command")
                    .arg(&hook.action.exec)
                    .status()?
            } else {
                Command::new("bash")
                    .arg("-c")
                    .arg(&hook.action.exec)
                    .status()?
            };

            if !status.success() {
                eprintln!(
                    "{}: Global hook '{}' failed.",
                    "Warning".yellow().bold(),
                    hook.name
                );
            }
        }
    }

    Ok(())
}