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