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 => 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>> {
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());
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)
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 = 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
697fn 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 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 if !mapped_file.dest.starts_with("${pkgstore}")
796 && dest_path.exists()
797 && !owned_files.contains(&mapped_file.dest)
798 && !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 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 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 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 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 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
985fn 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}