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