Skip to main content

zoi_install/
pkg_install.rs

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