Skip to main content

zoi_cli/pkg/
helper.rs

1//! Helper functions for package installation and uninstallation,
2//! including elevated operations and validation.
3
4use std::fs::File;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Result, anyhow};
9use mlua::{Function, Lua, Table};
10use sha2::{Digest, Sha256, Sha512};
11
12use crate::pkg::install::manifest;
13use crate::pkg::install::resolver::InstallNode;
14use crate::pkg::{local, types};
15
16/// Reads an internal input file after confining it to the system temp
17/// directory.
18fn read_temp_input(path: &Path) -> Result<String> {
19    let temp_root = std::env::temp_dir();
20    if !crate::pkg::utils::is_safe_path(&temp_root, path) {
21        return Err(anyhow!(
22            "Temporary input path is outside the temp directory."
23        ));
24    }
25    let root = temp_root.canonicalize()?;
26    let resolved = path.canonicalize()?;
27    if !resolved.starts_with(root) {
28        return Err(anyhow!(
29            "Temporary input path is outside the temp directory."
30        ));
31    }
32    Ok(std::fs::read_to_string(resolved)?)
33}
34
35/// Installs a package with elevated privileges.
36///
37/// # Errors
38///
39/// Returns an error if the node JSON file cannot be read, if the node JSON is
40/// invalid, or if the installation process fails.
41pub fn elevate_install_node(
42    cmd: &crate::cmd::helper::ElevateInstallNodeCommand
43) -> Result<()> {
44    let content = read_temp_input(&cmd.node_json)?;
45    let node: InstallNode = serde_json::from_str(&content)?;
46
47    let pkg = &node.pkg;
48    let handle = &node.registry_handle;
49    let sub_packages_vec = node.sub_package.clone().map(|s| vec![s]);
50
51    let (installed_files, file_digests) =
52        crate::pkg::install::pkg_install::run(
53            &cmd.archive,
54            Some(pkg.scope),
55            handle,
56            Some(&node.version),
57            cmd.yes,
58            sub_packages_vec,
59            cmd.link_bins,
60            None
61        )?;
62
63    if let types::InstallReason::Dependency { ref parent } = node.reason {
64        let package_dir =
65            local::get_package_dir(pkg.scope, handle, &pkg.repo, &pkg.name)?;
66        local::add_dependent(&package_dir, parent)?;
67    }
68
69    let manifest = manifest::create_manifest(
70        pkg,
71        node.reason.clone(),
72        node.dependencies.clone(),
73        Some(cmd.install_method.clone()),
74        installed_files,
75        handle,
76        node.repo_type.clone(),
77        &node.chosen_options,
78        &node.chosen_optionals,
79        node.sub_package.clone(),
80        file_digests
81    )?;
82
83    local::write_manifest(&manifest)?;
84    local::persist_package_source(&manifest, Path::new(&node.source))?;
85
86    Ok(())
87}
88
89/// Uninstalls a package with elevated privileges.
90///
91/// # Errors
92///
93/// Returns an error if the manifest JSON file cannot be read, if the manifest
94/// JSON is invalid, or if the uninstallation process fails.
95pub fn elevate_uninstall(
96    cmd: &crate::cmd::helper::ElevateUninstallCommand
97) -> Result<()> {
98    let content = read_temp_input(&cmd.manifest_json)?;
99    let manifest: types::InstallManifest = serde_json::from_str(&content)?;
100
101    let handle = &manifest.registry_handle;
102    let scope = manifest.scope;
103    let package_dir =
104        local::get_package_dir(scope, handle, &manifest.repo, &manifest.name)?;
105    let version_dir = package_dir.join(&manifest.version);
106
107    let pkg_lua_path = local::get_package_source_path(&manifest)?;
108    let mut pkg_opt = None;
109    if pkg_lua_path.exists() {
110        let path_str = pkg_lua_path
111            .to_str()
112            .ok_or_else(|| anyhow!("Package path contains invalid UTF-8"))?;
113        if let Ok(p) = crate::pkg::lua::parser::parse_lua_package(
114            path_str,
115            Some(&manifest.version),
116            Some(manifest.scope),
117            true
118        ) {
119            pkg_opt = Some(p);
120        }
121    }
122
123    if let Some(pkg) = &pkg_opt
124        && let Some(hooks) = &pkg.hooks
125    {
126        let _ = crate::pkg::hooks::run_hooks(
127            hooks,
128            crate::pkg::hooks::HookType::PreRemove,
129            manifest.scope
130        );
131    }
132
133    if pkg_lua_path.exists() {
134        let lua = Lua::new();
135        if crate::pkg::lua::functions::setup_lua_environment(
136            &lua,
137            &crate::pkg::utils::get_platform()?,
138            Some(&manifest.version),
139            pkg_lua_path.to_str(),
140            None,
141            None,
142            None,
143            manifest.sub_package.as_deref(),
144            Some(manifest.scope),
145            None,
146            true
147        )
148        .is_ok()
149        {
150            let lua_code = crate::pkg::utils::read_file_within(
151                &version_dir,
152                &pkg_lua_path
153            )?;
154            if lua.load(&lua_code).exec().is_ok() {
155                if let Ok(uninstall_fn) =
156                    lua.globals().get::<Function>("uninstall")
157                {
158                    let _ = uninstall_fn.call::<()>(());
159                }
160
161                if let Ok(uninstall_ops) =
162                    lua.globals().get::<Table>("__ZoiUninstallOperations")
163                {
164                    for op in uninstall_ops.sequence_values::<Table>() {
165                        if let Ok(op) = op
166                            && let Ok(op_type) = op.get::<String>("op")
167                            && op_type == "zrm"
168                        {
169                            let path_to_remove: String =
170                                op.get("path").unwrap_or_default();
171                            let path = std::path::PathBuf::from(
172                                crate::pkg::utils::expand_placeholders(
173                                    &path_to_remove,
174                                    &version_dir,
175                                    scope
176                                )?
177                            );
178                            if path.exists() {
179                                if path.is_dir() {
180                                    let _ = std::fs::remove_dir_all(path);
181                                } else {
182                                    let _ = std::fs::remove_file(path);
183                                }
184                            }
185                        }
186                    }
187                }
188            }
189        }
190    }
191
192    if let Some(bins) = &manifest.bins {
193        let bin_root = crate::pkg::utils::get_system_bin_dir();
194
195        for bin in bins {
196            let symlink_path = bin_root.join(bin);
197            if symlink_path.is_symlink() || symlink_path.exists() {
198                let _ = std::fs::remove_file(&symlink_path);
199            }
200        }
201    }
202
203    for file_path_str in &manifest.installed_files {
204        // Manifest entries use placeholders such as ${usrroot}, so they must
205        // be expanded before any filesystem lookup.
206        let expanded = crate::pkg::utils::expand_placeholders(
207            file_path_str,
208            &version_dir,
209            scope
210        )?;
211        let file_path = PathBuf::from(expanded);
212        // Use symlink_metadata so links are removed even when they dangle
213        // or point to a directory; only the link is deleted.
214        let Ok(meta) = std::fs::symlink_metadata(&file_path) else {
215            continue;
216        };
217        if meta.file_type().is_symlink() {
218            let _ = std::fs::remove_file(&file_path);
219        } else if meta.is_dir() {
220            // Only remove if empty to be safe: a populated directory may
221            // contain files that no longer belong to this package.
222            if std::fs::read_dir(&file_path)
223                .is_ok_and(|mut entries| entries.next().is_none())
224            {
225                let _ = std::fs::remove_dir(&file_path);
226            }
227        } else {
228            let _ = std::fs::remove_file(&file_path);
229        }
230    }
231
232    let manifest_path = version_dir
233        .join(local::manifest_filename(manifest.sub_package.as_deref()));
234    if manifest_path.exists() {
235        std::fs::remove_file(&manifest_path)?;
236    }
237    // Remove the legacy YAML twin if an old install left one behind.
238    let legacy_path = version_dir.join(format!(
239        "manifest{}.yaml",
240        manifest
241            .sub_package
242            .as_deref()
243            .map_or_else(String::new, |sub| format!("-{sub}"))
244    ));
245    if legacy_path != manifest_path && legacy_path.exists() {
246        std::fs::remove_file(legacy_path)?;
247    }
248
249    if version_dir.exists() && std::fs::read_dir(&version_dir)?.next().is_none()
250    {
251        std::fs::remove_dir_all(version_dir)?;
252    }
253
254    if package_dir.exists() {
255        let _ = crate::pkg::service::cleanup_service(&manifest.name, scope);
256        if let Ok(mut entries) = std::fs::read_dir(&package_dir)
257            && entries.next().is_none()
258        {
259            std::fs::remove_dir_all(package_dir)?;
260        }
261    }
262
263    let parent_id = format!(
264        "#{}@{}/{}@{}",
265        manifest.registry_handle,
266        manifest.repo,
267        manifest.name,
268        manifest.version
269    );
270    for dep_str in &manifest.installed_dependencies {
271        if let Ok(dep) =
272            crate::pkg::dependencies::parse_dependency_string(dep_str)
273            && dep.manager == "zoi"
274        {
275            let dep_req =
276                crate::pkg::resolve::parse_source_string(dep.package)?;
277            let dep_matches =
278                crate::pkg::local::find_installed_manifests_matching(
279                    &dep_req, scope
280                )?;
281            if dep_matches.len() == 1
282                && let Some(dep_manifest) = dep_matches.first()
283                && let Ok(dep_pkg_dir) = crate::pkg::local::get_package_dir(
284                    dep_manifest.scope,
285                    &dep_manifest.registry_handle,
286                    &dep_manifest.repo,
287                    &dep_manifest.name
288                )
289            {
290                let _ = crate::pkg::local::remove_dependent(
291                    &dep_pkg_dir,
292                    &parent_id
293                );
294            }
295        }
296    }
297
298    if let Some(pkg) = &pkg_opt
299        && let Some(hooks) = &pkg.hooks
300    {
301        let _ = crate::pkg::hooks::run_hooks(
302            hooks,
303            crate::pkg::hooks::HookType::PostRemove,
304            manifest.scope
305        );
306    }
307
308    Ok(())
309}
310
311/// Supported hash types for file verification.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum HashType {
314    /// SHA-512 hash.
315    Sha512,
316    /// SHA-256 hash.
317    Sha256
318}
319
320/// Updates a digest from a reader.
321fn update_digest_from_reader<R: Read, D: Digest>(
322    reader: &mut R,
323    hasher: &mut D
324) -> Result<()> {
325    let mut buffer = [0; 8192];
326    loop {
327        let bytes_read = reader.read(&mut buffer)?;
328        if bytes_read == 0 {
329            break;
330        }
331        if let Some(chunk) = buffer.get(..bytes_read) {
332            hasher.update(chunk);
333        }
334    }
335    Ok(())
336}
337
338/// Calculates the hash of a file or remote URL.
339///
340/// # Errors
341///
342/// Returns an error if the file cannot be opened or read, or if the remote URL
343/// cannot be downloaded.
344pub fn get_hash(source: &str, hash_type: HashType) -> Result<String> {
345    let mut hasher_sha512 = Sha512::new();
346    let mut hasher_sha256 = Sha256::new();
347
348    if source.starts_with("http://") || source.starts_with("https://") {
349        let client = crate::pkg::utils::get_http_client()?;
350        let mut response = client.get(source).send()?;
351        if !response.status().is_success() {
352            let status = response.status();
353            return Err(anyhow!("Failed to download file from URL: {status}"));
354        }
355        match hash_type {
356            HashType::Sha512 => {
357                update_digest_from_reader(&mut response, &mut hasher_sha512)?;
358            }
359            HashType::Sha256 => {
360                update_digest_from_reader(&mut response, &mut hasher_sha256)?;
361            }
362        }
363    } else {
364        let mut file = File::open(source)?;
365        match hash_type {
366            HashType::Sha512 => {
367                update_digest_from_reader(&mut file, &mut hasher_sha512)?;
368            }
369            HashType::Sha256 => {
370                update_digest_from_reader(&mut file, &mut hasher_sha256)?;
371            }
372        }
373    }
374
375    let hash = match hash_type {
376        HashType::Sha512 => hex::encode(hasher_sha512.finalize()),
377        HashType::Sha256 => hex::encode(hasher_sha256.finalize())
378    };
379
380    Ok(hash)
381}
382
383/// Validation utilities for Zoi specification files.
384pub mod validate {
385    use std::path::Path;
386
387    use anyhow::{Result, anyhow};
388    use colored::Colorize;
389
390    /// Validates a Zoi specification file (e.g. repo.yaml, a built-in
391    /// `<registry>.yaml`, advisories.json, packages.json).
392    ///
393    /// # Errors
394    ///
395    /// Returns an error if the file does not exist, cannot be read, or if the
396    /// file content does not match any known Zoi specification.
397    pub fn run(file: &Path) -> Result<()> {
398        if !file.exists() {
399            let path = file.display();
400            return Err(anyhow!("File does not exist: {path}"));
401        }
402
403        let content = std::fs::read_to_string(file)?;
404        let file_name = file
405            .file_name()
406            .and_then(|n| n.to_str())
407            .unwrap_or_default();
408
409        let path = file.display();
410        println!("{} Validating {path}...", "::".bold().blue());
411
412        if file_name == "repo.yaml" || file_name == "repo.yml" {
413            let _: crate::pkg::types::RepoConfig =
414                serde_yaml::from_str(&content)
415                    .map_err(|e| anyhow!("Invalid repo.yaml spec: {e}"))?;
416            println!("{} file is a valid repo.yaml spec.", "OK".bold().green());
417        } else if file_name == "advisories.json" {
418            let _: crate::pkg::types::AdvisoryRegistry =
419                serde_json::from_str(&content).map_err(|e| {
420                    anyhow!("Invalid advisories.json spec: {e}")
421                })?;
422            println!(
423                "{} file is a valid advisories.json spec.",
424                "OK".bold().green()
425            );
426        } else if file_name == "packages.json" {
427            let _: crate::pkg::purl::RegistryIndex =
428                serde_json::from_str(&content)
429                    .map_err(|e| anyhow!("Invalid packages.json spec: {e}"))?;
430            println!(
431                "{} file is a valid packages.json spec.",
432                "OK".bold().green()
433            );
434        } else if file_name.ends_with(".sec.yaml")
435            || file_name.ends_with(".sec.yml")
436        {
437            let _: crate::pkg::types::Advisory = serde_yaml::from_str(&content)
438                .map_err(|e| {
439                    anyhow!("Invalid security advisory (.sec.yaml) spec: {e}")
440                })?;
441            println!("{} file is a valid .sec.yaml spec.", "OK".bold().green());
442        } else if file_name.ends_with(".hook.yaml")
443            || file_name.ends_with(".hook.yml")
444        {
445            let _: crate::pkg::hooks::global::GlobalHook =
446                serde_yaml::from_str(&content).map_err(|e| {
447                    anyhow!("Invalid global hook (.hook.yaml) spec: {e}")
448                })?;
449            println!(
450                "{} file is a valid .hook.yaml spec.",
451                "OK".bold().green()
452            );
453        } else if file.extension().and_then(|e| e.to_str()) == Some("json") {
454            if serde_json::from_str::<crate::pkg::types::AdvisoryRegistry>(
455                &content
456            )
457            .is_ok()
458            {
459                println!(
460                    "{} file matches advisories.json spec.",
461                    "OK".bold().green()
462                );
463            } else if serde_json::from_str::<crate::pkg::purl::RegistryIndex>(
464                &content
465            )
466            .is_ok()
467            {
468                println!(
469                    "{} file matches packages.json spec.",
470                    "OK".bold().green()
471                );
472            } else {
473                return Err(anyhow!(
474                    "File does not match any known Zoi JSON spec \
475                     (advisories.json, or packages.json)"
476                ));
477            }
478        } else if file.extension().and_then(|e| e.to_str()) == Some("yaml")
479            || file.extension().and_then(|e| e.to_str()) == Some("yml")
480        {
481            if serde_yaml::from_str::<crate::pkg::types::RepoConfig>(&content)
482                .is_ok()
483            {
484                println!(
485                    "{} file matches repo.yaml spec.",
486                    "OK".bold().green()
487                );
488            } else if serde_yaml::from_str::<crate::pkg::types::Advisory>(
489                &content
490            )
491            .is_ok()
492            {
493                println!(
494                    "{} file matches .sec.yaml spec.",
495                    "OK".bold().green()
496                );
497            } else if serde_yaml::from_str::<crate::pkg::types::BuiltinRegistry>(
498                &content
499            )
500            .is_ok()
501            {
502                println!(
503                    "{} file matches a built-in registry YAML spec.",
504                    "OK".bold().green()
505                );
506            } else {
507                return Err(anyhow!(
508                    "File does not match any known Zoi YAML spec (repo.yaml \
509                     or .sec.yaml)"
510                ));
511            }
512        } else {
513            return Err(anyhow!(
514                "Unsupported file extension. Please provide a .json or .yaml \
515                 file"
516            ));
517        }
518
519        Ok(())
520    }
521}