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_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 => 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
30fn 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
71fn 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
94fn 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
147pub 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>, BTreeMap<String, String>)> {
178 let scope = scope_override.unwrap_or(types::Scope::User);
179
180 if package_file.as_os_str().is_empty() {
182 if pb.is_none() {
183 println!("Initializing meta-package...");
184 }
185 return Ok((Vec::new(), BTreeMap::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 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 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 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, BTreeMap::new()))
492}
493
494fn 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 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 = if Path::new(&expanded_path).is_absolute() {
515 PathBuf::from(expanded_path)
516 } else {
517 version_dir.join(expanded_path)
518 };
519
520 if backup_src.exists() && backup_src.is_file() {
521 let mut orig_path = backup_src.clone();
522 let ext =
523 orig_path.extension().and_then(|s| s.to_str()).map_or_else(
524 || "zoiorig".to_string(),
525 |s| format!("{s}.zoiorig")
526 );
527 orig_path.set_extension(ext);
528
529 if let Err(e) = fs::copy(&backup_src, &orig_path)
530 && pb.is_none()
531 {
532 eprintln!(
533 "Warning: failed to create .zoiorig for {}: {}",
534 backup_src.display(),
535 e
536 );
537 }
538 }
539 }
540 }
541
542 if link_bins && let Some(bins) = &metadata.bins {
543 let bin_root = get_bin_root(scope)?;
544 fs::create_dir_all(&bin_root)?;
545
546 let mut created_shims = Vec::new();
547 let mut link_error: Option<String> = None;
548
549 for bin_name in bins {
550 let mut found_bin = false;
551 for entry in WalkDir::new(version_dir)
552 .into_iter()
553 .filter_map(std::result::Result::ok)
554 {
555 if entry.file_type().is_file()
556 && entry.file_name().to_string_lossy() == *bin_name
557 {
558 let link_path = bin_root.join(bin_name);
559
560 let zoi_exe = std::env::current_exe()?;
561 if let Err(e) =
562 zoi_core::utils::symlink_file(&zoi_exe, &link_path)
563 {
564 link_error = Some(e.to_string());
565 break;
566 }
567 created_shims.push(link_path);
568
569 if pb.is_none() {
570 println!("Created shim for: {}", bin_name.green());
571 }
572 found_bin = true;
573 break;
574 }
575 }
576 if link_error.is_some() {
577 break;
578 }
579 if !found_bin && pb.is_none() {
580 eprintln!(
581 "Warning: could not find binary '{}' to link.",
582 bin_name.yellow()
583 );
584 }
585 }
586
587 if let Some(e) = link_error {
588 for shim in created_shims {
589 let _ = fs::remove_file(shim);
590 }
591 return Err(anyhow!("Failed to create shims: {e}"));
592 }
593 }
594
595 let shell_dir = version_dir.join("shell");
596 if shell_dir.exists() {
597 for shell_entry in fs::read_dir(&shell_dir).map_err(|e| {
598 anyhow!("Failed to read shell completions directory: {e}")
599 })? {
600 let shell_entry = shell_entry?;
601 if !shell_entry.file_type()?.is_dir() {
602 continue;
603 }
604 let shell_name =
605 shell_entry.file_name().to_string_lossy().to_string();
606 let completions_root = get_completions_root(scope, &shell_name)?;
607 let pkg_completions_dir = completions_root.join(&metadata.name);
608 fs::create_dir_all(&pkg_completions_dir)?;
609
610 for file_entry in fs::read_dir(shell_entry.path()).map_err(|e| {
611 anyhow!("Failed to read shell/{shell_name}/ directory: {e}")
612 })? {
613 let file_entry = file_entry?;
614 if !file_entry.file_type()?.is_file() {
615 continue;
616 }
617 let filename =
618 file_entry.file_name().to_string_lossy().to_string();
619 let store_path = file_entry.path();
620 let link_path = pkg_completions_dir.join(&filename);
621 create_completion_symlink(&store_path, &link_path)?;
622 if pb.is_none() {
623 println!(
624 "Linked {} completion: {}",
625 shell_name.green(),
626 filename.cyan()
627 );
628 }
629 }
630 }
631 }
632
633 #[cfg(target_os = "macos")]
634 {
635 let applications_dir = match scope {
636 types::Scope::System => PathBuf::from("/Applications"),
637 types::Scope::User => {
638 let home_dir = zoi_core::utils::get_user_home()
639 .ok_or_else(|| anyhow!("Could not find home directory."))?;
640 home_dir.join("Applications")
641 }
642 types::Scope::Project => {
643 std::env::current_dir()?.join("Applications")
644 }
645 };
646
647 let mut app_bundles = Vec::new();
648 for entry in WalkDir::new(version_dir)
649 .max_depth(2)
650 .into_iter()
651 .filter_map(|e| e.ok())
652 {
653 if entry.file_type().is_dir()
654 && entry.file_name().to_string_lossy().ends_with(".app")
655 {
656 app_bundles.push(entry.path().to_path_buf());
657 }
658 }
659
660 if !app_bundles.is_empty() {
661 fs::create_dir_all(&applications_dir)?;
662 for app_path in app_bundles {
663 if zoi_core::utils::command_exists("xattr") {
664 let _ = std::process::Command::new("xattr")
665 .arg("-r")
666 .arg("-d")
667 .arg("com.apple.quarantine")
668 .arg(&app_path)
669 .status();
670 }
671
672 let app_name = app_path.file_name().ok_or_else(|| {
673 anyhow!("App path has no filename: {:?}", app_path)
674 })?;
675 let symlink_path = applications_dir.join(app_name);
676
677 if symlink_path.exists() {
678 let _ = fs::remove_file(&symlink_path);
679 let _ = fs::remove_dir_all(&symlink_path);
680 }
681
682 if std::os::unix::fs::symlink(&app_path, &symlink_path).is_ok()
683 {
684 _installed_files.push(format!(
685 "${{applications}}/{}",
686 app_name.to_string_lossy().replace('\\', "/")
687 ));
688 if pb.is_none() {
689 println!(
690 "Linked {} to {}",
691 app_name.to_string_lossy().green(),
692 applications_dir.display()
693 );
694 }
695 }
696 }
697 }
698 }
699
700 Ok(())
701}
702
703fn extract_pooled_zpa(
706 pooled_manifest: &types::PooledZpaManifest,
707 unpack_path: &Path,
708 scope: types::Scope,
709 metadata: &types::Package,
710 sub_packages: Option<Vec<String>>,
711 link_bins: bool,
712 pb: Option<&indicatif::ProgressBar>,
713 yes: bool,
714 registry_handle: &str
715) -> Result<(Vec<String>, BTreeMap<String, String>)> {
716 let version = metadata.version.as_ref().ok_or_else(|| {
717 anyhow!(
718 "Package '{}' is missing version field in its metadata.",
719 metadata.name
720 )
721 })?;
722
723 if pb.is_none() {
724 println!(
725 "Installing pooled package: {} v{} [{:?}]",
726 metadata.name.cyan(),
727 version.yellow(),
728 scope
729 );
730 }
731
732 let package_dir = local::get_package_dir(
733 scope,
734 registry_handle,
735 &metadata.repo,
736 &metadata.name
737 )?;
738 fs::create_dir_all(&package_dir)?;
739
740 let staging_dir = tempfile::Builder::new()
741 .prefix(".tmp-install-")
742 .tempdir_in(&package_dir)?;
743
744 let mut installed_files: Vec<String> = Vec::new();
745 let mut file_digests: BTreeMap<String, String> = BTreeMap::new();
749 let version_dir = package_dir.join(version);
750
751 let subs_to_install = if let Some(subs) = sub_packages {
752 subs
753 } else if let Some(subs) = &metadata.sub_packages {
754 if let Some(main_subs) = &metadata.main_subs {
755 main_subs.clone()
756 } else {
757 subs.clone()
758 }
759 } else {
760 vec![String::new()]
761 };
762
763 let pool_dir = unpack_path.join("pool");
764
765 let mut conflicts = Vec::new();
766 let mut owned_files = HashSet::new();
767
768 let track_dest = |dest: &str| -> Result<String> {
773 if dest.starts_with("${createpkgdir}") {
774 Ok(expand_pooled_path(dest, staging_dir.path(), scope)?
775 .to_string_lossy()
776 .to_string())
777 } else {
778 Ok(dest.to_string())
779 }
780 };
781
782 for sub in &subs_to_install {
783 let sub_opt = if sub.is_empty() {
784 None
785 } else {
786 Some(sub.as_str())
787 };
788 if let Ok(Some(manifest)) =
789 local::is_package_installed(&metadata.name, sub_opt, scope)
790 {
791 owned_files.extend(manifest.installed_files);
792 }
793
794 if let Some(sub_mapping) = pooled_manifest.mappings.get(sub)
795 && let Some(scope_mapping) = sub_mapping.scopes.get(&scope)
796 {
797 for mapped_file in &scope_mapping.files {
798 let dest_path = expand_pooled_path(
799 &mapped_file.dest,
800 staging_dir.path(),
801 scope
802 )?;
803 if !mapped_file.dest.starts_with("${pkgstore}")
806 && dest_path.exists()
807 && !owned_files.contains(&mapped_file.dest)
808 && !owned_files
810 .contains(&dest_path.to_string_lossy().to_string())
811 {
812 conflicts.push(dest_path);
813 }
814 }
815 }
816 }
817
818 if !conflicts.is_empty() {
819 println!();
820 println!("{}", "File Conflict Detected:".red().bold());
821 println!(
822 "The following files that this package wants to install already \
823 exist on your system:"
824 );
825 for file in &conflicts {
826 println!("- {}", file.display());
827 }
828 println!();
829
830 if !utils::ask_for_confirmation(
831 "Do you want to overwrite these files and continue with the \
832 installation?",
833 yes
834 ) {
835 return Err(anyhow!(
836 "Installation aborted by user due to file conflicts."
837 ));
838 }
839 }
840
841 for sub in subs_to_install {
842 let Some(sub_mapping) = pooled_manifest.mappings.get(&sub) else {
843 if pb.is_none() {
844 eprintln!(
845 "Warning: mapping for sub-package '{sub}' not found in \
846 archive, skipping."
847 );
848 }
849 continue;
850 };
851
852 let Some(scope_mapping) = sub_mapping.scopes.get(&scope) else {
853 if pb.is_none() {
854 eprintln!(
855 "Warning: mapping for scope {scope:?} not found for \
856 sub-package '{sub}', skipping."
857 );
858 }
859 continue;
860 };
861
862 for mapped_dir in &scope_mapping.dirs {
864 let dest_path = expand_pooled_path(
865 &mapped_dir.path,
866 staging_dir.path(),
867 scope
868 )?;
869 fs::create_dir_all(&dest_path)?;
870
871 #[cfg(unix)]
872 {
873 if let Some(mode) = mapped_dir.mode {
874 use std::os::unix::fs::PermissionsExt;
875 fs::set_permissions(
876 &dest_path,
877 fs::Permissions::from_mode(mode)
878 )?;
879 }
880 if let (Some(owner), Some(group)) =
881 (&mapped_dir.owner, &mapped_dir.group)
882 {
883 let _ = utils::set_path_owner(&dest_path, owner, group);
884 }
885 }
886 }
887
888 for mapped_file in &scope_mapping.files {
890 let pool_file = pool_dir.join(&mapped_file.hash);
891 if !pool_file.exists() {
892 return Err(anyhow!("Pool file missing: {}", mapped_file.hash));
893 }
894 let expected_hash =
895 mapped_file.hash.strip_prefix("sha256-").ok_or_else(|| {
896 anyhow!(
897 "Unsupported pool hash format: {}",
898 mapped_file.hash
899 )
900 })?;
901 let pool_entry = pooled_manifest
902 .pool
903 .get(&mapped_file.hash)
904 .ok_or_else(|| {
905 anyhow!("Pool manifest entry missing: {}", mapped_file.hash)
906 })?;
907 if pool_file.metadata()?.len() != pool_entry.size {
908 return Err(anyhow!(
909 "Pool file size does not match manifest: {}",
910 mapped_file.hash
911 ));
912 }
913 let actual_hash =
914 calculate_file_hash(&pool_file, HashAlgorithm::Sha256)?;
915 if actual_hash != expected_hash {
916 return Err(anyhow!(
917 "Pool file hash does not match manifest: {}",
918 mapped_file.hash
919 ));
920 }
921
922 let dest_path = expand_pooled_path(
923 &mapped_file.dest,
924 staging_dir.path(),
925 scope
926 )?;
927
928 if let Some(parent) = dest_path.parent() {
929 fs::create_dir_all(parent)?;
930 }
931
932 fs::copy(&pool_file, &dest_path)?;
933
934 #[cfg(unix)]
935 {
936 use std::os::unix::fs::PermissionsExt;
937 fs::set_permissions(
938 &dest_path,
939 fs::Permissions::from_mode(mapped_file.mode)
940 )?;
941 if let (Some(owner), Some(group)) =
942 (&mapped_file.owner, &mapped_file.group)
943 {
944 let _ = utils::set_path_owner(&dest_path, owner, group);
945 }
946 }
947
948 let tracked = track_dest(&mapped_file.dest)?;
949 file_digests.insert(tracked.clone(), mapped_file.hash.clone());
950 installed_files.push(tracked);
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, file_digests))
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}