Skip to main content

zoi_install/
pkg_install.rs

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