1use std::collections::{BTreeMap, HashSet};
2use std::fs::{self, File};
3use std::io::{Read, Seek};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Result, anyhow};
7use colored::Colorize;
8use tar::Archive;
9use tempfile::Builder;
10use walkdir::WalkDir;
11use zoi_core::hash::{HashAlgorithm, calculate_file_hash};
12use zoi_core::types;
13use zoi_core::utils::{self, copy_dir_all};
14use zoi_db as db;
15use zoi_resolver::local;
16use zstd::stream::read::Decoder as ZstdDecoder;
17
18fn get_bin_root(scope: types::Scope) -> Result<PathBuf> {
21 match scope {
22 types::Scope::User => zoi_core::utils::get_user_bin_dir(),
23 types::Scope::System => Ok(zoi_core::utils::get_system_bin_dir()),
24 types::Scope::Project => {
25 let current_dir = std::env::current_dir()?;
26 Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
27 }
28 }
29}
30
31fn get_completions_root(scope: types::Scope, shell: &str) -> Result<PathBuf> {
34 match scope {
35 types::Scope::User => zoi_core::utils::get_user_completions_dir(shell),
36 types::Scope::System => {
37 if cfg!(target_os = "windows") {
38 Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(format!(
39 "C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}"
40 ))))
41 } else if zoi_core::utils::is_zoios() {
42 let base = match shell {
43 "bash" => "/usr/share/bash-completion/completions",
44 "zsh" => "/usr/share/zsh/site-functions",
45 "fish" => "/usr/share/fish/vendor_completions.d",
46 "elvish" => "/usr/share/elvish/lib",
47 _ => "/usr/share/zoi/completions"
48 };
49 Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(base)))
50 } else {
51 let base = match shell {
52 "bash" => "/usr/share/bash-completion/completions",
53 "zsh" => "/usr/share/zsh/site-functions",
54 "fish" => "/usr/share/fish/vendor_completions.d",
55 "elvish" => "/usr/share/elvish/lib",
56 _ => "/usr/local/share/zoi/completions"
57 };
58 Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(base)))
59 }
60 }
61 types::Scope::Project => {
62 let current_dir = std::env::current_dir()?;
63 Ok(current_dir
64 .join(".zoi")
65 .join("pkgs")
66 .join("shell")
67 .join(shell))
68 }
69 }
70}
71
72fn create_completion_symlink(source: &Path, link: &Path) -> Result<()> {
75 if link.exists() || link.is_symlink() {
76 fs::remove_file(link)?;
77 }
78 if let Some(parent) = link.parent() {
79 fs::create_dir_all(parent)?;
80 }
81 #[cfg(unix)]
82 {
83 std::os::unix::fs::symlink(source, link)
84 .map_err(|e| anyhow!("Failed to create completion symlink: {e}"))?;
85 }
86 #[cfg(windows)]
87 {
88 std::os::windows::fs::symlink_file(source, link).map_err(|e| {
89 anyhow!("Failed to create completion symlink: {}", e)
90 })?;
91 }
92 Ok(())
93}
94
95struct FileConflict {
98 path: PathBuf,
100 owners: Vec<String>
105}
106
107struct InstallingPackage<'a> {
114 name: &'a str,
116 sub_package: Option<&'a str>,
118 repo: &'a str,
120 registry: &'a str
122}
123
124fn check_and_handle_file_conflicts(
139 source_dir: &Path,
140 dest_dir: &Path,
141 owned_files: &HashSet<String>,
142 package: Option<&InstallingPackage<'_>>,
143 yes: bool
144) -> Result<()> {
145 let mut stray_files = Vec::new();
146 let mut owned_by_others: Vec<FileConflict> = Vec::new();
147
148 let (conn, current_pkg_id) = match package {
157 Some(pkg) => {
158 let registry_key = if pkg.registry.is_empty() {
161 "local"
162 } else {
163 pkg.registry
164 };
165 match db::open_connection(registry_key) {
166 Ok(conn) => {
167 let id = db::get_package_id(
168 &conn,
169 pkg.name,
170 pkg.sub_package,
171 pkg.repo,
172 registry_key
173 )
174 .ok();
175 (Some(conn), id)
176 }
177 Err(_) => (None, None)
178 }
179 }
180 None => (None, None)
181 };
182
183 for entry in WalkDir::new(source_dir)
184 .into_iter()
185 .filter_map(std::result::Result::ok)
186 .skip(1)
187 {
188 if !entry.file_type().is_file() {
189 continue;
190 }
191
192 let relative_path = entry.path().strip_prefix(source_dir)?;
193 let dest_path = dest_dir.join(relative_path);
194
195 if !dest_path.exists() {
196 continue;
197 }
198
199 let expanded = zoi_core::utils::expand_placeholders(
205 &dest_path.to_string_lossy(),
206 Path::new(""),
207 types::Scope::System
208 );
209 let is_self_owned = expanded
210 .as_ref()
211 .is_ok_and(|p| owned_files.contains(p))
212 || owned_files.contains(&dest_path.to_string_lossy().to_string());
213
214 if is_self_owned {
215 continue;
216 }
217
218 let owners = conn
219 .as_ref()
220 .and_then(|c| {
221 db::get_other_owners(
222 c,
223 &dest_path.to_string_lossy(),
224 current_pkg_id
225 )
226 .ok()
227 })
228 .unwrap_or_default();
229
230 if owners.is_empty() {
231 stray_files.push(dest_path);
232 } else {
233 owned_by_others.push(FileConflict {
234 path: dest_path,
235 owners
236 });
237 }
238 }
239
240 if !stray_files.is_empty() {
241 println!();
242 println!("{}", "File Conflict Detected:".red().bold());
243 println!(
244 "The following files that this package wants to install already \
245 exist on your system:"
246 );
247 for file in &stray_files {
248 println!("- {}", file.display());
249 }
250 println!();
251
252 if !utils::ask_for_confirmation(
253 "Do you want to overwrite these files and continue with the \
254 installation?",
255 yes
256 ) {
257 return Err(anyhow!(
258 "Installation aborted by user due to file conflicts."
259 ));
260 }
261 }
262
263 if !owned_by_others.is_empty() {
264 println!();
265 println!("{}", "File Conflict With Another Package:".red().bold());
266 println!(
267 "The following files are owned by other installed packages. \
268 Installing this package would replace them, leaving the other \
269 package broken:"
270 );
271 for conflict in &owned_by_others {
272 println!(
273 "- {} (owned by {})",
274 conflict.path.display(),
275 conflict.owners.join(", ")
276 );
277 }
278 println!();
279
280 if !utils::ask_for_confirmation(
281 "Do you want to overwrite these files anyway? The packages listed \
282 above may stop working correctly.",
283 yes
284 ) {
285 return Err(anyhow!(
286 "Installation aborted by user due to file conflicts with \
287 other packages."
288 ));
289 }
290 }
291
292 Ok(())
293}
294
295pub fn run(
317 package_file: &Path,
318 scope_override: Option<types::Scope>,
319 registry_handle: &str,
320 version_override: Option<&str>,
321 yes: bool,
322 sub_packages: Option<Vec<String>>,
323 link_bins: bool,
324 pb: Option<&indicatif::ProgressBar>
325) -> Result<(Vec<String>, BTreeMap<String, String>)> {
326 let scope = scope_override.unwrap_or(types::Scope::User);
327
328 if package_file.as_os_str().is_empty() {
330 if pb.is_none() {
331 println!("Initializing meta-package...");
332 }
333 return Ok((Vec::new(), BTreeMap::new()));
334 }
335
336 if pb.is_none() {
337 println!(
338 "Installing from package archive: {}",
339 package_file.display()
340 );
341 }
342
343 let file_metadata = fs::metadata(package_file)
344 .map_err(|e| anyhow!("Failed to get archive metadata: {e}"))?;
345 let file_size = file_metadata.len();
346
347 if pb.is_none() {
348 println!("Archive size: {}", zoi_core::utils::format_bytes(file_size));
349 }
350
351 let mut file = File::open(package_file)
352 .map_err(|e| anyhow!("Failed to open package archive: {e}"))?;
353
354 let mut magic = [0u8; 4];
355 if file.read_exact(&mut magic).is_ok() && magic != [0x28, 0xB5, 0x2F, 0xFD]
356 {
357 return Err(anyhow!(
358 "Invalid archive format: expected zstd magic number 28 B5 2F FD, \
359 but found {magic:02X?}. This file is likely not a valid .zst \
360 archive."
361 ));
362 }
363
364 file.rewind()
365 .map_err(|e| anyhow!("Failed to rewind archive file: {e}"))?;
366
367 let decoder = ZstdDecoder::new(file)
368 .map_err(|e| anyhow!("Failed to initialize zstd decoder: {e}"))?;
369 let mut archive = Archive::new(decoder);
370
371 #[cfg(target_os = "linux")]
372 archive.set_unpack_xattrs(true);
373
374 let temp_dir = Builder::new().prefix("zoi-install-").tempdir()?;
375 let unpack_path = temp_dir.path().to_path_buf();
376
377 for entry_res in archive
378 .entries()
379 .map_err(|e| anyhow!("Failed to read archive entries: {e}"))?
380 {
381 let mut entry = entry_res.map_err(|e| {
382 anyhow!(
383 "Failed to process archive entry: {e}. The archive may be \
384 truncated or corrupted."
385 )
386 })?;
387 let path = entry
388 .path()
389 .map_err(|e| anyhow!("Failed to get entry path: {e}"))?
390 .to_path_buf();
391 entry.unpack_in(&unpack_path).map_err(|e| {
392 anyhow!("Failed to unpack file '{}': {}", path.display(), e)
393 })?;
394 }
395
396 let mut pkg_lua_path = None;
397 for entry in WalkDir::new(temp_dir.path())
398 .into_iter()
399 .filter_map(std::result::Result::ok)
400 {
401 if entry.file_name().to_string_lossy().ends_with(".pkg.lua") {
402 pkg_lua_path = Some(entry.path().to_path_buf());
403 break;
404 }
405 }
406 let pkg_lua_path = pkg_lua_path.ok_or_else(|| {
407 anyhow!(
408 "Could not find .pkg.lua file in archive '{}'",
409 package_file.display()
410 )
411 })?;
412
413 let platform = utils::get_platform()?;
414 let metadata = zoi_lua::parser::parse_lua_package_for_platform(
415 pkg_lua_path.to_str().ok_or_else(|| {
416 anyhow!(
417 "Path contains invalid UTF-8 characters: {}",
418 pkg_lua_path.display()
419 )
420 })?,
421 &platform,
422 version_override,
423 Some(scope),
424 true
425 )?;
426
427 let pooled_manifest_path =
428 utils::safe_join(&unpack_path, Path::new("manifest.json"))?;
429 if pooled_manifest_path.exists() {
430 let content =
431 utils::read_file_within(&unpack_path, &pooled_manifest_path)?;
432 if let Ok(pooled_manifest) =
433 serde_json::from_str::<types::PooledZpaManifest>(&content)
434 {
435 return extract_pooled_zpa(
436 &pooled_manifest,
437 &unpack_path,
438 scope,
439 &metadata,
440 sub_packages,
441 link_bins,
442 pb,
443 yes,
444 registry_handle
445 );
446 }
447 }
448
449 let version = metadata.version.as_ref().ok_or_else(|| {
450 anyhow!(
451 "Package '{}' is missing version field in its metadata.",
452 metadata.name
453 )
454 })?;
455
456 if pb.is_none() {
457 println!(
458 "Installing package: {} v{}",
459 metadata.name.cyan(),
460 version.yellow()
461 );
462 }
463
464 let package_dir = local::get_package_dir(
465 scope,
466 registry_handle,
467 &metadata.repo,
468 &metadata.name
469 )?;
470 fs::create_dir_all(&package_dir)?;
471
472 let staging_dir = tempfile::Builder::new()
473 .prefix(".tmp-install-")
474 .tempdir_in(&package_dir)?;
475
476 let mut installed_files: Vec<String> = Vec::new();
477 let version_dir = package_dir.join(version);
478
479 let data_dir = temp_dir.path().join("data");
480 if data_dir.exists() {
481 if let Some(p) = pb {
482 p.set_message(format!("Installing {}...", metadata.name.cyan()));
483 } else {
484 println!("Installing {}...", metadata.name.cyan());
485 }
486
487 let subs_to_install = if let Some(subs) = sub_packages {
488 subs
489 } else if let Some(subs) = &metadata.sub_packages {
490 if let Some(main_subs) = &metadata.main_subs {
491 main_subs.clone()
492 } else {
493 let mut all = vec![String::new()];
494 all.extend(subs.clone());
495 all
496 }
497 } else {
498 vec![String::new()]
499 };
500
501 for sub in subs_to_install {
502 let sub_data_dir = if sub.is_empty() {
503 data_dir.clone()
504 } else {
505 utils::validate_path_component(&sub)?;
506 if pb.is_none() {
507 println!("Installing sub-package: {}", sub.bold());
508 }
509 utils::safe_join(&data_dir, Path::new(&sub))?
510 };
511
512 if !sub_data_dir.exists() {
513 if pb.is_none() {
514 eprintln!(
515 "Warning: sub-package '{sub}' not found in archive, \
516 skipping."
517 );
518 }
519 continue;
520 }
521
522 let mut owned_files = HashSet::new();
523 let sub_opt = if sub.is_empty() {
524 None
525 } else {
526 Some(sub.as_str())
527 };
528 if let Ok(Some(manifest)) =
529 local::is_package_installed(&metadata.name, sub_opt, scope)
530 {
531 owned_files.extend(manifest.installed_files);
532 }
533
534 let pkgstore_src = sub_data_dir.join("pkgstore");
535 if pkgstore_src.exists() {
536 copy_dir_all(&pkgstore_src, staging_dir.path())?;
537 }
538
539 let usrroot_src = sub_data_dir.join("usrroot");
540 if usrroot_src.exists() {
541 if !utils::is_admin() {
542 return Err(anyhow!(
543 "Administrator privileges required to install \
544 system-wide files. Please run with sudo or as an \
545 administrator."
546 ));
547 }
548 let root_dest =
549 zoi_core::sysroot::apply_sysroot(PathBuf::from("/"));
550 check_and_handle_file_conflicts(
551 &usrroot_src,
552 &root_dest,
553 &owned_files,
554 Some(&InstallingPackage {
555 name: &metadata.name,
556 sub_package: sub_opt,
557 repo: &metadata.repo,
558 registry: registry_handle
559 }),
560 yes
561 )?;
562 copy_dir_all(&usrroot_src, &root_dest)?;
563 for entry in WalkDir::new(&usrroot_src)
564 .into_iter()
565 .filter_map(std::result::Result::ok)
566 {
567 if entry.file_type().is_file()
570 || entry.file_type().is_symlink()
571 {
572 let rel_to_root =
573 entry.path().strip_prefix(&usrroot_src)?;
574 installed_files.push(format!(
575 "${{usrroot}}/{}",
576 rel_to_root.to_string_lossy().replace('\\', "/")
577 ));
578 }
579 }
580 }
581
582 let usrhome_src = sub_data_dir.join("usrhome");
583 if usrhome_src.exists() {
584 let home_dest = zoi_core::utils::get_user_home()
585 .ok_or_else(|| anyhow!("Could not find home directory"))?;
586 check_and_handle_file_conflicts(
587 &usrhome_src,
588 &home_dest,
589 &owned_files,
590 Some(&InstallingPackage {
591 name: &metadata.name,
592 sub_package: sub_opt,
593 repo: &metadata.repo,
594 registry: registry_handle
595 }),
596 yes
597 )?;
598 copy_dir_all(&usrhome_src, &home_dest)?;
599 for entry in WalkDir::new(&usrhome_src)
600 .into_iter()
601 .filter_map(std::result::Result::ok)
602 {
603 if entry.file_type().is_file()
605 || entry.file_type().is_symlink()
606 {
607 let rel_to_home =
608 entry.path().strip_prefix(&usrhome_src)?;
609 installed_files.push(format!(
610 "${{usrhome}}/{}",
611 rel_to_home.to_string_lossy().replace('\\', "/")
612 ));
613 }
614 }
615 }
616 }
617 }
618
619 if let Some(p) = pb {
620 p.set_position(60);
621 }
622
623 for entry in WalkDir::new(staging_dir.path())
624 .into_iter()
625 .filter_map(std::result::Result::ok)
626 {
627 if entry.file_type().is_file() || entry.file_type().is_symlink() {
630 let rel_path = entry.path().strip_prefix(staging_dir.path())?;
631 installed_files.push(format!(
632 "${{pkgstore}}/{}",
633 rel_path.to_string_lossy().replace('\\', "/")
634 ));
635 }
636 }
637
638 copy_dir_all(staging_dir.path(), &version_dir)?;
639
640 finalize_installation(
641 &version_dir,
642 &metadata,
643 scope,
644 link_bins,
645 pb,
646 &mut installed_files
647 )?;
648
649 if let Some(p) = pb {
650 p.set_position(100);
651 } else {
652 println!("{} Installation complete.", "Success:".green());
653 }
654 Ok((installed_files, BTreeMap::new()))
655}
656
657fn finalize_installation(
660 version_dir: &Path,
661 metadata: &types::Package,
662 scope: types::Scope,
663 link_bins: bool,
664 pb: Option<&indicatif::ProgressBar>,
665 _installed_files: &mut Vec<String>
666) -> Result<()> {
667 if let Some(backup_files) = &metadata.backup {
669 for backup_file_rel in backup_files {
670 let expanded_path = utils::expand_placeholders(
671 backup_file_rel,
672 version_dir,
673 scope
674 )?;
675 let backup_src = if Path::new(&expanded_path).is_absolute() {
678 PathBuf::from(expanded_path)
679 } else {
680 version_dir.join(expanded_path)
681 };
682
683 if backup_src.exists() && backup_src.is_file() {
684 let mut orig_path = backup_src.clone();
685 let ext =
686 orig_path.extension().and_then(|s| s.to_str()).map_or_else(
687 || "zoiorig".to_string(),
688 |s| format!("{s}.zoiorig")
689 );
690 orig_path.set_extension(ext);
691
692 if let Err(e) = fs::copy(&backup_src, &orig_path)
693 && pb.is_none()
694 {
695 eprintln!(
696 "Warning: failed to create .zoiorig for {}: {}",
697 backup_src.display(),
698 e
699 );
700 }
701 }
702 }
703 }
704
705 if link_bins && let Some(bins) = &metadata.bins {
706 let bin_root = get_bin_root(scope)?;
707 fs::create_dir_all(&bin_root)?;
708
709 let mut created_shims = Vec::new();
710 let mut link_error: Option<String> = None;
711
712 for bin_name in bins {
713 let mut found_bin = false;
714 for entry in WalkDir::new(version_dir)
715 .into_iter()
716 .filter_map(std::result::Result::ok)
717 {
718 if entry.file_type().is_file()
719 && entry.file_name().to_string_lossy() == *bin_name
720 {
721 let link_path = bin_root.join(bin_name);
722
723 let zoi_exe = std::env::current_exe()?;
724 if let Err(e) =
725 zoi_core::utils::symlink_file(&zoi_exe, &link_path)
726 {
727 link_error = Some(e.to_string());
728 break;
729 }
730 created_shims.push(link_path);
731
732 if pb.is_none() {
733 println!("Created shim for: {}", bin_name.green());
734 }
735 found_bin = true;
736 break;
737 }
738 }
739 if link_error.is_some() {
740 break;
741 }
742 if !found_bin && pb.is_none() {
743 eprintln!(
744 "Warning: could not find binary '{}' to link.",
745 bin_name.yellow()
746 );
747 }
748 }
749
750 if let Some(e) = link_error {
751 for shim in created_shims {
752 let _ = fs::remove_file(shim);
753 }
754 return Err(anyhow!("Failed to create shims: {e}"));
755 }
756 }
757
758 let shell_dir = version_dir.join("shell");
759 if shell_dir.exists() {
760 for shell_entry in fs::read_dir(&shell_dir).map_err(|e| {
761 anyhow!("Failed to read shell completions directory: {e}")
762 })? {
763 let shell_entry = shell_entry?;
764 if !shell_entry.file_type()?.is_dir() {
765 continue;
766 }
767 let shell_name =
768 shell_entry.file_name().to_string_lossy().to_string();
769 let completions_root = get_completions_root(scope, &shell_name)?;
770 let pkg_completions_dir = completions_root.join(&metadata.name);
771 fs::create_dir_all(&pkg_completions_dir)?;
772
773 for file_entry in fs::read_dir(shell_entry.path()).map_err(|e| {
774 anyhow!("Failed to read shell/{shell_name}/ directory: {e}")
775 })? {
776 let file_entry = file_entry?;
777 if !file_entry.file_type()?.is_file() {
778 continue;
779 }
780 let filename =
781 file_entry.file_name().to_string_lossy().to_string();
782 let store_path = file_entry.path();
783 let link_path = pkg_completions_dir.join(&filename);
784 create_completion_symlink(&store_path, &link_path)?;
785 if pb.is_none() {
786 println!(
787 "Linked {} completion: {}",
788 shell_name.green(),
789 filename.cyan()
790 );
791 }
792 }
793 }
794 }
795
796 #[cfg(target_os = "macos")]
797 {
798 let applications_dir = match scope {
799 types::Scope::System => PathBuf::from("/Applications"),
800 types::Scope::User => {
801 let home_dir = zoi_core::utils::get_user_home()
802 .ok_or_else(|| anyhow!("Could not find home directory."))?;
803 home_dir.join("Applications")
804 }
805 types::Scope::Project => {
806 std::env::current_dir()?.join("Applications")
807 }
808 };
809
810 let mut app_bundles = Vec::new();
811 for entry in WalkDir::new(version_dir)
812 .max_depth(2)
813 .into_iter()
814 .filter_map(|e| e.ok())
815 {
816 if entry.file_type().is_dir()
817 && entry.file_name().to_string_lossy().ends_with(".app")
818 {
819 app_bundles.push(entry.path().to_path_buf());
820 }
821 }
822
823 if !app_bundles.is_empty() {
824 fs::create_dir_all(&applications_dir)?;
825 for app_path in app_bundles {
826 if zoi_core::utils::command_exists("xattr") {
827 let _ = std::process::Command::new("xattr")
828 .arg("-r")
829 .arg("-d")
830 .arg("com.apple.quarantine")
831 .arg(&app_path)
832 .status();
833 }
834
835 let app_name = app_path.file_name().ok_or_else(|| {
836 anyhow!("App path has no filename: {:?}", app_path)
837 })?;
838 let symlink_path = zoi_core::utils::safe_join(
843 &applications_dir,
844 Path::new(app_name)
845 )?;
846
847 if symlink_path.exists() {
848 let _ = fs::remove_file(&symlink_path);
849 let _ = fs::remove_dir_all(&symlink_path);
850 }
851
852 if std::os::unix::fs::symlink(&app_path, &symlink_path).is_ok()
853 {
854 _installed_files.push(format!(
855 "${{applications}}/{}",
856 app_name.to_string_lossy().replace('\\', "/")
857 ));
858 if pb.is_none() {
859 println!(
860 "Linked {} to {}",
861 app_name.to_string_lossy().green(),
862 applications_dir.display()
863 );
864 }
865 }
866 }
867 }
868 }
869
870 Ok(())
871}
872
873fn extract_pooled_zpa(
876 pooled_manifest: &types::PooledZpaManifest,
877 unpack_path: &Path,
878 scope: types::Scope,
879 metadata: &types::Package,
880 sub_packages: Option<Vec<String>>,
881 link_bins: bool,
882 pb: Option<&indicatif::ProgressBar>,
883 yes: bool,
884 registry_handle: &str
885) -> Result<(Vec<String>, BTreeMap<String, String>)> {
886 let version = metadata.version.as_ref().ok_or_else(|| {
887 anyhow!(
888 "Package '{}' is missing version field in its metadata.",
889 metadata.name
890 )
891 })?;
892
893 if pb.is_none() {
894 println!(
895 "Installing pooled package: {} v{} [{:?}]",
896 metadata.name.cyan(),
897 version.yellow(),
898 scope
899 );
900 }
901
902 let package_dir = local::get_package_dir(
903 scope,
904 registry_handle,
905 &metadata.repo,
906 &metadata.name
907 )?;
908 fs::create_dir_all(&package_dir)?;
909
910 let staging_dir = tempfile::Builder::new()
911 .prefix(".tmp-install-")
912 .tempdir_in(&package_dir)?;
913
914 let mut installed_files: Vec<String> = Vec::new();
915 let mut file_digests: BTreeMap<String, String> = BTreeMap::new();
919 let version_dir = package_dir.join(version);
920
921 let subs_to_install = if let Some(subs) = sub_packages {
922 subs
923 } else if let Some(subs) = &metadata.sub_packages {
924 if let Some(main_subs) = &metadata.main_subs {
925 main_subs.clone()
926 } else {
927 subs.clone()
928 }
929 } else {
930 vec![String::new()]
931 };
932
933 let pool_dir = unpack_path.join("pool");
934
935 let mut conflicts = Vec::new();
936 let mut owned_files = HashSet::new();
937
938 let track_dest = |dest: &str| -> Result<String> {
943 if dest.starts_with("${createpkgdir}") {
944 Ok(expand_pooled_path(dest, staging_dir.path(), scope)?
945 .to_string_lossy()
946 .to_string())
947 } else {
948 Ok(dest.to_string())
949 }
950 };
951
952 for sub in &subs_to_install {
953 let sub_opt = if sub.is_empty() {
954 None
955 } else {
956 Some(sub.as_str())
957 };
958 if let Ok(Some(manifest)) =
959 local::is_package_installed(&metadata.name, sub_opt, scope)
960 {
961 owned_files.extend(manifest.installed_files);
962 }
963
964 if let Some(sub_mapping) = pooled_manifest.mappings.get(sub)
965 && let Some(scope_mapping) = sub_mapping.scopes.get(&scope)
966 {
967 for mapped_file in &scope_mapping.files {
968 let dest_path = expand_pooled_path(
969 &mapped_file.dest,
970 staging_dir.path(),
971 scope
972 )?;
973 if !mapped_file.dest.starts_with("${pkgstore}")
976 && dest_path.exists()
977 && !owned_files.contains(&mapped_file.dest)
978 && !owned_files
980 .contains(&dest_path.to_string_lossy().to_string())
981 {
982 conflicts.push(dest_path);
983 }
984 }
985 }
986 }
987
988 if !conflicts.is_empty() {
989 println!();
990 println!("{}", "File Conflict Detected:".red().bold());
991 println!(
992 "The following files that this package wants to install already \
993 exist on your system:"
994 );
995 for file in &conflicts {
996 println!("- {}", file.display());
997 }
998 println!();
999
1000 if !utils::ask_for_confirmation(
1001 "Do you want to overwrite these files and continue with the \
1002 installation?",
1003 yes
1004 ) {
1005 return Err(anyhow!(
1006 "Installation aborted by user due to file conflicts."
1007 ));
1008 }
1009 }
1010
1011 for sub in subs_to_install {
1012 let Some(sub_mapping) = pooled_manifest.mappings.get(&sub) else {
1013 if pb.is_none() {
1014 eprintln!(
1015 "Warning: mapping for sub-package '{sub}' not found in \
1016 archive, skipping."
1017 );
1018 }
1019 continue;
1020 };
1021
1022 let Some(scope_mapping) = sub_mapping.scopes.get(&scope) else {
1023 if pb.is_none() {
1024 eprintln!(
1025 "Warning: mapping for scope {scope:?} not found for \
1026 sub-package '{sub}', skipping."
1027 );
1028 }
1029 continue;
1030 };
1031
1032 for mapped_dir in &scope_mapping.dirs {
1034 let dest_path = expand_pooled_path(
1035 &mapped_dir.path,
1036 staging_dir.path(),
1037 scope
1038 )?;
1039 fs::create_dir_all(&dest_path)?;
1040
1041 #[cfg(unix)]
1042 {
1043 if let Some(mode) = mapped_dir.mode {
1044 use std::os::unix::fs::PermissionsExt;
1045 fs::set_permissions(
1046 &dest_path,
1047 fs::Permissions::from_mode(mode)
1048 )?;
1049 }
1050 if let (Some(owner), Some(group)) =
1051 (&mapped_dir.owner, &mapped_dir.group)
1052 {
1053 let _ = utils::set_path_owner(&dest_path, owner, group);
1054 }
1055 }
1056 }
1057
1058 for mapped_file in &scope_mapping.files {
1060 let pool_file =
1061 utils::safe_join(&pool_dir, Path::new(&mapped_file.hash))?;
1062 if !pool_file.exists() {
1063 return Err(anyhow!("Pool file missing: {}", mapped_file.hash));
1064 }
1065 let expected_hash =
1066 mapped_file.hash.strip_prefix("sha256-").ok_or_else(|| {
1067 anyhow!(
1068 "Unsupported pool hash format: {}",
1069 mapped_file.hash
1070 )
1071 })?;
1072 let pool_entry = pooled_manifest
1073 .pool
1074 .get(&mapped_file.hash)
1075 .ok_or_else(|| {
1076 anyhow!("Pool manifest entry missing: {}", mapped_file.hash)
1077 })?;
1078 if pool_file.metadata()?.len() != pool_entry.size {
1079 return Err(anyhow!(
1080 "Pool file size does not match manifest: {}",
1081 mapped_file.hash
1082 ));
1083 }
1084 let actual_hash =
1085 calculate_file_hash(&pool_file, HashAlgorithm::Sha256)?;
1086 if actual_hash != expected_hash {
1087 return Err(anyhow!(
1088 "Pool file hash does not match manifest: {}",
1089 mapped_file.hash
1090 ));
1091 }
1092
1093 let dest_path = expand_pooled_path(
1094 &mapped_file.dest,
1095 staging_dir.path(),
1096 scope
1097 )?;
1098
1099 if let Some(parent) = dest_path.parent() {
1100 fs::create_dir_all(parent)?;
1101 }
1102
1103 fs::copy(&pool_file, &dest_path)?;
1104
1105 #[cfg(unix)]
1106 {
1107 use std::os::unix::fs::PermissionsExt;
1108 fs::set_permissions(
1109 &dest_path,
1110 fs::Permissions::from_mode(mapped_file.mode)
1111 )?;
1112 if let (Some(owner), Some(group)) =
1113 (&mapped_file.owner, &mapped_file.group)
1114 {
1115 let _ = utils::set_path_owner(&dest_path, owner, group);
1116 }
1117 }
1118
1119 let tracked = track_dest(&mapped_file.dest)?;
1120 file_digests.insert(tracked.clone(), mapped_file.hash.clone());
1121 installed_files.push(tracked);
1122 }
1123
1124 for mapped_link in &scope_mapping.symlinks {
1126 let dest_path = expand_pooled_path(
1127 &mapped_link.link,
1128 staging_dir.path(),
1129 scope
1130 )?;
1131
1132 if let Some(parent) = dest_path.parent() {
1133 fs::create_dir_all(parent)?;
1134 }
1135
1136 if dest_path.exists() || dest_path.is_symlink() {
1137 fs::remove_file(&dest_path).ok();
1138 }
1139
1140 let target = mapped_link.target.clone();
1142
1143 utils::symlink_file(Path::new(&target), &dest_path)?;
1144 installed_files.push(track_dest(&mapped_link.link)?);
1145 }
1146 }
1147
1148 fs::create_dir_all(&version_dir)?;
1150 copy_dir_all(staging_dir.path(), &version_dir)?;
1151
1152 finalize_installation(
1153 &version_dir,
1154 metadata,
1155 scope,
1156 link_bins,
1157 pb,
1158 &mut installed_files
1159 )?;
1160
1161 if let Some(p) = pb {
1162 p.set_position(100);
1163 }
1164
1165 Ok((installed_files, file_digests))
1166}
1167
1168fn expand_pooled_path(
1170 path: &str,
1171 staging_path: &Path,
1172 _scope: types::Scope
1173) -> Result<PathBuf> {
1174 let safe_join = |base: PathBuf, relative: &str| -> Result<PathBuf> {
1175 let relative_path = Path::new(relative);
1176 if !utils::is_safe_path(&base, relative_path) {
1177 return Err(anyhow!(
1178 "Pooled path escapes its destination root: {path}"
1179 ));
1180 }
1181 Ok(base.join(relative_path))
1182 };
1183
1184 if let Some(rel) = path.strip_prefix("${pkgstore}/") {
1185 safe_join(staging_path.to_path_buf(), rel)
1186 } else if let Some(rel) = path.strip_prefix("${usrroot}/") {
1187 safe_join(zoi_core::sysroot::apply_sysroot(PathBuf::from("/")), rel)
1188 } else if let Some(rel) = path.strip_prefix("${usrhome}/") {
1189 let home_dir = zoi_core::utils::get_user_home()
1190 .ok_or_else(|| anyhow!("Home dir not found"))?;
1191 safe_join(home_dir, rel)
1192 } else if let Some(rel) = path.strip_prefix("${createpkgdir}/") {
1193 safe_join(std::env::current_dir()?, rel)
1194 } else {
1195 Err(anyhow!("Invalid pooled path placeholder: {path}"))
1196 }
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202
1203 #[test]
1204 fn pooled_paths_should_not_escape_their_roots() {
1205 let staging =
1206 tempfile::tempdir().expect("staging directory should exist");
1207
1208 assert!(
1209 expand_pooled_path(
1210 "${pkgstore}/../outside",
1211 staging.path(),
1212 types::Scope::User
1213 )
1214 .is_err()
1215 );
1216 }
1217}