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