Skip to main content

zoi_plugins/
lib.rs

1//! Plugin management system for Zoi.
2//!
3//! This crate provides the `PluginManager` which handles loading and executing
4//! Lua plugins that extend Zoi's functionality. It also includes support for
5//! extensions that can modify registry configurations.
6
7/// Registry extension management.
8pub mod extension;
9
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Result, anyhow};
15use colored::Colorize;
16use comfy_table::Table as ComfyTable;
17use comfy_table::presets::UTF8_FULL;
18use dialoguer::theme::ColorfulTheme;
19use dialoguer::{Confirm, Select};
20use mlua::{Function, Lua, LuaSerdeExt, Table, Value};
21use sha2::{Digest, Sha256};
22use zoi_core::{types, utils};
23use zoi_project as project;
24use zoi_resolver::{local, resolve};
25
26/// Key used in the Lua globals to store environment variable overrides.
27const PLUGIN_ENV_OVERRIDES_KEY: &str = "__ZOI_ENV_OVERRIDES";
28
29/// Orchestrates Zoi's extensibility via global Lua plugins.
30///
31/// Plugins allow users to customize Zoi's behavior by:
32/// - Registering new subcommands (`zoi.register_command`).
33/// - Intercepting lifecycle events (`zoi.on_post_install`, etc.).
34/// - Overriding tool versions at runtime (shim resolution).
35///
36/// Plugins are stored in Zoi's user data directory and are verified against
37/// a `trusted_hashes.json` database to prevent unauthorized execution.
38pub struct PluginManager {
39    /// The initialized mlua Lua Virtual Machine.
40    pub lua: Lua
41}
42
43impl PluginManager {
44    /// Initializes a new `PluginManager` and sets up the global Lua API.
45    ///
46    /// This injects the entire `zoi.*` API surface into the Lua environment,
47    /// enabling plugins to interact with the filesystem, HTTP, archives,
48    /// the UI, and the hook registry.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the Lua VM cannot be initialized or if the `zoi` API
53    /// fails to be injected.
54    pub fn new() -> Result<Self> {
55        let lua = Lua::new();
56        let manager = Self { lua };
57        manager.setup_api()?;
58        Ok(manager)
59    }
60
61    /// Sets up the global `zoi` API in the Lua environment.
62    fn setup_api(&self) -> Result<()> {
63        let zoi = self
64            .lua
65            .create_table()
66            .map_err(|e| anyhow!(e.to_string()))?;
67
68        self.lua
69            .globals()
70            .set(
71                "__ZOI_COMMANDS",
72                self.lua
73                    .create_table()
74                    .map_err(|e| anyhow!(e.to_string()))?
75            )
76            .map_err(|e| anyhow!(e.to_string()))?;
77        self.lua
78            .globals()
79            .set(
80                "__ZOI_COMMAND_HELP",
81                self.lua
82                    .create_table()
83                    .map_err(|e| anyhow!(e.to_string()))?
84            )
85            .map_err(|e| anyhow!(e.to_string()))?;
86
87        let register_command = self
88            .lua
89            .create_function(|lua, arg: Value| {
90                let registry: Table = lua.globals().get("__ZOI_COMMANDS")?;
91                let help_registry: Table =
92                    lua.globals().get("__ZOI_COMMAND_HELP")?;
93                match arg {
94                    Value::Table(t) => {
95                        let name: String = t.get("name")?;
96                        let desc: String = t
97                            .get("description")
98                            .unwrap_or_else(|_| String::new());
99                        let callback: Function = t.get("callback")?;
100                        registry.set(name.clone(), callback)?;
101                        help_registry.set(name, desc)?;
102                    }
103                    _ => {
104                        return Err(mlua::Error::RuntimeError(
105                            "Invalid argument to register_command. Expected a \
106                             table {name, description, callback}"
107                                .to_string()
108                        ));
109                    }
110                }
111                Ok(())
112            })
113            .map_err(|e| anyhow!(e.to_string()))?;
114        zoi.set("register_command", register_command)
115            .map_err(|e| anyhow!(e.to_string()))?;
116
117        let register_command_simple = self
118            .lua
119            .create_function(|lua, (name, callback): (String, Function)| {
120                let registry: Table = lua.globals().get("__ZOI_COMMANDS")?;
121                registry.set(name, callback)?;
122                Ok(())
123            })
124            .map_err(|e| anyhow!(e.to_string()))?;
125        zoi.set("register_command_simple", register_command_simple)
126            .map_err(|e| anyhow!(e.to_string()))?;
127
128        self.lua
129            .globals()
130            .set(
131                "__ZOI_HOOKS",
132                self.lua
133                    .create_table()
134                    .map_err(|e| anyhow!(e.to_string()))?
135            )
136            .map_err(|e| anyhow!(e.to_string()))?;
137        self.lua
138            .globals()
139            .set(
140                PLUGIN_ENV_OVERRIDES_KEY,
141                self.lua
142                    .create_table()
143                    .map_err(|e| anyhow!(e.to_string()))?
144            )
145            .map_err(|e| anyhow!(e.to_string()))?;
146        let hooks = [
147            "on_pre_install",
148            "on_post_install",
149            "on_pre_uninstall",
150            "on_post_uninstall",
151            "on_pre_sync",
152            "on_post_sync",
153            "on_rollback",
154            "on_pre_create",
155            "on_post_create",
156            "on_pre_extension_add",
157            "on_post_extension_add",
158            "on_pre_extension_remove",
159            "on_post_extension_remove",
160            "on_resolve_shim_version",
161            "on_project_install"
162        ];
163        for hook in hooks {
164            let hook_name = hook.to_string();
165            let register_hook = self
166                .lua
167                .create_function(move |lua, callback: Function| {
168                    let registry: Table = lua.globals().get("__ZOI_HOOKS")?;
169                    let hook_list: Table =
170                        if let Ok(t) = registry.get(hook_name.as_str()) {
171                            t
172                        } else {
173                            let t = lua.create_table()?;
174                            registry.set(hook_name.as_str(), t.clone())?;
175                            t
176                        };
177                    hook_list.push(callback)?;
178                    Ok(())
179                })
180                .map_err(|e| anyhow!(e.to_string()))?;
181            zoi.set(hook, register_hook)
182                .map_err(|e| anyhow!(e.to_string()))?;
183        }
184
185        let set_data = self
186            .lua
187            .create_function(|_, (key, value): (String, Value)| {
188                let mut state = read_plugin_state().unwrap_or_default();
189                let json_val: serde_json::Value = match value {
190                    Value::String(s) => {
191                        serde_json::Value::String(s.to_str()?.to_string())
192                    }
193                    Value::Integer(i) => serde_json::Value::Number(i.into()),
194                    Value::Number(n) => {
195                        let Some(num) = serde_json::Number::from_f64(n) else {
196                            return Err(mlua::Error::RuntimeError(
197                                "Non-finite numbers are not supported for \
198                                 set_data"
199                                    .to_string()
200                            ));
201                        };
202                        serde_json::Value::Number(num)
203                    }
204                    Value::Boolean(b) => serde_json::Value::Bool(b),
205                    _ => {
206                        return Err(mlua::Error::RuntimeError(
207                            "Unsupported value type for set_data".to_string()
208                        ));
209                    }
210                };
211                state.insert(key, json_val);
212                write_plugin_state(&state)
213                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
214                Ok(())
215            })
216            .map_err(|e| anyhow!(e.to_string()))?;
217        zoi.set("set_data", set_data)
218            .map_err(|e| anyhow!(e.to_string()))?;
219
220        let get_data = self
221            .lua
222            .create_function(|lua, key: String| {
223                let state = read_plugin_state().unwrap_or_default();
224                if let Some(val) = state.get(&key) {
225                    lua.to_value(val)
226                } else {
227                    Ok(Value::Nil)
228                }
229            })
230            .map_err(|e| anyhow!(e.to_string()))?;
231        zoi.set("get_data", get_data)
232            .map_err(|e| anyhow!(e.to_string()))?;
233
234        let list_installed = self
235            .lua
236            .create_function(|lua, ()| {
237                let installed = local::get_installed_packages()
238                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
239                lua.to_value(&installed)
240            })
241            .map_err(|e| anyhow!(e.to_string()))?;
242        zoi.set("list_installed", list_installed)
243            .map_err(|e| anyhow!(e.to_string()))?;
244
245        let get_package = self
246            .lua
247            .create_function(|lua, name: String| {
248                let (pkg, _, _, _, _, _, _) =
249                    resolve::resolve_package_and_version(
250                        &name, None, true, false
251                    )
252                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
253                lua.to_value(&pkg)
254            })
255            .map_err(|e| anyhow!(e.to_string()))?;
256        zoi.set("get_package", get_package)
257            .map_err(|e| anyhow!(e.to_string()))?;
258
259        if let Ok(config) = project::config::load() {
260            let project_table = self
261                .lua
262                .create_table()
263                .map_err(|e| anyhow!(e.to_string()))?;
264            project_table
265                .set("name", config.name)
266                .map_err(|e| anyhow!(e.to_string()))?;
267            project_table
268                .set("packages", config.pkgs)
269                .map_err(|e| anyhow!(e.to_string()))?;
270            zoi.set("project", project_table)
271                .map_err(|e| anyhow!(e.to_string()))?;
272        }
273
274        let ui = self
275            .lua
276            .create_table()
277            .map_err(|e| anyhow!(e.to_string()))?;
278        let ui_print = self
279            .lua
280            .create_function(|_, (text, color): (String, Option<String>)| {
281                let colored_text = match color.as_deref() {
282                    Some("red") => text.red(),
283                    Some("green") => text.green(),
284                    Some("yellow") => text.yellow(),
285                    Some("blue") => text.blue(),
286                    Some("cyan") => text.cyan(),
287                    Some("magenta") => text.magenta(),
288                    _ => text.normal()
289                };
290                println!("{colored_text}");
291                Ok(())
292            })
293            .map_err(|e| anyhow!(e.to_string()))?;
294        ui.set("print", ui_print)
295            .map_err(|e| anyhow!(e.to_string()))?;
296
297        let ui_confirm = self
298            .lua
299            .create_function(|_, prompt: String| {
300                Ok(Confirm::with_theme(&ColorfulTheme::default())
301                    .with_prompt(prompt)
302                    .interact()
303                    .unwrap_or(false))
304            })
305            .map_err(|e| anyhow!(e.to_string()))?;
306        ui.set("confirm", ui_confirm)
307            .map_err(|e| anyhow!(e.to_string()))?;
308
309        let ui_select = self
310            .lua
311            .create_function(|_, (prompt, options): (String, Vec<String>)| {
312                let selection = Select::with_theme(&ColorfulTheme::default())
313                    .with_prompt(prompt)
314                    .items(&options)
315                    .default(0)
316                    .interact_opt()
317                    .unwrap_or(None);
318                Ok(selection.map(|s| s + 1))
319            })
320            .map_err(|e| anyhow!(e.to_string()))?;
321        ui.set("select", ui_select)
322            .map_err(|e| anyhow!(e.to_string()))?;
323
324        let ui_table = self
325            .lua
326            .create_function(
327                |_, (headers, rows): (Vec<String>, Vec<Vec<String>>)| {
328                    let mut table = ComfyTable::new();
329                    table.load_style(UTF8_FULL).set_header(headers);
330                    for row in rows {
331                        table.add_row(row);
332                    }
333                    println!("{table}");
334                    Ok(())
335                }
336            )
337            .map_err(|e| anyhow!(e.to_string()))?;
338        ui.set("table", ui_table)
339            .map_err(|e| anyhow!(e.to_string()))?;
340        zoi.set("ui", ui).map_err(|e| anyhow!(e.to_string()))?;
341
342        let system = self
343            .lua
344            .create_table()
345            .map_err(|e| anyhow!(e.to_string()))?;
346        let platform = utils::get_platform()
347            .unwrap_or_else(|_| "unknown-unknown".to_string());
348        let parts: Vec<&str> = platform.split('-').collect();
349        system
350            .set("os", parts.first().unwrap_or(&"unknown").to_string())
351            .map_err(|e| anyhow!(e.to_string()))?;
352        system
353            .set("arch", parts.get(1).unwrap_or(&"unknown").to_string())
354            .map_err(|e| anyhow!(e.to_string()))?;
355        if let Some(distro) = utils::get_linux_distribution() {
356            system
357                .set("distro", distro)
358                .map_err(|e| anyhow!(e.to_string()))?;
359        }
360        if let Some(dv) = utils::get_distro_version() {
361            system
362                .set("distro_ver", dv)
363                .map_err(|e| anyhow!(e.to_string()))?;
364        }
365
366        zoi.set("system", system)
367            .map_err(|e| anyhow!(e.to_string()))?;
368        zoi.set("version", env!("CARGO_PKG_VERSION"))
369            .map_err(|e| anyhow!(e.to_string()))?;
370        zoi.set("scope", "user") // Default
371            .map_err(|e| anyhow!(e.to_string()))?;
372
373        let shell = self
374            .lua
375            .create_function(|lua, cmd: String| {
376                let env_overrides: Table = lua
377                    .globals()
378                    .get(PLUGIN_ENV_OVERRIDES_KEY)
379                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
380
381                let mut command = if cfg!(target_os = "windows") {
382                    let mut c = std::process::Command::new("pwsh");
383                    c.arg("-Command").arg(&cmd);
384                    c
385                } else {
386                    let mut c = std::process::Command::new("bash");
387                    c.arg("-c").arg(&cmd);
388                    c
389                };
390
391                for pair in env_overrides.pairs::<String, String>() {
392                    let (key, value) = pair.map_err(|e| {
393                        mlua::Error::RuntimeError(e.to_string())
394                    })?;
395                    command.env(key, value);
396                }
397
398                if let Ok(zoi_table) = lua.globals().get::<Table>("zoi")
399                    && let Ok(scope_str) = zoi_table.get::<String>("scope")
400                {
401                    command.env("ZOI_SCOPE", scope_str);
402                }
403
404                let status = command.status();
405                match status {
406                    Ok(s) => Ok(s.code().unwrap_or(i32::from(!s.success()))),
407                    Err(e) => Err(mlua::Error::RuntimeError(e.to_string()))
408                }
409            })
410            .map_err(|e| anyhow!(e.to_string()))?;
411        zoi.set("sh", shell).map_err(|e| anyhow!(e.to_string()))?;
412
413        let fs_table = self
414            .lua
415            .create_table()
416            .map_err(|e| anyhow!(e.to_string()))?;
417        let fs_read = self
418            .lua
419            .create_function(
420                |_, path: String| Ok(fs::read_to_string(path).ok())
421            )
422            .map_err(|e| anyhow!(e.to_string()))?;
423        fs_table
424            .set("read", fs_read)
425            .map_err(|e| anyhow!(e.to_string()))?;
426
427        let fs_write = self
428            .lua
429            .create_function(|_, (path, content): (String, String)| {
430                Ok(fs::write(path, content).is_ok())
431            })
432            .map_err(|e| anyhow!(e.to_string()))?;
433        fs_table
434            .set("write", fs_write)
435            .map_err(|e| anyhow!(e.to_string()))?;
436
437        let fs_exists = self
438            .lua
439            .create_function(|_, path: String| Ok(PathBuf::from(path).exists()))
440            .map_err(|e| anyhow!(e.to_string()))?;
441        fs_table
442            .set("exists", fs_exists)
443            .map_err(|e| anyhow!(e.to_string()))?;
444
445        let fs_list = self
446            .lua
447            .create_function(|lua, path: String| {
448                let mut entries = Vec::new();
449                if let Ok(read_dir) = fs::read_dir(path) {
450                    for entry in read_dir.flatten() {
451                        entries.push(
452                            entry.file_name().to_string_lossy().to_string()
453                        );
454                    }
455                }
456                lua.to_value(&entries)
457            })
458            .map_err(|e| anyhow!(e.to_string()))?;
459        fs_table
460            .set("list", fs_list)
461            .map_err(|e| anyhow!(e.to_string()))?;
462
463        let fs_delete = self
464            .lua
465            .create_function(|_, path: String| {
466                let p = PathBuf::from(path);
467                if p.is_dir() {
468                    Ok(fs::remove_dir_all(p).is_ok())
469                } else {
470                    Ok(fs::remove_file(p).is_ok())
471                }
472            })
473            .map_err(|e| anyhow!(e.to_string()))?;
474        fs_table
475            .set("delete", fs_delete)
476            .map_err(|e| anyhow!(e.to_string()))?;
477
478        let fs_symlink = self
479            .lua
480            .create_function(
481                |_, (target, link, is_dir): (String, String, bool)| {
482                    let target_path = PathBuf::from(target);
483                    let link_path = PathBuf::from(link);
484                    if is_dir {
485                        Ok(utils::symlink_dir(&target_path, &link_path).is_ok())
486                    } else {
487                        Ok(utils::symlink_file(&target_path, &link_path)
488                            .is_ok())
489                    }
490                }
491            )
492            .map_err(|e| anyhow!(e.to_string()))?;
493        fs_table
494            .set("symlink", fs_symlink)
495            .map_err(|e| anyhow!(e.to_string()))?;
496
497        let fs_copy = self
498            .lua
499            .create_function(|_, (src, dest): (String, String)| {
500                let src_path = Path::new(&src);
501                let dest_path = Path::new(&dest);
502                if src_path.is_dir() {
503                    Ok(utils::copy_dir_all(src_path, dest_path).is_ok())
504                } else {
505                    Ok(fs::copy(src_path, dest_path).is_ok())
506                }
507            })
508            .map_err(|e| anyhow!(e.to_string()))?;
509        fs_table
510            .set("copy", fs_copy)
511            .map_err(|e| anyhow!(e.to_string()))?;
512
513        zoi.set("fs", fs_table)
514            .map_err(|e| anyhow!(e.to_string()))?;
515
516        let archive_table = self
517            .lua
518            .create_table()
519            .map_err(|e| anyhow!(e.to_string()))?;
520        let archive_extract = self
521            .lua
522            .create_function(
523                |_, (source, dest, strip): (String, String, Option<usize>)| {
524                    fn safe_stripped_relative_path(
525                        path: &Path,
526                        strip: usize
527                    ) -> Result<Option<PathBuf>, std::io::Error>
528                    {
529                        let mut sanitized = PathBuf::new();
530                        let mut has_component = false;
531                        for component in path.components().skip(strip) {
532                            match component {
533                                std::path::Component::Normal(part) => {
534                                    sanitized.push(part);
535                                    has_component = true;
536                                }
537                                std::path::Component::CurDir => {}
538                                _ => {
539                                    return Err(std::io::Error::new(
540                                        std::io::ErrorKind::InvalidInput,
541                                        format!(
542                                            "Archive entry escapes \
543                                             destination: {}",
544                                            path.display()
545                                        )
546                                    ));
547                                }
548                            }
549                        }
550                        if has_component {
551                            Ok(Some(sanitized))
552                        } else {
553                            Ok(None)
554                        }
555                    }
556
557                    fn unpack_with_strip<R: std::io::Read>(
558                        mut archive: tar::Archive<R>,
559                        dest: &Path,
560                        strip: usize
561                    ) -> Result<(), std::io::Error> {
562                        for entry in archive.entries()? {
563                            let mut entry = entry?;
564                            let path = entry.path()?.to_path_buf();
565                            let Some(stripped_path) =
566                                safe_stripped_relative_path(&path, strip)?
567                            else {
568                                continue;
569                            };
570                            entry.unpack(dest.join(stripped_path))?;
571                        }
572                        Ok(())
573                    }
574
575                    let src_path = Path::new(&source);
576                    let dest_path = Path::new(&dest);
577
578                    if !dest_path.exists() {
579                        let _ = fs::create_dir_all(dest_path);
580                    }
581
582                    let file = fs::File::open(src_path).map_err(|e| {
583                        mlua::Error::RuntimeError(e.to_string())
584                    })?;
585                    let archive_path_str = source.to_lowercase();
586
587                    let strip_val = strip.unwrap_or(0);
588
589                    if std::path::Path::new(&archive_path_str)
590                        .extension()
591                        .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
592                    {
593                        let mut archive =
594                            zip::ZipArchive::new(file).map_err(|e| {
595                                mlua::Error::RuntimeError(e.to_string())
596                            })?;
597                        if strip_val > 0 {
598                            for i in 0..archive.len() {
599                                let mut file =
600                                    archive.by_index(i).map_err(|e| {
601                                        mlua::Error::RuntimeError(e.to_string())
602                                    })?;
603                                let path = PathBuf::from(
604                                    // Since zip 9.0 reading an entry name is
605                                    // fallible, because it is decoded
606                                    // according to the entry's encoding flags.
607                                    file.name()
608                                        .map_err(|e| {
609                                            mlua::Error::RuntimeError(
610                                                e.to_string()
611                                            )
612                                        })?
613                                        .into_owned()
614                                );
615                                let Some(stripped_path) =
616                                    safe_stripped_relative_path(
617                                        &path, strip_val
618                                    )
619                                    .map_err(|e| {
620                                        mlua::Error::RuntimeError(e.to_string())
621                                    })?
622                                else {
623                                    continue;
624                                };
625                                let out_path = dest_path.join(stripped_path);
626                                if file.is_dir() {
627                                    fs::create_dir_all(&out_path).map_err(
628                                        |e| {
629                                            mlua::Error::RuntimeError(
630                                                e.to_string()
631                                            )
632                                        }
633                                    )?;
634                                } else {
635                                    if let Some(p) = out_path.parent() {
636                                        fs::create_dir_all(p).map_err(|e| {
637                                            mlua::Error::RuntimeError(
638                                                e.to_string()
639                                            )
640                                        })?;
641                                    }
642                                    let mut outfile = fs::File::create(
643                                        &out_path
644                                    )
645                                    .map_err(|e| {
646                                        mlua::Error::RuntimeError(e.to_string())
647                                    })?;
648                                    std::io::copy(&mut file, &mut outfile)
649                                        .map_err(|e| {
650                                            mlua::Error::RuntimeError(
651                                                e.to_string()
652                                            )
653                                        })?;
654                                }
655                            }
656                        } else {
657                            archive.extract(dest_path).map_err(|e| {
658                                mlua::Error::RuntimeError(e.to_string())
659                            })?;
660                        }
661                    } else if archive_path_str.ends_with(".tar.gz")
662                        || std::path::Path::new(&archive_path_str)
663                            .extension()
664                            .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
665                    {
666                        let tar_gz = flate2::read::GzDecoder::new(file);
667                        let archive = tar::Archive::new(tar_gz);
668                        if strip_val > 0 {
669                            unpack_with_strip(archive, dest_path, strip_val)
670                                .map_err(|e| {
671                                    mlua::Error::RuntimeError(e.to_string())
672                                })?;
673                        } else {
674                            let mut archive = archive;
675                            archive.unpack(dest_path).map_err(|e| {
676                                mlua::Error::RuntimeError(e.to_string())
677                            })?;
678                        }
679                    } else if archive_path_str.ends_with(".tar.zst") {
680                        let tar_zst = zstd::stream::read::Decoder::new(file)
681                            .map_err(|e| {
682                                mlua::Error::RuntimeError(e.to_string())
683                            })?;
684                        let archive = tar::Archive::new(tar_zst);
685                        if strip_val > 0 {
686                            unpack_with_strip(archive, dest_path, strip_val)
687                                .map_err(|e| {
688                                    mlua::Error::RuntimeError(e.to_string())
689                                })?;
690                        } else {
691                            let mut archive = archive;
692                            archive.unpack(dest_path).map_err(|e| {
693                                mlua::Error::RuntimeError(e.to_string())
694                            })?;
695                        }
696                    } else if archive_path_str.ends_with(".tar.xz") {
697                        let tar_xz = xz2::read::XzDecoder::new(file);
698                        let archive = tar::Archive::new(tar_xz);
699                        if strip_val > 0 {
700                            unpack_with_strip(archive, dest_path, strip_val)
701                                .map_err(|e| {
702                                    mlua::Error::RuntimeError(e.to_string())
703                                })?;
704                        } else {
705                            let mut archive = archive;
706                            archive.unpack(dest_path).map_err(|e| {
707                                mlua::Error::RuntimeError(e.to_string())
708                            })?;
709                        }
710                    } else {
711                        return Err(mlua::Error::RuntimeError(format!(
712                            "Unsupported archive format: {source}"
713                        )));
714                    }
715                    Ok(true)
716                }
717            )
718            .map_err(|e| anyhow!(e.to_string()))?;
719        archive_table
720            .set("extract", archive_extract)
721            .map_err(|e| anyhow!(e.to_string()))?;
722        zoi.set("archive", archive_table)
723            .map_err(|e| anyhow!(e.to_string()))?;
724
725        let http_table = self
726            .lua
727            .create_table()
728            .map_err(|e| anyhow!(e.to_string()))?;
729        let http_get = self
730            .lua
731            .create_function(|_, url: String| {
732                let client = reqwest::blocking::Client::builder()
733                    .user_agent("zoi-plugin")
734                    .build()
735                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
736                match client.get(&url).send() {
737                    Ok(resp) => Ok(resp.text().ok()),
738                    Err(_) => Ok(None)
739                }
740            })
741            .map_err(|e| anyhow!(e.to_string()))?;
742        http_table
743            .set("get", http_get)
744            .map_err(|e| anyhow!(e.to_string()))?;
745
746        let http_download = self
747            .lua
748            .create_function(|_, (url, dest): (String, String)| {
749                let mut response = reqwest::blocking::get(&url)
750                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
751                if !response.status().is_success() {
752                    return Ok(false);
753                }
754                let mut dest_file = fs::File::create(dest)
755                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
756                std::io::copy(&mut response, &mut dest_file)
757                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
758                Ok(true)
759            })
760            .map_err(|e| anyhow!(e.to_string()))?;
761        http_table
762            .set("download", http_download)
763            .map_err(|e| anyhow!(e.to_string()))?;
764
765        let http_post = self
766            .lua
767            .create_function(|_, (url, body): (String, String)| {
768                let client = reqwest::blocking::Client::builder()
769                    .user_agent("zoi-plugin")
770                    .build()
771                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
772                match client.post(&url).body(body).send() {
773                    Ok(resp) => Ok(resp.text().ok()),
774                    Err(_) => Ok(None)
775                }
776            })
777            .map_err(|e| anyhow!(e.to_string()))?;
778        http_table
779            .set("post", http_post)
780            .map_err(|e| anyhow!(e.to_string()))?;
781        zoi.set("http", http_table)
782            .map_err(|e| anyhow!(e.to_string()))?;
783
784        let json_table = self
785            .lua
786            .create_table()
787            .map_err(|e| anyhow!(e.to_string()))?;
788        let json_parse = self
789            .lua
790            .create_function(|lua, json_str: String| {
791                let parsed: serde_json::Value = serde_json::from_str(&json_str)
792                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
793                lua.to_value(&parsed)
794            })
795            .map_err(|e| anyhow!(e.to_string()))?;
796        json_table
797            .set("parse", json_parse)
798            .map_err(|e| anyhow!(e.to_string()))?;
799
800        let json_stringify = self
801            .lua
802            .create_function(|lua, value: Value| {
803                let json_val: serde_json::Value = lua
804                    .from_value(value)
805                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
806                Ok(serde_json::to_string(&json_val).unwrap_or_default())
807            })
808            .map_err(|e| anyhow!(e.to_string()))?;
809        json_table
810            .set("stringify", json_stringify)
811            .map_err(|e| anyhow!(e.to_string()))?;
812        zoi.set("json", json_table)
813            .map_err(|e| anyhow!(e.to_string()))?;
814
815        let env_table = self
816            .lua
817            .create_table()
818            .map_err(|e| anyhow!(e.to_string()))?;
819        let env_get = self
820            .lua
821            .create_function(|lua, name: String| {
822                let env_overrides: Table = lua
823                    .globals()
824                    .get(PLUGIN_ENV_OVERRIDES_KEY)
825                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
826
827                if let Some(value) = env_overrides
828                    .get::<Option<String>>(name.as_str())
829                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
830                {
831                    return Ok(Some(value));
832                }
833
834                Ok(std::env::var(name).ok())
835            })
836            .map_err(|e| anyhow!(e.to_string()))?;
837        env_table
838            .set("get", env_get)
839            .map_err(|e| anyhow!(e.to_string()))?;
840
841        let env_set = self
842            .lua
843            .create_function(|lua, (name, value): (String, String)| {
844                let env_overrides: Table = lua
845                    .globals()
846                    .get(PLUGIN_ENV_OVERRIDES_KEY)
847                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
848                env_overrides
849                    .set(name, value)
850                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
851                Ok(())
852            })
853            .map_err(|e| anyhow!(e.to_string()))?;
854        env_table
855            .set("set", env_set)
856            .map_err(|e| anyhow!(e.to_string()))?;
857        zoi.set("env", env_table)
858            .map_err(|e| anyhow!(e.to_string()))?;
859
860        self.lua
861            .globals()
862            .set("zoi", zoi)
863            .map_err(|e| anyhow!(e.to_string()))?;
864
865        let plugin_dir = get_plugin_dir()?;
866        let import_fn = self
867            .lua
868            .create_function(move |lua, file_name: String| {
869                // `IMPORT` is documented as "load a file that sits next to
870                // the plugin", but a plain `join` also accepts an absolute
871                // path or `../` segments, which would let a plugin read any
872                // file on the host. `safe_join` enforces the boundary.
873                let path = zoi_core::utils::safe_join(
874                    &plugin_dir,
875                    Path::new(&file_name)
876                )
877                .map_err(|e| {
878                    mlua::Error::RuntimeError(format!(
879                        "Invalid import path '{file_name}': {e}"
880                    ))
881                })?;
882                if !path.exists() {
883                    return Err(mlua::Error::RuntimeError(format!(
884                        "File not found: {}",
885                        path.display()
886                    )));
887                }
888                let content = fs::read_to_string(&path)
889                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
890                if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
891                    match ext {
892                        "json" => {
893                            let val: serde_json::Value = serde_json::from_str(
894                                &content
895                            )
896                            .map_err(|e| {
897                                mlua::Error::RuntimeError(e.to_string())
898                            })?;
899                            return lua.to_value(&val);
900                        }
901                        _ => return lua.to_value(&content)
902                    }
903                }
904                lua.to_value(&content)
905            })
906            .map_err(|e| anyhow!(e.to_string()))?;
907        self.lua
908            .globals()
909            .set("IMPORT", import_fn)
910            .map_err(|e| anyhow!(e.to_string()))?;
911
912        Ok(())
913    }
914
915    /// Sets the operational scope for plugins.
916    ///
917    /// # Errors
918    ///
919    /// Returns an error if the `zoi` global table cannot be accessed or if the
920    /// scope cannot be set.
921    pub fn set_context(&self, scope: types::Scope) -> Result<()> {
922        let zoi: Table = self
923            .lua
924            .globals()
925            .get("zoi")
926            .map_err(|e| anyhow!(e.to_string()))?;
927        let scope_str = format!("{scope:?}").to_lowercase();
928        zoi.set("scope", scope_str)
929            .map_err(|e| anyhow!(e.to_string()))?;
930        Ok(())
931    }
932
933    /// Loads and executes all trusted Lua plugins from the plugin directory.
934    ///
935    /// # Errors
936    ///
937    /// Returns an error if the plugin directory cannot be read, if a plugin
938    /// script cannot be loaded, or if a plugin execution fails.
939    pub fn load_all(&self, yes: bool) -> Result<()> {
940        let plugin_dir = get_plugin_dir()?;
941        if !plugin_dir.exists() {
942            return Ok(());
943        }
944        let mut plugin_paths = Vec::new();
945        for entry in fs::read_dir(plugin_dir)? {
946            let entry = entry?;
947            let path = entry.path();
948            if path.extension().and_then(|s| s.to_str()) == Some("lua") {
949                plugin_paths.push(path);
950            }
951        }
952        plugin_paths.sort();
953
954        let trusted_path = get_plugin_dir()?.join("trusted_hashes.json");
955        let mut trusted: HashMap<String, String> = if trusted_path.exists() {
956            let content = fs::read_to_string(&trusted_path)?;
957            serde_json::from_str(&content).unwrap_or_default()
958        } else {
959            HashMap::new()
960        };
961        let mut trusted_changed = false;
962
963        for path in plugin_paths {
964            let script = fs::read_to_string(&path)?;
965
966            let mut hasher = Sha256::new();
967            hasher.update(script.as_bytes());
968            let hash = hex::encode(hasher.finalize());
969
970            let plugin_name = path
971                .file_name()
972                .unwrap_or_default()
973                .to_string_lossy()
974                .to_string();
975
976            let is_trusted = trusted
977                .get(&plugin_name)
978                .is_some_and(|known_hash| known_hash == &hash);
979
980            if !is_trusted {
981                if yes {
982                    println!(
983                        "\n{}: Skipping untrusted plugin: {}. Run Zoi \
984                         interactively to trust it.",
985                        "Warning".yellow().bold(),
986                        plugin_name.cyan()
987                    );
988
989                    continue;
990                }
991
992                println!(
993                    "\n{}: Untrusted plugin detected: {}",
994                    "SECURITY WARNING".yellow().bold(),
995                    plugin_name.cyan()
996                );
997                println!(
998                    "Plugins can execute arbitrary commands and modify your \
999                     system."
1000                );
1001                if utils::ask_for_confirmation(
1002                    "Do you trust this plugin and want to execute it?",
1003                    false
1004                ) {
1005                    trusted.insert(plugin_name.clone(), hash);
1006                    trusted_changed = true;
1007                } else {
1008                    println!("Skipping untrusted plugin: {plugin_name}");
1009                    continue;
1010                }
1011            }
1012
1013            let script_wrapper = format!(
1014                "local old_reg = zoi.register_command; zoi.register_command = \
1015                 function(a, b) if type(a) == 'string' then \
1016                 zoi.register_command_simple(a, b) else old_reg(a) end end; \
1017                 {script}"
1018            );
1019            self.lua.load(&script_wrapper).exec().map_err(|e| {
1020                anyhow!("Plugin error in {}: {}", path.display(), e)
1021            })?;
1022        }
1023
1024        if trusted_changed {
1025            let content = serde_json::to_string_pretty(&trusted)?;
1026            fs::write(trusted_path, content)?;
1027        }
1028
1029        Ok(())
1030    }
1031
1032    /// Triggers a lifecycle hook, executing all registered callbacks.
1033    ///
1034    /// # Errors
1035    ///
1036    /// Returns an error if the hook registry cannot be accessed or if any
1037    /// callback execution fails.
1038    pub fn trigger_hook(
1039        &self,
1040        hook_name: &str,
1041        arg: Option<&Value>
1042    ) -> Result<()> {
1043        let registry: Table = self
1044            .lua
1045            .globals()
1046            .get("__ZOI_HOOKS")
1047            .map_err(|e| anyhow!(e.to_string()))?;
1048        if let Ok(hook_list) = registry.get::<Table>(hook_name) {
1049            for callback in hook_list.sequence_values::<Function>() {
1050                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1051                if let Some(a) = arg {
1052                    callback
1053                        .call::<()>(a.clone())
1054                        .map_err(|e| anyhow!(e.to_string()))?;
1055                } else {
1056                    callback
1057                        .call::<()>(())
1058                        .map_err(|e| anyhow!(e.to_string()))?;
1059                }
1060            }
1061        }
1062        Ok(())
1063    }
1064
1065    /// Triggers a lifecycle hook but ignores errors, printing a warning
1066    /// instead.
1067    pub fn trigger_hook_nonfatal(&self, hook_name: &str, arg: Option<&Value>) {
1068        if let Err(error) = self.trigger_hook(hook_name, arg) {
1069            eprintln!(
1070                "Warning: hook '{hook_name}' failed after the operation \
1071                 completed: {error}"
1072            );
1073        }
1074    }
1075
1076    /// Triggers the shim version resolution hook.
1077    ///
1078    /// # Errors
1079    ///
1080    /// Returns an error if the hook registry cannot be accessed or if any
1081    /// callback execution fails.
1082    pub fn trigger_resolve_shim_version(
1083        &self,
1084        bin_name: &str
1085    ) -> Result<Option<String>> {
1086        let registry: Table = self
1087            .lua
1088            .globals()
1089            .get("__ZOI_HOOKS")
1090            .map_err(|e| anyhow!(e.to_string()))?;
1091
1092        if let Ok(hook_list) = registry.get::<Table>("on_resolve_shim_version")
1093        {
1094            for callback in hook_list.sequence_values::<Function>() {
1095                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1096                let result: Option<String> = callback
1097                    .call(bin_name)
1098                    .map_err(|e| anyhow!(e.to_string()))?;
1099                if result.is_some() {
1100                    return Ok(result);
1101                }
1102            }
1103        }
1104        Ok(None)
1105    }
1106
1107    /// Triggers the project installation hook.
1108    ///
1109    /// # Errors
1110    ///
1111    /// Returns an error if the hook registry cannot be accessed or if any
1112    /// callback execution fails.
1113    pub fn trigger_project_install_hook(&self) -> Result<bool> {
1114        let registry: Table = self
1115            .lua
1116            .globals()
1117            .get("__ZOI_HOOKS")
1118            .map_err(|e| anyhow!(e.to_string()))?;
1119
1120        if let Ok(hook_list) = registry.get::<Table>("on_project_install") {
1121            for callback in hook_list.sequence_values::<Function>() {
1122                let callback = callback.map_err(|e| anyhow!(e.to_string()))?;
1123                let handled: bool =
1124                    callback.call(()).map_err(|e| anyhow!(e.to_string()))?;
1125                if handled {
1126                    return Ok(true);
1127                }
1128            }
1129        }
1130        Ok(false)
1131    }
1132
1133    /// Executes a custom command registered by a plugin.
1134    ///
1135    /// # Errors
1136    ///
1137    /// Returns an error if the command registry cannot be accessed or if the
1138    /// command execution fails.
1139    pub fn run_command(&self, name: &str, args: Vec<String>) -> Result<bool> {
1140        let registry: Table = self
1141            .lua
1142            .globals()
1143            .get("__ZOI_COMMANDS")
1144            .map_err(|e| anyhow!(e.to_string()))?;
1145        let callback: Value =
1146            registry.get(name).map_err(|e| anyhow!(e.to_string()))?;
1147        if let Value::Function(func) = callback {
1148            func.call::<()>(args).map_err(|e| anyhow!(e.to_string()))?;
1149            Ok(true)
1150        } else {
1151            Ok(false)
1152        }
1153    }
1154
1155    /// Lists all custom commands registered by plugins.
1156    ///
1157    /// # Errors
1158    ///
1159    /// Returns an error if the command registry cannot be accessed.
1160    pub fn list_commands(&self) -> Result<Vec<(String, String)>> {
1161        let registry: Table = self
1162            .lua
1163            .globals()
1164            .get("__ZOI_COMMANDS")
1165            .map_err(|e| anyhow!(e.to_string()))?;
1166        let help_registry: Table = self
1167            .lua
1168            .globals()
1169            .get("__ZOI_COMMAND_HELP")
1170            .map_err(|e| anyhow!(e.to_string()))?;
1171        let mut commands = Vec::new();
1172        for pair in registry.pairs::<String, Value>() {
1173            let (name, _) = pair.map_err(|e| anyhow!(e.to_string()))?;
1174            let desc: String = help_registry
1175                .get(name.clone())
1176                .unwrap_or_else(|_| String::new());
1177            commands.push((name, desc));
1178        }
1179        Ok(commands)
1180    }
1181}
1182
1183/// Returns the path to the Zoi plugin directory.
1184///
1185/// # Errors
1186///
1187/// Returns an error if the user home directory cannot be found or if the
1188/// plugin directory cannot be created.
1189pub fn get_plugin_dir() -> Result<PathBuf> {
1190    let plugin_dir = utils::get_user_data_dir()?.join("plugins");
1191    if !plugin_dir.exists() {
1192        fs::create_dir_all(&plugin_dir)?;
1193    }
1194    Ok(plugin_dir)
1195}
1196
1197/// Reads the persistent state for plugins from state.json.
1198fn read_plugin_state() -> Result<HashMap<String, serde_json::Value>> {
1199    let path = get_plugin_dir()?.join("state.json");
1200    if !path.exists() {
1201        return Ok(HashMap::new());
1202    }
1203    let content = fs::read_to_string(path)?;
1204    Ok(serde_json::from_str(&content).unwrap_or_default())
1205}
1206
1207/// Writes the persistent state for plugins to state.json.
1208fn write_plugin_state(
1209    state: &HashMap<String, serde_json::Value>
1210) -> Result<()> {
1211    let path = get_plugin_dir()?.join("state.json");
1212    let content = serde_json::to_string_pretty(state)?;
1213    fs::write(path, content)?;
1214    Ok(())
1215}