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