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