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