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
17fn 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
42fn 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
83fn 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
106fn 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
159pub 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 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 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 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 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
506fn 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 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
709fn 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 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 if !mapped_file.dest.starts_with("${pkgstore}")
808 && dest_path.exists()
809 && !owned_files.contains(&mapped_file.dest)
810 && !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 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 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 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 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 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
997fn 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}