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#[derive(Debug, Serialize, Deserialize, Clone)]
26pub struct GlobalHook {
27 pub name: String,
29 pub description: String,
31 pub platforms: Option<Vec<String>>,
33 pub trigger: HookTrigger,
35 pub action: HookAction,
37 #[serde(skip)]
39 pub is_builtin: bool
40}
41
42#[derive(Debug, Serialize, Deserialize, Clone)]
44pub struct HookTrigger {
45 #[serde(default)]
47 pub paths: Vec<String>,
48 #[serde(default)]
50 pub dirs: Vec<String>,
51 #[serde(default)]
54 pub operation: Vec<String>,
55 #[serde(default)]
57 pub packages: Vec<String>
58}
59
60#[derive(Debug, Serialize, Deserialize, Clone)]
62pub struct HookAction {
63 pub when: HookWhen,
65 pub exec: String
67}
68
69#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
71pub enum HookWhen {
72 #[serde(rename = "PreTransaction")]
74 PreTransaction,
75 #[serde(rename = "PostTransaction")]
77 PostTransaction
78}
79
80pub 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
94pub fn get_system_hooks_dir() -> Result<PathBuf> {
101 Ok(utils::get_system_config_dir().join("hooks"))
102}
103
104pub 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 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 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 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
206fn 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
228fn 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
237fn 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
247pub 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 for pkg in modified_packages {
257 for pkg_pattern in &trigger.packages {
258 if pkg == pkg_pattern {
259 return true;
260 }
261 }
262 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 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
306fn 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
351pub 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(¤t_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 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 &[], 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}