Skip to main content

zoi_install/
pkg_install.rs

1use std::collections::{BTreeMap, HashSet};
2use std::fs::{self, File};
3use std::io::{Read, Seek};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Result, anyhow};
7use colored::Colorize;
8use tar::Archive;
9use tempfile::Builder;
10use walkdir::WalkDir;
11use zoi_core::hash::{HashAlgorithm, calculate_file_hash};
12use zoi_core::types;
13use zoi_core::utils::{self, copy_dir_all};
14use zoi_db as db;
15use zoi_resolver::local;
16use zstd::stream::read::Decoder as ZstdDecoder;
17
18/// Returns the root directory where binary shims should be created for a given
19/// scope.
20fn get_bin_root(scope: types::Scope) -> Result<PathBuf> {
21    match scope {
22        types::Scope::User => zoi_core::utils::get_user_bin_dir(),
23        types::Scope::System => Ok(zoi_core::utils::get_system_bin_dir()),
24        types::Scope::Project => {
25            let current_dir = std::env::current_dir()?;
26            Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
27        }
28    }
29}
30
31/// Returns the root directory where shell completions should be installed for a
32/// given scope and shell.
33fn get_completions_root(scope: types::Scope, shell: &str) -> Result<PathBuf> {
34    match scope {
35        types::Scope::User => zoi_core::utils::get_user_completions_dir(shell),
36        types::Scope::System => {
37            if cfg!(target_os = "windows") {
38                Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(format!(
39                    "C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}"
40                ))))
41            } else if zoi_core::utils::is_zoios() {
42                let base = match shell {
43                    "bash" => "/usr/share/bash-completion/completions",
44                    "zsh" => "/usr/share/zsh/site-functions",
45                    "fish" => "/usr/share/fish/vendor_completions.d",
46                    "elvish" => "/usr/share/elvish/lib",
47                    _ => "/usr/share/zoi/completions"
48                };
49                Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(base)))
50            } else {
51                let base = match shell {
52                    "bash" => "/usr/share/bash-completion/completions",
53                    "zsh" => "/usr/share/zsh/site-functions",
54                    "fish" => "/usr/share/fish/vendor_completions.d",
55                    "elvish" => "/usr/share/elvish/lib",
56                    _ => "/usr/local/share/zoi/completions"
57                };
58                Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(base)))
59            }
60        }
61        types::Scope::Project => {
62            let current_dir = std::env::current_dir()?;
63            Ok(current_dir
64                .join(".zoi")
65                .join("pkgs")
66                .join("shell")
67                .join(shell))
68        }
69    }
70}
71
72/// Creates a symlink for a shell completion file, removing any existing file or
73/// link at the destination.
74fn create_completion_symlink(source: &Path, link: &Path) -> Result<()> {
75    if link.exists() || link.is_symlink() {
76        fs::remove_file(link)?;
77    }
78    if let Some(parent) = link.parent() {
79        fs::create_dir_all(parent)?;
80    }
81    #[cfg(unix)]
82    {
83        std::os::unix::fs::symlink(source, link)
84            .map_err(|e| anyhow!("Failed to create completion symlink: {e}"))?;
85    }
86    #[cfg(windows)]
87    {
88        std::os::windows::fs::symlink_file(source, link).map_err(|e| {
89            anyhow!("Failed to create completion symlink: {}", e)
90        })?;
91    }
92    Ok(())
93}
94
95/// A destination path that an incoming file would overwrite, and who, if
96/// anyone, already claims it.
97struct FileConflict {
98    /// The absolute path the incoming file would be written to.
99    path: PathBuf,
100    /// The packages that already record this path in `package_files`.
101    ///
102    /// Empty means the path exists on disk but no installed package claims
103    /// it, which is a stray file rather than a package overlap.
104    owners: Vec<String>
105}
106
107/// Identifies the package being installed, so that the ownership lookup can
108/// exclude its own earlier claims.
109///
110/// A reinstall legitimately rewrites files the package already owns, and those
111/// must not be reported as conflicts against another package. Without the
112/// identity, every package would appear to conflict with itself.
113struct InstallingPackage<'a> {
114    /// Package name as recorded in the `packages` table.
115    name: &'a str,
116    /// Sub-package name for a split package, `None` otherwise.
117    sub_package: Option<&'a str>,
118    /// Repository tier the package was installed from.
119    repo: &'a str,
120    /// Registry handle the package belongs to.
121    registry: &'a str
122}
123
124/// Checks for file conflicts between a source directory and a destination
125/// directory, prompting the user for confirmation if conflicts are found.
126///
127/// The check consults the global `package_files` index rather than only the
128/// incoming package's own previous manifest. That distinction is what lets a
129/// clobber be reported as a clobber: an unowned file is a stray the user may
130/// reasonably want replaced, while a file another package owns is a conflict
131/// between two packages that should not be resolved silently by whoever
132/// installed last.
133///
134/// `owned_files` is the caller's existing footprint for this package. It is
135/// still needed because the ownership index is only populated once a package
136/// has been installed at least once, and it is also the only record for a
137/// package installed before this index existed.
138fn check_and_handle_file_conflicts(
139    source_dir: &Path,
140    dest_dir: &Path,
141    owned_files: &HashSet<String>,
142    package: Option<&InstallingPackage<'_>>,
143    yes: bool
144) -> Result<()> {
145    let mut stray_files = Vec::new();
146    let mut owned_by_others: Vec<FileConflict> = Vec::new();
147
148    // Resolved once rather than per file: this runs for every incoming file,
149    // and a system-scope package contributes thousands of them.
150    //
151    // A database that cannot be opened, or a package with no row yet, both
152    // degrade to "no owners known" rather than failing the install. Losing the
153    // ownership index turns a cross-package clobber back into the plain
154    // stray-file prompt, which is the behaviour this check had before, so the
155    // failure mode is a weaker check and never a broken install.
156    let (conn, current_pkg_id) = match package {
157        Some(pkg) => {
158            // `open_connection` keys the database file by registry handle, and
159            // an empty handle is how the local store is named.
160            let registry_key = if pkg.registry.is_empty() {
161                "local"
162            } else {
163                pkg.registry
164            };
165            match db::open_connection(registry_key) {
166                Ok(conn) => {
167                    let id = db::get_package_id(
168                        &conn,
169                        pkg.name,
170                        pkg.sub_package,
171                        pkg.repo,
172                        registry_key
173                    )
174                    .ok();
175                    (Some(conn), id)
176                }
177                Err(_) => (None, None)
178            }
179        }
180        None => (None, None)
181    };
182
183    for entry in WalkDir::new(source_dir)
184        .into_iter()
185        .filter_map(std::result::Result::ok)
186        .skip(1)
187    {
188        if !entry.file_type().is_file() {
189            continue;
190        }
191
192        let relative_path = entry.path().strip_prefix(source_dir)?;
193        let dest_path = dest_dir.join(relative_path);
194
195        if !dest_path.exists() {
196            continue;
197        }
198
199        // `owned_files` stores placeholder-form paths such as
200        // `${usrroot}/usr/bin/zoi`, so the comparison has to be made against
201        // that same form rather than the expanded destination path. Comparing
202        // the expanded path against the stored form never matches, which made
203        // every reinstall look like a conflict against itself.
204        let expanded = zoi_core::utils::expand_placeholders(
205            &dest_path.to_string_lossy(),
206            Path::new(""),
207            types::Scope::System
208        );
209        let is_self_owned = expanded
210            .as_ref()
211            .is_ok_and(|p| owned_files.contains(p))
212            || owned_files.contains(&dest_path.to_string_lossy().to_string());
213
214        if is_self_owned {
215            continue;
216        }
217
218        let owners = conn
219            .as_ref()
220            .and_then(|c| {
221                db::get_other_owners(
222                    c,
223                    &dest_path.to_string_lossy(),
224                    current_pkg_id
225                )
226                .ok()
227            })
228            .unwrap_or_default();
229
230        if owners.is_empty() {
231            stray_files.push(dest_path);
232        } else {
233            owned_by_others.push(FileConflict {
234                path: dest_path,
235                owners
236            });
237        }
238    }
239
240    if !stray_files.is_empty() {
241        println!();
242        println!("{}", "File Conflict Detected:".red().bold());
243        println!(
244            "The following files that this package wants to install already \
245             exist on your system:"
246        );
247        for file in &stray_files {
248            println!("- {}", file.display());
249        }
250        println!();
251
252        if !utils::ask_for_confirmation(
253            "Do you want to overwrite these files and continue with the \
254             installation?",
255            yes
256        ) {
257            return Err(anyhow!(
258                "Installation aborted by user due to file conflicts."
259            ));
260        }
261    }
262
263    if !owned_by_others.is_empty() {
264        println!();
265        println!("{}", "File Conflict With Another Package:".red().bold());
266        println!(
267            "The following files are owned by other installed packages. \
268             Installing this package would replace them, leaving the other \
269             package broken:"
270        );
271        for conflict in &owned_by_others {
272            println!(
273                "- {} (owned by {})",
274                conflict.path.display(),
275                conflict.owners.join(", ")
276            );
277        }
278        println!();
279
280        if !utils::ask_for_confirmation(
281            "Do you want to overwrite these files anyway? The packages listed \
282             above may stop working correctly.",
283            yes
284        ) {
285            return Err(anyhow!(
286                "Installation aborted by user due to file conflicts with \
287                 other packages."
288            ));
289        }
290    }
291
292    Ok(())
293}
294
295/// Performs the low-level extraction and staging of a package archive.
296///
297/// Atomic Staging Pattern:
298/// - The archive is unpacked into a temporary system `temp_dir`.
299/// - Files are then moved into a `.tmp-install-` subdirectory within the target
300///   store.
301/// - Only after ALL files are staged and shims are verified does Zoi move the
302///   staging folder to its final versioned path (`{version}/`).
303///
304/// This ensures that a crash, power loss, or network failure during extraction
305/// never leaves a partially-installed or broken package in the Zoi store.
306///
307/// # Errors
308///
309/// Returns an error if:
310/// - The archive file cannot be opened or read.
311/// - The archive format is invalid.
312/// - Extraction fails.
313/// - Metadata parsing fails.
314/// - Required permissions are missing.
315/// - Filesystem operations (copying, directory creation) fail.
316pub fn run(
317    package_file: &Path,
318    scope_override: Option<types::Scope>,
319    registry_handle: &str,
320    version_override: Option<&str>,
321    yes: bool,
322    sub_packages: Option<Vec<String>>,
323    link_bins: bool,
324    pb: Option<&indicatif::ProgressBar>
325) -> Result<(Vec<String>, BTreeMap<String, String>)> {
326    let scope = scope_override.unwrap_or(types::Scope::User);
327
328    // Handle meta-packages with no archive
329    if package_file.as_os_str().is_empty() {
330        if pb.is_none() {
331            println!("Initializing meta-package...");
332        }
333        return Ok((Vec::new(), BTreeMap::new()));
334    }
335
336    if pb.is_none() {
337        println!(
338            "Installing from package archive: {}",
339            package_file.display()
340        );
341    }
342
343    let file_metadata = fs::metadata(package_file)
344        .map_err(|e| anyhow!("Failed to get archive metadata: {e}"))?;
345    let file_size = file_metadata.len();
346
347    if pb.is_none() {
348        println!("Archive size: {}", zoi_core::utils::format_bytes(file_size));
349    }
350
351    let mut file = File::open(package_file)
352        .map_err(|e| anyhow!("Failed to open package archive: {e}"))?;
353
354    let mut magic = [0u8; 4];
355    if file.read_exact(&mut magic).is_ok() && magic != [0x28, 0xB5, 0x2F, 0xFD]
356    {
357        return Err(anyhow!(
358            "Invalid archive format: expected zstd magic number 28 B5 2F FD, \
359             but found {magic:02X?}. This file is likely not a valid .zst \
360             archive."
361        ));
362    }
363
364    file.rewind()
365        .map_err(|e| anyhow!("Failed to rewind archive file: {e}"))?;
366
367    let decoder = ZstdDecoder::new(file)
368        .map_err(|e| anyhow!("Failed to initialize zstd decoder: {e}"))?;
369    let mut archive = Archive::new(decoder);
370
371    #[cfg(target_os = "linux")]
372    archive.set_unpack_xattrs(true);
373
374    let temp_dir = Builder::new().prefix("zoi-install-").tempdir()?;
375    let unpack_path = temp_dir.path().to_path_buf();
376
377    for entry_res in archive
378        .entries()
379        .map_err(|e| anyhow!("Failed to read archive entries: {e}"))?
380    {
381        let mut entry = entry_res.map_err(|e| {
382            anyhow!(
383                "Failed to process archive entry: {e}. The archive may be \
384                 truncated or corrupted."
385            )
386        })?;
387        let path = entry
388            .path()
389            .map_err(|e| anyhow!("Failed to get entry path: {e}"))?
390            .to_path_buf();
391        entry.unpack_in(&unpack_path).map_err(|e| {
392            anyhow!("Failed to unpack file '{}': {}", path.display(), e)
393        })?;
394    }
395
396    let mut pkg_lua_path = None;
397    for entry in WalkDir::new(temp_dir.path())
398        .into_iter()
399        .filter_map(std::result::Result::ok)
400    {
401        if entry.file_name().to_string_lossy().ends_with(".pkg.lua") {
402            pkg_lua_path = Some(entry.path().to_path_buf());
403            break;
404        }
405    }
406    let pkg_lua_path = pkg_lua_path.ok_or_else(|| {
407        anyhow!(
408            "Could not find .pkg.lua file in archive '{}'",
409            package_file.display()
410        )
411    })?;
412
413    let platform = utils::get_platform()?;
414    let metadata = zoi_lua::parser::parse_lua_package_for_platform(
415        pkg_lua_path.to_str().ok_or_else(|| {
416            anyhow!(
417                "Path contains invalid UTF-8 characters: {}",
418                pkg_lua_path.display()
419            )
420        })?,
421        &platform,
422        version_override,
423        Some(scope),
424        true
425    )?;
426
427    let pooled_manifest_path =
428        utils::safe_join(&unpack_path, Path::new("manifest.json"))?;
429    if pooled_manifest_path.exists() {
430        let content =
431            utils::read_file_within(&unpack_path, &pooled_manifest_path)?;
432        if let Ok(pooled_manifest) =
433            serde_json::from_str::<types::PooledZpaManifest>(&content)
434        {
435            return extract_pooled_zpa(
436                &pooled_manifest,
437                &unpack_path,
438                scope,
439                &metadata,
440                sub_packages,
441                link_bins,
442                pb,
443                yes,
444                registry_handle
445            );
446        }
447    }
448
449    let version = metadata.version.as_ref().ok_or_else(|| {
450        anyhow!(
451            "Package '{}' is missing version field in its metadata.",
452            metadata.name
453        )
454    })?;
455
456    if pb.is_none() {
457        println!(
458            "Installing package: {} v{}",
459            metadata.name.cyan(),
460            version.yellow()
461        );
462    }
463
464    let package_dir = local::get_package_dir(
465        scope,
466        registry_handle,
467        &metadata.repo,
468        &metadata.name
469    )?;
470    fs::create_dir_all(&package_dir)?;
471
472    let staging_dir = tempfile::Builder::new()
473        .prefix(".tmp-install-")
474        .tempdir_in(&package_dir)?;
475
476    let mut installed_files: Vec<String> = Vec::new();
477    let version_dir = package_dir.join(version);
478
479    let data_dir = temp_dir.path().join("data");
480    if data_dir.exists() {
481        if let Some(p) = pb {
482            p.set_message(format!("Installing {}...", metadata.name.cyan()));
483        } else {
484            println!("Installing {}...", metadata.name.cyan());
485        }
486
487        let subs_to_install = if let Some(subs) = sub_packages {
488            subs
489        } else if let Some(subs) = &metadata.sub_packages {
490            if let Some(main_subs) = &metadata.main_subs {
491                main_subs.clone()
492            } else {
493                let mut all = vec![String::new()];
494                all.extend(subs.clone());
495                all
496            }
497        } else {
498            vec![String::new()]
499        };
500
501        for sub in subs_to_install {
502            let sub_data_dir = if sub.is_empty() {
503                data_dir.clone()
504            } else {
505                utils::validate_path_component(&sub)?;
506                if pb.is_none() {
507                    println!("Installing sub-package: {}", sub.bold());
508                }
509                utils::safe_join(&data_dir, Path::new(&sub))?
510            };
511
512            if !sub_data_dir.exists() {
513                if pb.is_none() {
514                    eprintln!(
515                        "Warning: sub-package '{sub}' not found in archive, \
516                         skipping."
517                    );
518                }
519                continue;
520            }
521
522            let mut owned_files = HashSet::new();
523            let sub_opt = if sub.is_empty() {
524                None
525            } else {
526                Some(sub.as_str())
527            };
528            if let Ok(Some(manifest)) =
529                local::is_package_installed(&metadata.name, sub_opt, scope)
530            {
531                owned_files.extend(manifest.installed_files);
532            }
533
534            let pkgstore_src = sub_data_dir.join("pkgstore");
535            if pkgstore_src.exists() {
536                copy_dir_all(&pkgstore_src, staging_dir.path())?;
537            }
538
539            let usrroot_src = sub_data_dir.join("usrroot");
540            if usrroot_src.exists() {
541                if !utils::is_admin() {
542                    return Err(anyhow!(
543                        "Administrator privileges required to install \
544                         system-wide files. Please run with sudo or as an \
545                         administrator."
546                    ));
547                }
548                let root_dest =
549                    zoi_core::sysroot::apply_sysroot(PathBuf::from("/"));
550                check_and_handle_file_conflicts(
551                    &usrroot_src,
552                    &root_dest,
553                    &owned_files,
554                    Some(&InstallingPackage {
555                        name: &metadata.name,
556                        sub_package: sub_opt,
557                        repo: &metadata.repo,
558                        registry: registry_handle
559                    }),
560                    yes
561                )?;
562                copy_dir_all(&usrroot_src, &root_dest)?;
563                for entry in WalkDir::new(&usrroot_src)
564                    .into_iter()
565                    .filter_map(std::result::Result::ok)
566                {
567                    // Symlinks are recreated by copy_dir_all and must be
568                    // tracked too, otherwise they survive uninstall.
569                    if entry.file_type().is_file()
570                        || entry.file_type().is_symlink()
571                    {
572                        let rel_to_root =
573                            entry.path().strip_prefix(&usrroot_src)?;
574                        installed_files.push(format!(
575                            "${{usrroot}}/{}",
576                            rel_to_root.to_string_lossy().replace('\\', "/")
577                        ));
578                    }
579                }
580            }
581
582            let usrhome_src = sub_data_dir.join("usrhome");
583            if usrhome_src.exists() {
584                let home_dest = zoi_core::utils::get_user_home()
585                    .ok_or_else(|| anyhow!("Could not find home directory"))?;
586                check_and_handle_file_conflicts(
587                    &usrhome_src,
588                    &home_dest,
589                    &owned_files,
590                    Some(&InstallingPackage {
591                        name: &metadata.name,
592                        sub_package: sub_opt,
593                        repo: &metadata.repo,
594                        registry: registry_handle
595                    }),
596                    yes
597                )?;
598                copy_dir_all(&usrhome_src, &home_dest)?;
599                for entry in WalkDir::new(&usrhome_src)
600                    .into_iter()
601                    .filter_map(std::result::Result::ok)
602                {
603                    // Track symlinks here as well so cleanup stays complete.
604                    if entry.file_type().is_file()
605                        || entry.file_type().is_symlink()
606                    {
607                        let rel_to_home =
608                            entry.path().strip_prefix(&usrhome_src)?;
609                        installed_files.push(format!(
610                            "${{usrhome}}/{}",
611                            rel_to_home.to_string_lossy().replace('\\', "/")
612                        ));
613                    }
614                }
615            }
616        }
617    }
618
619    if let Some(p) = pb {
620        p.set_position(60);
621    }
622
623    for entry in WalkDir::new(staging_dir.path())
624        .into_iter()
625        .filter_map(std::result::Result::ok)
626    {
627        // copy_dir_all preserves symlinks into the store, so they must be
628        // recorded here as well.
629        if entry.file_type().is_file() || entry.file_type().is_symlink() {
630            let rel_path = entry.path().strip_prefix(staging_dir.path())?;
631            installed_files.push(format!(
632                "${{pkgstore}}/{}",
633                rel_path.to_string_lossy().replace('\\', "/")
634            ));
635        }
636    }
637
638    copy_dir_all(staging_dir.path(), &version_dir)?;
639
640    finalize_installation(
641        &version_dir,
642        &metadata,
643        scope,
644        link_bins,
645        pb,
646        &mut installed_files
647    )?;
648
649    if let Some(p) = pb {
650        p.set_position(100);
651    } else {
652        println!("{} Installation complete.", "Success:".green());
653    }
654    Ok((installed_files, BTreeMap::new()))
655}
656
657/// Finalizes the installation by creating backups, linking binaries,
658/// and installing shell completions and application bundles.
659fn finalize_installation(
660    version_dir: &Path,
661    metadata: &types::Package,
662    scope: types::Scope,
663    link_bins: bool,
664    pb: Option<&indicatif::ProgressBar>,
665    _installed_files: &mut Vec<String>
666) -> Result<()> {
667    // Create .zoiorig copies for 3-way merge support
668    if let Some(backup_files) = &metadata.backup {
669        for backup_file_rel in backup_files {
670            let expanded_path = utils::expand_placeholders(
671                backup_file_rel,
672                version_dir,
673                scope
674            )?;
675            // Backup paths in package metadata are relative to the package
676            // store (version dir), so resolve them against it here.
677            let backup_src = if Path::new(&expanded_path).is_absolute() {
678                PathBuf::from(expanded_path)
679            } else {
680                version_dir.join(expanded_path)
681            };
682
683            if backup_src.exists() && backup_src.is_file() {
684                let mut orig_path = backup_src.clone();
685                let ext =
686                    orig_path.extension().and_then(|s| s.to_str()).map_or_else(
687                        || "zoiorig".to_string(),
688                        |s| format!("{s}.zoiorig")
689                    );
690                orig_path.set_extension(ext);
691
692                if let Err(e) = fs::copy(&backup_src, &orig_path)
693                    && pb.is_none()
694                {
695                    eprintln!(
696                        "Warning: failed to create .zoiorig for {}: {}",
697                        backup_src.display(),
698                        e
699                    );
700                }
701            }
702        }
703    }
704
705    if link_bins && let Some(bins) = &metadata.bins {
706        let bin_root = get_bin_root(scope)?;
707        fs::create_dir_all(&bin_root)?;
708
709        let mut created_shims = Vec::new();
710        let mut link_error: Option<String> = None;
711
712        for bin_name in bins {
713            let mut found_bin = false;
714            for entry in WalkDir::new(version_dir)
715                .into_iter()
716                .filter_map(std::result::Result::ok)
717            {
718                if entry.file_type().is_file()
719                    && entry.file_name().to_string_lossy() == *bin_name
720                {
721                    let link_path = bin_root.join(bin_name);
722
723                    let zoi_exe = std::env::current_exe()?;
724                    if let Err(e) =
725                        zoi_core::utils::symlink_file(&zoi_exe, &link_path)
726                    {
727                        link_error = Some(e.to_string());
728                        break;
729                    }
730                    created_shims.push(link_path);
731
732                    if pb.is_none() {
733                        println!("Created shim for: {}", bin_name.green());
734                    }
735                    found_bin = true;
736                    break;
737                }
738            }
739            if link_error.is_some() {
740                break;
741            }
742            if !found_bin && pb.is_none() {
743                eprintln!(
744                    "Warning: could not find binary '{}' to link.",
745                    bin_name.yellow()
746                );
747            }
748        }
749
750        if let Some(e) = link_error {
751            for shim in created_shims {
752                let _ = fs::remove_file(shim);
753            }
754            return Err(anyhow!("Failed to create shims: {e}"));
755        }
756    }
757
758    let shell_dir = version_dir.join("shell");
759    if shell_dir.exists() {
760        for shell_entry in fs::read_dir(&shell_dir).map_err(|e| {
761            anyhow!("Failed to read shell completions directory: {e}")
762        })? {
763            let shell_entry = shell_entry?;
764            if !shell_entry.file_type()?.is_dir() {
765                continue;
766            }
767            let shell_name =
768                shell_entry.file_name().to_string_lossy().to_string();
769            let completions_root = get_completions_root(scope, &shell_name)?;
770            let pkg_completions_dir = completions_root.join(&metadata.name);
771            fs::create_dir_all(&pkg_completions_dir)?;
772
773            for file_entry in fs::read_dir(shell_entry.path()).map_err(|e| {
774                anyhow!("Failed to read shell/{shell_name}/ directory: {e}")
775            })? {
776                let file_entry = file_entry?;
777                if !file_entry.file_type()?.is_file() {
778                    continue;
779                }
780                let filename =
781                    file_entry.file_name().to_string_lossy().to_string();
782                let store_path = file_entry.path();
783                let link_path = pkg_completions_dir.join(&filename);
784                create_completion_symlink(&store_path, &link_path)?;
785                if pb.is_none() {
786                    println!(
787                        "Linked {} completion: {}",
788                        shell_name.green(),
789                        filename.cyan()
790                    );
791                }
792            }
793        }
794    }
795
796    #[cfg(target_os = "macos")]
797    {
798        let applications_dir = match scope {
799            types::Scope::System => PathBuf::from("/Applications"),
800            types::Scope::User => {
801                let home_dir = zoi_core::utils::get_user_home()
802                    .ok_or_else(|| anyhow!("Could not find home directory."))?;
803                home_dir.join("Applications")
804            }
805            types::Scope::Project => {
806                std::env::current_dir()?.join("Applications")
807            }
808        };
809
810        let mut app_bundles = Vec::new();
811        for entry in WalkDir::new(version_dir)
812            .max_depth(2)
813            .into_iter()
814            .filter_map(|e| e.ok())
815        {
816            if entry.file_type().is_dir()
817                && entry.file_name().to_string_lossy().ends_with(".app")
818            {
819                app_bundles.push(entry.path().to_path_buf());
820            }
821        }
822
823        if !app_bundles.is_empty() {
824            fs::create_dir_all(&applications_dir)?;
825            for app_path in app_bundles {
826                if zoi_core::utils::command_exists("xattr") {
827                    let _ = std::process::Command::new("xattr")
828                        .arg("-r")
829                        .arg("-d")
830                        .arg("com.apple.quarantine")
831                        .arg(&app_path)
832                        .status();
833                }
834
835                let app_name = app_path.file_name().ok_or_else(|| {
836                    anyhow!("App path has no filename: {:?}", app_path)
837                })?;
838                // `file_name` already yields a single component, but the bundle
839                // name comes from inside the package archive, so pin the link
840                // to the applications directory explicitly rather than relying
841                // on that invariant holding upstream.
842                let symlink_path = zoi_core::utils::safe_join(
843                    &applications_dir,
844                    Path::new(app_name)
845                )?;
846
847                if symlink_path.exists() {
848                    let _ = fs::remove_file(&symlink_path);
849                    let _ = fs::remove_dir_all(&symlink_path);
850                }
851
852                if std::os::unix::fs::symlink(&app_path, &symlink_path).is_ok()
853                {
854                    _installed_files.push(format!(
855                        "${{applications}}/{}",
856                        app_name.to_string_lossy().replace('\\', "/")
857                    ));
858                    if pb.is_none() {
859                        println!(
860                            "Linked {} to {}",
861                            app_name.to_string_lossy().green(),
862                            applications_dir.display()
863                        );
864                    }
865                }
866            }
867        }
868    }
869
870    Ok(())
871}
872
873/// Extracts and installs a pooled ZPA package using its manifest
874/// to map files from the pool to their final destinations.
875fn extract_pooled_zpa(
876    pooled_manifest: &types::PooledZpaManifest,
877    unpack_path: &Path,
878    scope: types::Scope,
879    metadata: &types::Package,
880    sub_packages: Option<Vec<String>>,
881    link_bins: bool,
882    pb: Option<&indicatif::ProgressBar>,
883    yes: bool,
884    registry_handle: &str
885) -> Result<(Vec<String>, BTreeMap<String, String>)> {
886    let version = metadata.version.as_ref().ok_or_else(|| {
887        anyhow!(
888            "Package '{}' is missing version field in its metadata.",
889            metadata.name
890        )
891    })?;
892
893    if pb.is_none() {
894        println!(
895            "Installing pooled package: {} v{} [{:?}]",
896            metadata.name.cyan(),
897            version.yellow(),
898            scope
899        );
900    }
901
902    let package_dir = local::get_package_dir(
903        scope,
904        registry_handle,
905        &metadata.repo,
906        &metadata.name
907    )?;
908    fs::create_dir_all(&package_dir)?;
909
910    let staging_dir = tempfile::Builder::new()
911        .prefix(".tmp-install-")
912        .tempdir_in(&package_dir)?;
913
914    let mut installed_files: Vec<String> = Vec::new();
915    // Content digests recorded for post-install verification (`zoi package
916    // verify`). Keyed by the same tracked path that goes into
917    // installed_files.
918    let mut file_digests: BTreeMap<String, String> = BTreeMap::new();
919    let version_dir = package_dir.join(version);
920
921    let subs_to_install = if let Some(subs) = sub_packages {
922        subs
923    } else if let Some(subs) = &metadata.sub_packages {
924        if let Some(main_subs) = &metadata.main_subs {
925            main_subs.clone()
926        } else {
927            subs.clone()
928        }
929    } else {
930        vec![String::new()]
931    };
932
933    let pool_dir = unpack_path.join("pool");
934
935    let mut conflicts = Vec::new();
936    let mut owned_files = HashSet::new();
937
938    // ${createpkgdir} resolves to the invocation directory, so its absolute
939    // location is recorded at install time instead of the placeholder.
940    // Otherwise cleanup from a different directory could not find (or worse,
941    // could mis-target) these artifacts.
942    let track_dest = |dest: &str| -> Result<String> {
943        if dest.starts_with("${createpkgdir}") {
944            Ok(expand_pooled_path(dest, staging_dir.path(), scope)?
945                .to_string_lossy()
946                .to_string())
947        } else {
948            Ok(dest.to_string())
949        }
950    };
951
952    for sub in &subs_to_install {
953        let sub_opt = if sub.is_empty() {
954            None
955        } else {
956            Some(sub.as_str())
957        };
958        if let Ok(Some(manifest)) =
959            local::is_package_installed(&metadata.name, sub_opt, scope)
960        {
961            owned_files.extend(manifest.installed_files);
962        }
963
964        if let Some(sub_mapping) = pooled_manifest.mappings.get(sub)
965            && let Some(scope_mapping) = sub_mapping.scopes.get(&scope)
966        {
967            for mapped_file in &scope_mapping.files {
968                let dest_path = expand_pooled_path(
969                    &mapped_file.dest,
970                    staging_dir.path(),
971                    scope
972                )?;
973                // We only check conflicts for files that land outside the Zoi
974                // store's staging area
975                if !mapped_file.dest.starts_with("${pkgstore}")
976                    && dest_path.exists()
977                    && !owned_files.contains(&mapped_file.dest)
978                    // Older manifests may have recorded the expanded form.
979                    && !owned_files
980                        .contains(&dest_path.to_string_lossy().to_string())
981                {
982                    conflicts.push(dest_path);
983                }
984            }
985        }
986    }
987
988    if !conflicts.is_empty() {
989        println!();
990        println!("{}", "File Conflict Detected:".red().bold());
991        println!(
992            "The following files that this package wants to install already \
993             exist on your system:"
994        );
995        for file in &conflicts {
996            println!("- {}", file.display());
997        }
998        println!();
999
1000        if !utils::ask_for_confirmation(
1001            "Do you want to overwrite these files and continue with the \
1002             installation?",
1003            yes
1004        ) {
1005            return Err(anyhow!(
1006                "Installation aborted by user due to file conflicts."
1007            ));
1008        }
1009    }
1010
1011    for sub in subs_to_install {
1012        let Some(sub_mapping) = pooled_manifest.mappings.get(&sub) else {
1013            if pb.is_none() {
1014                eprintln!(
1015                    "Warning: mapping for sub-package '{sub}' not found in \
1016                     archive, skipping."
1017                );
1018            }
1019            continue;
1020        };
1021
1022        let Some(scope_mapping) = sub_mapping.scopes.get(&scope) else {
1023            if pb.is_none() {
1024                eprintln!(
1025                    "Warning: mapping for scope {scope:?} not found for \
1026                     sub-package '{sub}', skipping."
1027                );
1028            }
1029            continue;
1030        };
1031
1032        // Step 1: Create directories
1033        for mapped_dir in &scope_mapping.dirs {
1034            let dest_path = expand_pooled_path(
1035                &mapped_dir.path,
1036                staging_dir.path(),
1037                scope
1038            )?;
1039            fs::create_dir_all(&dest_path)?;
1040
1041            #[cfg(unix)]
1042            {
1043                if let Some(mode) = mapped_dir.mode {
1044                    use std::os::unix::fs::PermissionsExt;
1045                    fs::set_permissions(
1046                        &dest_path,
1047                        fs::Permissions::from_mode(mode)
1048                    )?;
1049                }
1050                if let (Some(owner), Some(group)) =
1051                    (&mapped_dir.owner, &mapped_dir.group)
1052                {
1053                    let _ = utils::set_path_owner(&dest_path, owner, group);
1054                }
1055            }
1056        }
1057
1058        // Step 2: Extract files
1059        for mapped_file in &scope_mapping.files {
1060            let pool_file =
1061                utils::safe_join(&pool_dir, Path::new(&mapped_file.hash))?;
1062            if !pool_file.exists() {
1063                return Err(anyhow!("Pool file missing: {}", mapped_file.hash));
1064            }
1065            let expected_hash =
1066                mapped_file.hash.strip_prefix("sha256-").ok_or_else(|| {
1067                    anyhow!(
1068                        "Unsupported pool hash format: {}",
1069                        mapped_file.hash
1070                    )
1071                })?;
1072            let pool_entry = pooled_manifest
1073                .pool
1074                .get(&mapped_file.hash)
1075                .ok_or_else(|| {
1076                    anyhow!("Pool manifest entry missing: {}", mapped_file.hash)
1077                })?;
1078            if pool_file.metadata()?.len() != pool_entry.size {
1079                return Err(anyhow!(
1080                    "Pool file size does not match manifest: {}",
1081                    mapped_file.hash
1082                ));
1083            }
1084            let actual_hash =
1085                calculate_file_hash(&pool_file, HashAlgorithm::Sha256)?;
1086            if actual_hash != expected_hash {
1087                return Err(anyhow!(
1088                    "Pool file hash does not match manifest: {}",
1089                    mapped_file.hash
1090                ));
1091            }
1092
1093            let dest_path = expand_pooled_path(
1094                &mapped_file.dest,
1095                staging_dir.path(),
1096                scope
1097            )?;
1098
1099            if let Some(parent) = dest_path.parent() {
1100                fs::create_dir_all(parent)?;
1101            }
1102
1103            fs::copy(&pool_file, &dest_path)?;
1104
1105            #[cfg(unix)]
1106            {
1107                use std::os::unix::fs::PermissionsExt;
1108                fs::set_permissions(
1109                    &dest_path,
1110                    fs::Permissions::from_mode(mapped_file.mode)
1111                )?;
1112                if let (Some(owner), Some(group)) =
1113                    (&mapped_file.owner, &mapped_file.group)
1114                {
1115                    let _ = utils::set_path_owner(&dest_path, owner, group);
1116                }
1117            }
1118
1119            let tracked = track_dest(&mapped_file.dest)?;
1120            file_digests.insert(tracked.clone(), mapped_file.hash.clone());
1121            installed_files.push(tracked);
1122        }
1123
1124        // Step 3: Create symlinks
1125        for mapped_link in &scope_mapping.symlinks {
1126            let dest_path = expand_pooled_path(
1127                &mapped_link.link,
1128                staging_dir.path(),
1129                scope
1130            )?;
1131
1132            if let Some(parent) = dest_path.parent() {
1133                fs::create_dir_all(parent)?;
1134            }
1135
1136            if dest_path.exists() || dest_path.is_symlink() {
1137                fs::remove_file(&dest_path).ok();
1138            }
1139
1140            // Resolve target placeholders if any
1141            let target = mapped_link.target.clone();
1142
1143            utils::symlink_file(Path::new(&target), &dest_path)?;
1144            installed_files.push(track_dest(&mapped_link.link)?);
1145        }
1146    }
1147
1148    // Finalize staging-to-store move
1149    fs::create_dir_all(&version_dir)?;
1150    copy_dir_all(staging_dir.path(), &version_dir)?;
1151
1152    finalize_installation(
1153        &version_dir,
1154        metadata,
1155        scope,
1156        link_bins,
1157        pb,
1158        &mut installed_files
1159    )?;
1160
1161    if let Some(p) = pb {
1162        p.set_position(100);
1163    }
1164
1165    Ok((installed_files, file_digests))
1166}
1167
1168/// Expands placeholders in a pooled path to their absolute filesystem paths.
1169fn expand_pooled_path(
1170    path: &str,
1171    staging_path: &Path,
1172    _scope: types::Scope
1173) -> Result<PathBuf> {
1174    let safe_join = |base: PathBuf, relative: &str| -> Result<PathBuf> {
1175        let relative_path = Path::new(relative);
1176        if !utils::is_safe_path(&base, relative_path) {
1177            return Err(anyhow!(
1178                "Pooled path escapes its destination root: {path}"
1179            ));
1180        }
1181        Ok(base.join(relative_path))
1182    };
1183
1184    if let Some(rel) = path.strip_prefix("${pkgstore}/") {
1185        safe_join(staging_path.to_path_buf(), rel)
1186    } else if let Some(rel) = path.strip_prefix("${usrroot}/") {
1187        safe_join(zoi_core::sysroot::apply_sysroot(PathBuf::from("/")), rel)
1188    } else if let Some(rel) = path.strip_prefix("${usrhome}/") {
1189        let home_dir = zoi_core::utils::get_user_home()
1190            .ok_or_else(|| anyhow!("Home dir not found"))?;
1191        safe_join(home_dir, rel)
1192    } else if let Some(rel) = path.strip_prefix("${createpkgdir}/") {
1193        safe_join(std::env::current_dir()?, rel)
1194    } else {
1195        Err(anyhow!("Invalid pooled path placeholder: {path}"))
1196    }
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::*;
1202
1203    #[test]
1204    fn pooled_paths_should_not_escape_their_roots() {
1205        let staging =
1206            tempfile::tempdir().expect("staging directory should exist");
1207
1208        assert!(
1209            expand_pooled_path(
1210                "${pkgstore}/../outside",
1211                staging.path(),
1212                types::Scope::User
1213            )
1214            .is_err()
1215        );
1216    }
1217}