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