use std::collections::{BTreeMap, HashSet};
use std::fs::{self, File};
use std::io::{Read, Seek};
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use colored::Colorize;
use tar::Archive;
use tempfile::Builder;
use walkdir::WalkDir;
use zoi_core::hash::{HashAlgorithm, calculate_file_hash};
use zoi_core::types;
use zoi_core::utils::{self, copy_dir_all};
use zoi_db as db;
use zoi_resolver::local;
use zstd::stream::read::Decoder as ZstdDecoder;
fn get_bin_root(scope: types::Scope) -> Result<PathBuf> {
match scope {
types::Scope::User => zoi_core::utils::get_user_bin_dir(),
types::Scope::System => Ok(zoi_core::utils::get_system_bin_dir()),
types::Scope::Project => {
let current_dir = std::env::current_dir()?;
Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
}
}
}
fn get_completions_root(scope: types::Scope, shell: &str) -> Result<PathBuf> {
match scope {
types::Scope::User => zoi_core::utils::get_user_completions_dir(shell),
types::Scope::System => {
if cfg!(target_os = "windows") {
Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(format!(
"C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}"
))))
} else if zoi_core::utils::is_zoios() {
let base = match shell {
"bash" => "/usr/share/bash-completion/completions",
"zsh" => "/usr/share/zsh/site-functions",
"fish" => "/usr/share/fish/vendor_completions.d",
"elvish" => "/usr/share/elvish/lib",
_ => "/usr/share/zoi/completions"
};
Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(base)))
} else {
let base = match shell {
"bash" => "/usr/share/bash-completion/completions",
"zsh" => "/usr/share/zsh/site-functions",
"fish" => "/usr/share/fish/vendor_completions.d",
"elvish" => "/usr/share/elvish/lib",
_ => "/usr/local/share/zoi/completions"
};
Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(base)))
}
}
types::Scope::Project => {
let current_dir = std::env::current_dir()?;
Ok(current_dir
.join(".zoi")
.join("pkgs")
.join("shell")
.join(shell))
}
}
}
fn create_completion_symlink(source: &Path, link: &Path) -> Result<()> {
if link.exists() || link.is_symlink() {
fs::remove_file(link)?;
}
if let Some(parent) = link.parent() {
fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(source, link)
.map_err(|e| anyhow!("Failed to create completion symlink: {e}"))?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(source, link).map_err(|e| {
anyhow!("Failed to create completion symlink: {}", e)
})?;
}
Ok(())
}
struct FileConflict {
path: PathBuf,
owners: Vec<String>
}
struct InstallingPackage<'a> {
name: &'a str,
sub_package: Option<&'a str>,
repo: &'a str,
registry: &'a str
}
fn check_and_handle_file_conflicts(
source_dir: &Path,
dest_dir: &Path,
owned_files: &HashSet<String>,
package: Option<&InstallingPackage<'_>>,
yes: bool
) -> Result<()> {
let mut stray_files = Vec::new();
let mut owned_by_others: Vec<FileConflict> = Vec::new();
let (conn, current_pkg_id) = match package {
Some(pkg) => {
let registry_key = if pkg.registry.is_empty() {
"local"
} else {
pkg.registry
};
match db::open_connection(registry_key) {
Ok(conn) => {
let id = db::get_package_id(
&conn,
pkg.name,
pkg.sub_package,
pkg.repo,
registry_key
)
.ok();
(Some(conn), id)
}
Err(_) => (None, None)
}
}
None => (None, None)
};
for entry in WalkDir::new(source_dir)
.into_iter()
.filter_map(std::result::Result::ok)
.skip(1)
{
if !entry.file_type().is_file() {
continue;
}
let relative_path = entry.path().strip_prefix(source_dir)?;
let dest_path = dest_dir.join(relative_path);
if !dest_path.exists() {
continue;
}
let expanded = zoi_core::utils::expand_placeholders(
&dest_path.to_string_lossy(),
Path::new(""),
types::Scope::System
);
let is_self_owned = expanded
.as_ref()
.is_ok_and(|p| owned_files.contains(p))
|| owned_files.contains(&dest_path.to_string_lossy().to_string());
if is_self_owned {
continue;
}
let owners = conn
.as_ref()
.and_then(|c| {
db::get_other_owners(
c,
&dest_path.to_string_lossy(),
current_pkg_id
)
.ok()
})
.unwrap_or_default();
if owners.is_empty() {
stray_files.push(dest_path);
} else {
owned_by_others.push(FileConflict {
path: dest_path,
owners
});
}
}
if !stray_files.is_empty() {
println!();
println!("{}", "File Conflict Detected:".red().bold());
println!(
"The following files that this package wants to install already \
exist on your system:"
);
for file in &stray_files {
println!("- {}", file.display());
}
println!();
if !utils::ask_for_confirmation(
"Do you want to overwrite these files and continue with the \
installation?",
yes
) {
return Err(anyhow!(
"Installation aborted by user due to file conflicts."
));
}
}
if !owned_by_others.is_empty() {
println!();
println!("{}", "File Conflict With Another Package:".red().bold());
println!(
"The following files are owned by other installed packages. \
Installing this package would replace them, leaving the other \
package broken:"
);
for conflict in &owned_by_others {
println!(
"- {} (owned by {})",
conflict.path.display(),
conflict.owners.join(", ")
);
}
println!();
if !utils::ask_for_confirmation(
"Do you want to overwrite these files anyway? The packages listed \
above may stop working correctly.",
yes
) {
return Err(anyhow!(
"Installation aborted by user due to file conflicts with \
other packages."
));
}
}
Ok(())
}
pub fn run(
package_file: &Path,
scope_override: Option<types::Scope>,
registry_handle: &str,
version_override: Option<&str>,
yes: bool,
sub_packages: Option<Vec<String>>,
link_bins: bool,
pb: Option<&indicatif::ProgressBar>
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
let scope = scope_override.unwrap_or(types::Scope::User);
if package_file.as_os_str().is_empty() {
if pb.is_none() {
println!("Initializing meta-package...");
}
return Ok((Vec::new(), BTreeMap::new()));
}
if pb.is_none() {
println!(
"Installing from package archive: {}",
package_file.display()
);
}
let file_metadata = fs::metadata(package_file)
.map_err(|e| anyhow!("Failed to get archive metadata: {e}"))?;
let file_size = file_metadata.len();
if pb.is_none() {
println!("Archive size: {}", zoi_core::utils::format_bytes(file_size));
}
let mut file = File::open(package_file)
.map_err(|e| anyhow!("Failed to open package archive: {e}"))?;
let mut magic = [0u8; 4];
if file.read_exact(&mut magic).is_ok() && magic != [0x28, 0xB5, 0x2F, 0xFD]
{
return Err(anyhow!(
"Invalid archive format: expected zstd magic number 28 B5 2F FD, \
but found {magic:02X?}. This file is likely not a valid .zst \
archive."
));
}
file.rewind()
.map_err(|e| anyhow!("Failed to rewind archive file: {e}"))?;
let decoder = ZstdDecoder::new(file)
.map_err(|e| anyhow!("Failed to initialize zstd decoder: {e}"))?;
let mut archive = Archive::new(decoder);
#[cfg(target_os = "linux")]
archive.set_unpack_xattrs(true);
let temp_dir = Builder::new().prefix("zoi-install-").tempdir()?;
let unpack_path = temp_dir.path().to_path_buf();
for entry_res in archive
.entries()
.map_err(|e| anyhow!("Failed to read archive entries: {e}"))?
{
let mut entry = entry_res.map_err(|e| {
anyhow!(
"Failed to process archive entry: {e}. The archive may be \
truncated or corrupted."
)
})?;
let path = entry
.path()
.map_err(|e| anyhow!("Failed to get entry path: {e}"))?
.to_path_buf();
entry.unpack_in(&unpack_path).map_err(|e| {
anyhow!("Failed to unpack file '{}': {}", path.display(), e)
})?;
}
let mut pkg_lua_path = None;
for entry in WalkDir::new(temp_dir.path())
.into_iter()
.filter_map(std::result::Result::ok)
{
if entry.file_name().to_string_lossy().ends_with(".pkg.lua") {
pkg_lua_path = Some(entry.path().to_path_buf());
break;
}
}
let pkg_lua_path = pkg_lua_path.ok_or_else(|| {
anyhow!(
"Could not find .pkg.lua file in archive '{}'",
package_file.display()
)
})?;
let platform = utils::get_platform()?;
let metadata = zoi_lua::parser::parse_lua_package_for_platform(
pkg_lua_path.to_str().ok_or_else(|| {
anyhow!(
"Path contains invalid UTF-8 characters: {}",
pkg_lua_path.display()
)
})?,
&platform,
version_override,
Some(scope),
true
)?;
let pooled_manifest_path =
utils::safe_join(&unpack_path, Path::new("manifest.json"))?;
if pooled_manifest_path.exists() {
let content =
utils::read_file_within(&unpack_path, &pooled_manifest_path)?;
if let Ok(pooled_manifest) =
serde_json::from_str::<types::PooledZpaManifest>(&content)
{
return extract_pooled_zpa(
&pooled_manifest,
&unpack_path,
scope,
&metadata,
sub_packages,
link_bins,
pb,
yes,
registry_handle
);
}
}
let version = metadata.version.as_ref().ok_or_else(|| {
anyhow!(
"Package '{}' is missing version field in its metadata.",
metadata.name
)
})?;
if pb.is_none() {
println!(
"Installing package: {} v{}",
metadata.name.cyan(),
version.yellow()
);
}
let package_dir = local::get_package_dir(
scope,
registry_handle,
&metadata.repo,
&metadata.name
)?;
fs::create_dir_all(&package_dir)?;
let staging_dir = tempfile::Builder::new()
.prefix(".tmp-install-")
.tempdir_in(&package_dir)?;
let mut installed_files: Vec<String> = Vec::new();
let version_dir = package_dir.join(version);
let data_dir = temp_dir.path().join("data");
if data_dir.exists() {
if let Some(p) = pb {
p.set_message(format!("Installing {}...", metadata.name.cyan()));
} else {
println!("Installing {}...", metadata.name.cyan());
}
let subs_to_install = if let Some(subs) = sub_packages {
subs
} else if let Some(subs) = &metadata.sub_packages {
if let Some(main_subs) = &metadata.main_subs {
main_subs.clone()
} else {
let mut all = vec![String::new()];
all.extend(subs.clone());
all
}
} else {
vec![String::new()]
};
for sub in subs_to_install {
let sub_data_dir = if sub.is_empty() {
data_dir.clone()
} else {
utils::validate_path_component(&sub)?;
if pb.is_none() {
println!("Installing sub-package: {}", sub.bold());
}
utils::safe_join(&data_dir, Path::new(&sub))?
};
if !sub_data_dir.exists() {
if pb.is_none() {
eprintln!(
"Warning: sub-package '{sub}' not found in archive, \
skipping."
);
}
continue;
}
let mut owned_files = HashSet::new();
let sub_opt = if sub.is_empty() {
None
} else {
Some(sub.as_str())
};
if let Ok(Some(manifest)) =
local::is_package_installed(&metadata.name, sub_opt, scope)
{
owned_files.extend(manifest.installed_files);
}
let pkgstore_src = sub_data_dir.join("pkgstore");
if pkgstore_src.exists() {
copy_dir_all(&pkgstore_src, staging_dir.path())?;
}
let usrroot_src = sub_data_dir.join("usrroot");
if usrroot_src.exists() {
if !utils::is_admin() {
return Err(anyhow!(
"Administrator privileges required to install \
system-wide files. Please run with sudo or as an \
administrator."
));
}
let root_dest =
zoi_core::sysroot::apply_sysroot(PathBuf::from("/"));
check_and_handle_file_conflicts(
&usrroot_src,
&root_dest,
&owned_files,
Some(&InstallingPackage {
name: &metadata.name,
sub_package: sub_opt,
repo: &metadata.repo,
registry: registry_handle
}),
yes
)?;
copy_dir_all(&usrroot_src, &root_dest)?;
for entry in WalkDir::new(&usrroot_src)
.into_iter()
.filter_map(std::result::Result::ok)
{
if entry.file_type().is_file()
|| entry.file_type().is_symlink()
{
let rel_to_root =
entry.path().strip_prefix(&usrroot_src)?;
installed_files.push(format!(
"${{usrroot}}/{}",
rel_to_root.to_string_lossy().replace('\\', "/")
));
}
}
}
let usrhome_src = sub_data_dir.join("usrhome");
if usrhome_src.exists() {
let home_dest = zoi_core::utils::get_user_home()
.ok_or_else(|| anyhow!("Could not find home directory"))?;
check_and_handle_file_conflicts(
&usrhome_src,
&home_dest,
&owned_files,
Some(&InstallingPackage {
name: &metadata.name,
sub_package: sub_opt,
repo: &metadata.repo,
registry: registry_handle
}),
yes
)?;
copy_dir_all(&usrhome_src, &home_dest)?;
for entry in WalkDir::new(&usrhome_src)
.into_iter()
.filter_map(std::result::Result::ok)
{
if entry.file_type().is_file()
|| entry.file_type().is_symlink()
{
let rel_to_home =
entry.path().strip_prefix(&usrhome_src)?;
installed_files.push(format!(
"${{usrhome}}/{}",
rel_to_home.to_string_lossy().replace('\\', "/")
));
}
}
}
}
}
if let Some(p) = pb {
p.set_position(60);
}
for entry in WalkDir::new(staging_dir.path())
.into_iter()
.filter_map(std::result::Result::ok)
{
if entry.file_type().is_file() || entry.file_type().is_symlink() {
let rel_path = entry.path().strip_prefix(staging_dir.path())?;
installed_files.push(format!(
"${{pkgstore}}/{}",
rel_path.to_string_lossy().replace('\\', "/")
));
}
}
copy_dir_all(staging_dir.path(), &version_dir)?;
finalize_installation(
&version_dir,
&metadata,
scope,
link_bins,
pb,
&mut installed_files
)?;
if let Some(p) = pb {
p.set_position(100);
} else {
println!("{} Installation complete.", "Success:".green());
}
Ok((installed_files, BTreeMap::new()))
}
fn finalize_installation(
version_dir: &Path,
metadata: &types::Package,
scope: types::Scope,
link_bins: bool,
pb: Option<&indicatif::ProgressBar>,
_installed_files: &mut Vec<String>
) -> Result<()> {
if let Some(backup_files) = &metadata.backup {
for backup_file_rel in backup_files {
let expanded_path = utils::expand_placeholders(
backup_file_rel,
version_dir,
scope
)?;
let backup_src = if Path::new(&expanded_path).is_absolute() {
PathBuf::from(expanded_path)
} else {
version_dir.join(expanded_path)
};
if backup_src.exists() && backup_src.is_file() {
let mut orig_path = backup_src.clone();
let ext =
orig_path.extension().and_then(|s| s.to_str()).map_or_else(
|| "zoiorig".to_string(),
|s| format!("{s}.zoiorig")
);
orig_path.set_extension(ext);
if let Err(e) = fs::copy(&backup_src, &orig_path)
&& pb.is_none()
{
eprintln!(
"Warning: failed to create .zoiorig for {}: {}",
backup_src.display(),
e
);
}
}
}
}
if link_bins && let Some(bins) = &metadata.bins {
let bin_root = get_bin_root(scope)?;
fs::create_dir_all(&bin_root)?;
let mut created_shims = Vec::new();
let mut link_error: Option<String> = None;
for bin_name in bins {
let mut found_bin = false;
for entry in WalkDir::new(version_dir)
.into_iter()
.filter_map(std::result::Result::ok)
{
if entry.file_type().is_file()
&& entry.file_name().to_string_lossy() == *bin_name
{
let link_path = bin_root.join(bin_name);
let zoi_exe = std::env::current_exe()?;
if let Err(e) =
zoi_core::utils::symlink_file(&zoi_exe, &link_path)
{
link_error = Some(e.to_string());
break;
}
created_shims.push(link_path);
if pb.is_none() {
println!("Created shim for: {}", bin_name.green());
}
found_bin = true;
break;
}
}
if link_error.is_some() {
break;
}
if !found_bin && pb.is_none() {
eprintln!(
"Warning: could not find binary '{}' to link.",
bin_name.yellow()
);
}
}
if let Some(e) = link_error {
for shim in created_shims {
let _ = fs::remove_file(shim);
}
return Err(anyhow!("Failed to create shims: {e}"));
}
}
let shell_dir = version_dir.join("shell");
if shell_dir.exists() {
for shell_entry in fs::read_dir(&shell_dir).map_err(|e| {
anyhow!("Failed to read shell completions directory: {e}")
})? {
let shell_entry = shell_entry?;
if !shell_entry.file_type()?.is_dir() {
continue;
}
let shell_name =
shell_entry.file_name().to_string_lossy().to_string();
let completions_root = get_completions_root(scope, &shell_name)?;
let pkg_completions_dir = completions_root.join(&metadata.name);
fs::create_dir_all(&pkg_completions_dir)?;
for file_entry in fs::read_dir(shell_entry.path()).map_err(|e| {
anyhow!("Failed to read shell/{shell_name}/ directory: {e}")
})? {
let file_entry = file_entry?;
if !file_entry.file_type()?.is_file() {
continue;
}
let filename =
file_entry.file_name().to_string_lossy().to_string();
let store_path = file_entry.path();
let link_path = pkg_completions_dir.join(&filename);
create_completion_symlink(&store_path, &link_path)?;
if pb.is_none() {
println!(
"Linked {} completion: {}",
shell_name.green(),
filename.cyan()
);
}
}
}
}
#[cfg(target_os = "macos")]
{
let applications_dir = match scope {
types::Scope::System => PathBuf::from("/Applications"),
types::Scope::User => {
let home_dir = zoi_core::utils::get_user_home()
.ok_or_else(|| anyhow!("Could not find home directory."))?;
home_dir.join("Applications")
}
types::Scope::Project => {
std::env::current_dir()?.join("Applications")
}
};
let mut app_bundles = Vec::new();
for entry in WalkDir::new(version_dir)
.max_depth(2)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_dir()
&& entry.file_name().to_string_lossy().ends_with(".app")
{
app_bundles.push(entry.path().to_path_buf());
}
}
if !app_bundles.is_empty() {
fs::create_dir_all(&applications_dir)?;
for app_path in app_bundles {
if zoi_core::utils::command_exists("xattr") {
let _ = std::process::Command::new("xattr")
.arg("-r")
.arg("-d")
.arg("com.apple.quarantine")
.arg(&app_path)
.status();
}
let app_name = app_path.file_name().ok_or_else(|| {
anyhow!("App path has no filename: {:?}", app_path)
})?;
let symlink_path = zoi_core::utils::safe_join(
&applications_dir,
Path::new(app_name)
)?;
if symlink_path.exists() {
let _ = fs::remove_file(&symlink_path);
let _ = fs::remove_dir_all(&symlink_path);
}
if std::os::unix::fs::symlink(&app_path, &symlink_path).is_ok()
{
_installed_files.push(format!(
"${{applications}}/{}",
app_name.to_string_lossy().replace('\\', "/")
));
if pb.is_none() {
println!(
"Linked {} to {}",
app_name.to_string_lossy().green(),
applications_dir.display()
);
}
}
}
}
}
Ok(())
}
fn extract_pooled_zpa(
pooled_manifest: &types::PooledZpaManifest,
unpack_path: &Path,
scope: types::Scope,
metadata: &types::Package,
sub_packages: Option<Vec<String>>,
link_bins: bool,
pb: Option<&indicatif::ProgressBar>,
yes: bool,
registry_handle: &str
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
let version = metadata.version.as_ref().ok_or_else(|| {
anyhow!(
"Package '{}' is missing version field in its metadata.",
metadata.name
)
})?;
if pb.is_none() {
println!(
"Installing pooled package: {} v{} [{:?}]",
metadata.name.cyan(),
version.yellow(),
scope
);
}
let package_dir = local::get_package_dir(
scope,
registry_handle,
&metadata.repo,
&metadata.name
)?;
fs::create_dir_all(&package_dir)?;
let staging_dir = tempfile::Builder::new()
.prefix(".tmp-install-")
.tempdir_in(&package_dir)?;
let mut installed_files: Vec<String> = Vec::new();
let mut file_digests: BTreeMap<String, String> = BTreeMap::new();
let version_dir = package_dir.join(version);
let subs_to_install = if let Some(subs) = sub_packages {
subs
} else if let Some(subs) = &metadata.sub_packages {
if let Some(main_subs) = &metadata.main_subs {
main_subs.clone()
} else {
subs.clone()
}
} else {
vec![String::new()]
};
let pool_dir = unpack_path.join("pool");
let mut conflicts = Vec::new();
let mut owned_files = HashSet::new();
let track_dest = |dest: &str| -> Result<String> {
if dest.starts_with("${createpkgdir}") {
Ok(expand_pooled_path(dest, staging_dir.path(), scope)?
.to_string_lossy()
.to_string())
} else {
Ok(dest.to_string())
}
};
for sub in &subs_to_install {
let sub_opt = if sub.is_empty() {
None
} else {
Some(sub.as_str())
};
if let Ok(Some(manifest)) =
local::is_package_installed(&metadata.name, sub_opt, scope)
{
owned_files.extend(manifest.installed_files);
}
if let Some(sub_mapping) = pooled_manifest.mappings.get(sub)
&& let Some(scope_mapping) = sub_mapping.scopes.get(&scope)
{
for mapped_file in &scope_mapping.files {
let dest_path = expand_pooled_path(
&mapped_file.dest,
staging_dir.path(),
scope
)?;
if !mapped_file.dest.starts_with("${pkgstore}")
&& dest_path.exists()
&& !owned_files.contains(&mapped_file.dest)
&& !owned_files
.contains(&dest_path.to_string_lossy().to_string())
{
conflicts.push(dest_path);
}
}
}
}
if !conflicts.is_empty() {
println!();
println!("{}", "File Conflict Detected:".red().bold());
println!(
"The following files that this package wants to install already \
exist on your system:"
);
for file in &conflicts {
println!("- {}", file.display());
}
println!();
if !utils::ask_for_confirmation(
"Do you want to overwrite these files and continue with the \
installation?",
yes
) {
return Err(anyhow!(
"Installation aborted by user due to file conflicts."
));
}
}
for sub in subs_to_install {
let Some(sub_mapping) = pooled_manifest.mappings.get(&sub) else {
if pb.is_none() {
eprintln!(
"Warning: mapping for sub-package '{sub}' not found in \
archive, skipping."
);
}
continue;
};
let Some(scope_mapping) = sub_mapping.scopes.get(&scope) else {
if pb.is_none() {
eprintln!(
"Warning: mapping for scope {scope:?} not found for \
sub-package '{sub}', skipping."
);
}
continue;
};
for mapped_dir in &scope_mapping.dirs {
let dest_path = expand_pooled_path(
&mapped_dir.path,
staging_dir.path(),
scope
)?;
fs::create_dir_all(&dest_path)?;
#[cfg(unix)]
{
if let Some(mode) = mapped_dir.mode {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
&dest_path,
fs::Permissions::from_mode(mode)
)?;
}
if let (Some(owner), Some(group)) =
(&mapped_dir.owner, &mapped_dir.group)
{
let _ = utils::set_path_owner(&dest_path, owner, group);
}
}
}
for mapped_file in &scope_mapping.files {
let pool_file =
utils::safe_join(&pool_dir, Path::new(&mapped_file.hash))?;
if !pool_file.exists() {
return Err(anyhow!("Pool file missing: {}", mapped_file.hash));
}
let expected_hash =
mapped_file.hash.strip_prefix("sha256-").ok_or_else(|| {
anyhow!(
"Unsupported pool hash format: {}",
mapped_file.hash
)
})?;
let pool_entry = pooled_manifest
.pool
.get(&mapped_file.hash)
.ok_or_else(|| {
anyhow!("Pool manifest entry missing: {}", mapped_file.hash)
})?;
if pool_file.metadata()?.len() != pool_entry.size {
return Err(anyhow!(
"Pool file size does not match manifest: {}",
mapped_file.hash
));
}
let actual_hash =
calculate_file_hash(&pool_file, HashAlgorithm::Sha256)?;
if actual_hash != expected_hash {
return Err(anyhow!(
"Pool file hash does not match manifest: {}",
mapped_file.hash
));
}
let dest_path = expand_pooled_path(
&mapped_file.dest,
staging_dir.path(),
scope
)?;
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&pool_file, &dest_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(
&dest_path,
fs::Permissions::from_mode(mapped_file.mode)
)?;
if let (Some(owner), Some(group)) =
(&mapped_file.owner, &mapped_file.group)
{
let _ = utils::set_path_owner(&dest_path, owner, group);
}
}
let tracked = track_dest(&mapped_file.dest)?;
file_digests.insert(tracked.clone(), mapped_file.hash.clone());
installed_files.push(tracked);
}
for mapped_link in &scope_mapping.symlinks {
let dest_path = expand_pooled_path(
&mapped_link.link,
staging_dir.path(),
scope
)?;
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
if dest_path.exists() || dest_path.is_symlink() {
fs::remove_file(&dest_path).ok();
}
let target = mapped_link.target.clone();
utils::symlink_file(Path::new(&target), &dest_path)?;
installed_files.push(track_dest(&mapped_link.link)?);
}
}
fs::create_dir_all(&version_dir)?;
copy_dir_all(staging_dir.path(), &version_dir)?;
finalize_installation(
&version_dir,
metadata,
scope,
link_bins,
pb,
&mut installed_files
)?;
if let Some(p) = pb {
p.set_position(100);
}
Ok((installed_files, file_digests))
}
fn expand_pooled_path(
path: &str,
staging_path: &Path,
_scope: types::Scope
) -> Result<PathBuf> {
let safe_join = |base: PathBuf, relative: &str| -> Result<PathBuf> {
let relative_path = Path::new(relative);
if !utils::is_safe_path(&base, relative_path) {
return Err(anyhow!(
"Pooled path escapes its destination root: {path}"
));
}
Ok(base.join(relative_path))
};
if let Some(rel) = path.strip_prefix("${pkgstore}/") {
safe_join(staging_path.to_path_buf(), rel)
} else if let Some(rel) = path.strip_prefix("${usrroot}/") {
safe_join(zoi_core::sysroot::apply_sysroot(PathBuf::from("/")), rel)
} else if let Some(rel) = path.strip_prefix("${usrhome}/") {
let home_dir = zoi_core::utils::get_user_home()
.ok_or_else(|| anyhow!("Home dir not found"))?;
safe_join(home_dir, rel)
} else if let Some(rel) = path.strip_prefix("${createpkgdir}/") {
safe_join(std::env::current_dir()?, rel)
} else {
Err(anyhow!("Invalid pooled path placeholder: {path}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pooled_paths_should_not_escape_their_roots() {
let staging =
tempfile::tempdir().expect("staging directory should exist");
assert!(
expand_pooled_path(
"${pkgstore}/../outside",
staging.path(),
types::Scope::User
)
.is_err()
);
}
}