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        // Manifest entries use placeholders such as ${usrroot}, so they must
199        // be expanded before any filesystem lookup.
200        let expanded = crate::pkg::utils::expand_placeholders(
201            file_path_str,
202            &version_dir,
203            scope
204        )?;
205        let file_path = PathBuf::from(expanded);
206        // Use symlink_metadata so links are removed even when they dangle
207        // or point to a directory; only the link is deleted.
208        let Ok(meta) = std::fs::symlink_metadata(&file_path) else {
209            continue;
210        };
211        if meta.file_type().is_symlink() {
212            let _ = std::fs::remove_file(&file_path);
213        } else if meta.is_dir() {
214            // Only remove if empty to be safe: a populated directory may
215            // contain files that no longer belong to this package.
216            if std::fs::read_dir(&file_path)
217                .is_ok_and(|mut entries| entries.next().is_none())
218            {
219                let _ = std::fs::remove_dir(&file_path);
220            }
221        } else {
222            let _ = std::fs::remove_file(&file_path);
223        }
224    }
225
226    let manifest_filename = if let Some(sub) = &manifest.sub_package {
227        format!("manifest-{sub}.yaml")
228    } else {
229        "manifest.yaml".to_string()
230    };
231    let manifest_path = version_dir.join(manifest_filename);
232    if manifest_path.exists() {
233        std::fs::remove_file(manifest_path)?;
234    }
235
236    if version_dir.exists() && std::fs::read_dir(&version_dir)?.next().is_none()
237    {
238        std::fs::remove_dir_all(version_dir)?;
239    }
240
241    if package_dir.exists() {
242        let _ = crate::pkg::service::cleanup_service(&manifest.name, scope);
243        if let Ok(mut entries) = std::fs::read_dir(&package_dir)
244            && entries.next().is_none()
245        {
246            std::fs::remove_dir_all(package_dir)?;
247        }
248    }
249
250    let parent_id = format!(
251        "#{}@{}/{}@{}",
252        manifest.registry_handle,
253        manifest.repo,
254        manifest.name,
255        manifest.version
256    );
257    for dep_str in &manifest.installed_dependencies {
258        if let Ok(dep) =
259            crate::pkg::dependencies::parse_dependency_string(dep_str)
260            && dep.manager == "zoi"
261        {
262            let dep_req =
263                crate::pkg::resolve::parse_source_string(dep.package)?;
264            let dep_matches =
265                crate::pkg::local::find_installed_manifests_matching(
266                    &dep_req, scope
267                )?;
268            if dep_matches.len() == 1
269                && let Some(dep_manifest) = dep_matches.first()
270                && let Ok(dep_pkg_dir) = crate::pkg::local::get_package_dir(
271                    dep_manifest.scope,
272                    &dep_manifest.registry_handle,
273                    &dep_manifest.repo,
274                    &dep_manifest.name
275                )
276            {
277                let _ = crate::pkg::local::remove_dependent(
278                    &dep_pkg_dir,
279                    &parent_id
280                );
281            }
282        }
283    }
284
285    if let Some(pkg) = &pkg_opt
286        && let Some(hooks) = &pkg.hooks
287    {
288        let _ = crate::pkg::hooks::run_hooks(
289            hooks,
290            crate::pkg::hooks::HookType::PostRemove,
291            manifest.scope
292        );
293    }
294
295    Ok(())
296}
297
298/// Supported hash types for file verification.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum HashType {
301    /// SHA-512 hash.
302    Sha512,
303    /// SHA-256 hash.
304    Sha256
305}
306
307/// Updates a digest from a reader.
308fn update_digest_from_reader<R: Read, D: Digest>(
309    reader: &mut R,
310    hasher: &mut D
311) -> Result<()> {
312    let mut buffer = [0; 8192];
313    loop {
314        let bytes_read = reader.read(&mut buffer)?;
315        if bytes_read == 0 {
316            break;
317        }
318        if let Some(chunk) = buffer.get(..bytes_read) {
319            hasher.update(chunk);
320        }
321    }
322    Ok(())
323}
324
325/// Calculates the hash of a file or remote URL.
326///
327/// # Errors
328///
329/// Returns an error if the file cannot be opened or read, or if the remote URL
330/// cannot be downloaded.
331pub fn get_hash(source: &str, hash_type: HashType) -> Result<String> {
332    let mut hasher_sha512 = Sha512::new();
333    let mut hasher_sha256 = Sha256::new();
334
335    if source.starts_with("http://") || source.starts_with("https://") {
336        let client = crate::pkg::utils::get_http_client()?;
337        let mut response = client.get(source).send()?;
338        if !response.status().is_success() {
339            let status = response.status();
340            return Err(anyhow!("Failed to download file from URL: {status}"));
341        }
342        match hash_type {
343            HashType::Sha512 => {
344                update_digest_from_reader(&mut response, &mut hasher_sha512)?;
345            }
346            HashType::Sha256 => {
347                update_digest_from_reader(&mut response, &mut hasher_sha256)?;
348            }
349        }
350    } else {
351        let mut file = File::open(source)?;
352        match hash_type {
353            HashType::Sha512 => {
354                update_digest_from_reader(&mut file, &mut hasher_sha512)?;
355            }
356            HashType::Sha256 => {
357                update_digest_from_reader(&mut file, &mut hasher_sha256)?;
358            }
359        }
360    }
361
362    let hash = match hash_type {
363        HashType::Sha512 => hex::encode(hasher_sha512.finalize()),
364        HashType::Sha256 => hex::encode(hasher_sha256.finalize())
365    };
366
367    Ok(hash)
368}
369
370/// Validation utilities for Zoi specification files.
371pub mod validate {
372    use std::path::Path;
373
374    use anyhow::{Result, anyhow};
375    use colored::Colorize;
376
377    /// Validates a Zoi specification file (e.g. registries.json, repo.yaml).
378    ///
379    /// # Errors
380    ///
381    /// Returns an error if the file does not exist, cannot be read, or if the
382    /// file content does not match any known Zoi specification.
383    pub fn run(file: &Path) -> Result<()> {
384        if !file.exists() {
385            let path = file.display();
386            return Err(anyhow!("File does not exist: {path}"));
387        }
388
389        let content = std::fs::read_to_string(file)?;
390        let file_name = file
391            .file_name()
392            .and_then(|n| n.to_str())
393            .unwrap_or_default();
394
395        let path = file.display();
396        println!("{} Validating {path}...", "::".bold().blue());
397
398        if file_name == "registries.json" {
399            let _: crate::pkg::purl::CentralDbSpec =
400                serde_json::from_str(&content).map_err(|e| {
401                    anyhow!("Invalid registries.json spec: {e}")
402                })?;
403            println!(
404                "{} file is a valid registries.json spec.",
405                "OK".bold().green()
406            );
407        } else if file_name == "repo.yaml" || file_name == "repo.yml" {
408            let _: crate::pkg::types::RepoConfig =
409                serde_yaml::from_str(&content)
410                    .map_err(|e| anyhow!("Invalid repo.yaml spec: {e}"))?;
411            println!("{} file is a valid repo.yaml spec.", "OK".bold().green());
412        } else if file_name == "advisories.json" {
413            let _: crate::pkg::types::AdvisoryRegistry =
414                serde_json::from_str(&content).map_err(|e| {
415                    anyhow!("Invalid advisories.json spec: {e}")
416                })?;
417            println!(
418                "{} file is a valid advisories.json spec.",
419                "OK".bold().green()
420            );
421        } else if file_name == "packages.json" {
422            let _: crate::pkg::purl::RegistryIndex =
423                serde_json::from_str(&content)
424                    .map_err(|e| anyhow!("Invalid packages.json spec: {e}"))?;
425            println!(
426                "{} file is a valid packages.json spec.",
427                "OK".bold().green()
428            );
429        } else if file_name.ends_with(".sec.yaml")
430            || file_name.ends_with(".sec.yml")
431        {
432            let _: crate::pkg::types::Advisory = serde_yaml::from_str(&content)
433                .map_err(|e| {
434                    anyhow!("Invalid security advisory (.sec.yaml) spec: {e}")
435                })?;
436            println!("{} file is a valid .sec.yaml spec.", "OK".bold().green());
437        } else if file.extension().and_then(|e| e.to_str()) == Some("json") {
438            if serde_json::from_str::<crate::pkg::purl::CentralDbSpec>(&content).is_ok() {
439                println!("{} file matches registries.json spec.", "OK".bold().green());
440            } else if serde_json::from_str::<crate::pkg::types::AdvisoryRegistry>(&content)
441                .is_ok()
442            {
443                println!("{} file matches advisories.json spec.", "OK".bold().green());
444            } else if serde_json::from_str::<crate::pkg::purl::RegistryIndex>(&content).is_ok()
445            {
446                println!("{} file matches packages.json spec.", "OK".bold().green());
447            } else {
448                return Err(anyhow!(
449                    "File does not match any known Zoi JSON spec (registries.json, advisories.json, or packages.json)"
450                ));
451            }
452        } else if file.extension().and_then(|e| e.to_str()) == Some("yaml")
453            || file.extension().and_then(|e| e.to_str()) == Some("yml")
454        {
455            if serde_yaml::from_str::<crate::pkg::types::RepoConfig>(&content)
456                .is_ok()
457            {
458                println!(
459                    "{} file matches repo.yaml spec.",
460                    "OK".bold().green()
461                );
462            } else if serde_yaml::from_str::<crate::pkg::types::Advisory>(
463                &content
464            )
465            .is_ok()
466            {
467                println!(
468                    "{} file matches .sec.yaml spec.",
469                    "OK".bold().green()
470                );
471            } else {
472                return Err(anyhow!(
473                    "File does not match any known Zoi YAML spec (repo.yaml \
474                     or .sec.yaml)"
475                ));
476            }
477        } else {
478            return Err(anyhow!(
479                "Unsupported file extension. Please provide a .json or .yaml \
480                 file"
481            ));
482        }
483
484        Ok(())
485    }
486}