use anyhow::{Result, anyhow};
use colored::*;
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use tar::Archive;
use tempfile::Builder;
use walkdir::WalkDir;
use zoi_core::types;
use zoi_core::utils::{self, copy_dir_all};
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 => {
let home_dir = zoi_core::utils::get_user_home()
.ok_or_else(|| anyhow!("Could not find home directory."))?;
Ok(zoi_core::sysroot::apply_sysroot(
home_dir.join(".zoi/pkgs/bin"),
))
}
types::Scope::System => {
if cfg!(target_os = "windows") {
Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(
"C:\\ProgramData\\zoi\\pkgs\\bin",
)))
} else if zoi_core::utils::is_zoios() {
Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from("/usr/bin")))
} else {
Ok(zoi_core::sysroot::apply_sysroot(PathBuf::from(
"/usr/local/bin",
)))
}
}
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 => {
let home_dir = zoi_core::utils::get_user_home()
.ok_or_else(|| anyhow!("Could not find home directory."))?;
Ok(zoi_core::sysroot::apply_sysroot(
home_dir.join(".zoi/pkgs/shell").join(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(())
}
fn check_and_handle_file_conflicts(
source_dir: &Path,
dest_dir: &Path,
owned_files: &HashSet<String>,
yes: bool,
) -> Result<()> {
let mut conflicting_files = Vec::new();
for entry in WalkDir::new(source_dir)
.into_iter()
.filter_map(|e| e.ok())
.skip(1)
{
if entry.file_type().is_file() {
let relative_path = entry.path().strip_prefix(source_dir)?;
let dest_path = dest_dir.join(relative_path);
if dest_path.exists() && !owned_files.contains(&dest_path.to_string_lossy().to_string())
{
conflicting_files.push(dest_path);
}
}
}
if !conflicting_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 &conflicting_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."
));
}
}
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>> {
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());
}
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 {:02X?}. This file is likely not a valid .zst archive.",
magic
));
}
use std::io::Seek;
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: {}. The archive may be truncated or corrupted.",
e
)
})?;
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(|e| e.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))?,
&platform,
version_override,
Some(scope),
true,
)?;
let pooled_manifest_path = unpack_path.join("manifest.json");
if pooled_manifest_path.exists() {
let content = fs::read_to_string(&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!["".to_string()];
all.extend(subs.clone());
all
}
} else {
vec!["".to_string()]
};
for sub in subs_to_install {
let sub_data_dir = if sub.is_empty() {
data_dir.clone()
} else {
if pb.is_none() {
println!("Installing sub-package: {}", sub.bold());
}
data_dir.join(&sub)
};
if !sub_data_dir.exists() {
if pb.is_none() {
eprintln!(
"Warning: sub-package '{}' not found in archive, skipping.",
sub
);
}
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, yes)?;
copy_dir_all(&usrroot_src, &root_dest)?;
for entry in WalkDir::new(&usrroot_src)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
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, yes)?;
copy_dir_all(&usrhome_src, &home_dest)?;
for entry in WalkDir::new(&usrhome_src)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
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(|e| e.ok())
{
if entry.file_type().is_file() {
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)
}
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 = PathBuf::from(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(|s| format!("{}.zoiorig", s))
.unwrap_or_else(|| "zoiorig".to_string());
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(|e| e.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/{}/ directory: {}", shell_name, 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 = applications_dir.join(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>> {
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 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!["".to_string()]
};
let pool_dir = unpack_path.join("pool");
let mut conflicts = Vec::new();
let mut owned_files = HashSet::new();
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)
{
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 sub_mapping = match pooled_manifest.mappings.get(&sub) {
Some(m) => m,
None => {
if pb.is_none() {
eprintln!(
"Warning: mapping for sub-package '{}' not found in archive, skipping.",
sub
);
}
continue;
}
};
let scope_mapping = match sub_mapping.scopes.get(&scope) {
Some(m) => m,
None => {
if pb.is_none() {
eprintln!(
"Warning: mapping for scope {:?} not found for sub-package '{}', skipping.",
scope, sub
);
}
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 = pool_dir.join(&mapped_file.hash);
if !pool_file.exists() {
return Err(anyhow!("Pool file missing: {}", 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);
}
}
installed_files.push(mapped_file.dest.clone());
}
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(mapped_link.link.clone());
}
}
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)
}
fn expand_pooled_path(path: &str, staging_path: &Path, _scope: types::Scope) -> Result<PathBuf> {
if let Some(rel) = path.strip_prefix("${pkgstore}/") {
Ok(staging_path.join(rel))
} else if let Some(rel) = path.strip_prefix("${usrroot}/") {
Ok(zoi_core::sysroot::apply_sysroot(
PathBuf::from("/").join(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"))?;
Ok(home_dir.join(rel))
} else if let Some(rel) = path.strip_prefix("${createpkgdir}/") {
Ok(std::env::current_dir()?.join(rel))
} else {
Err(anyhow!("Invalid pooled path placeholder: {}", path))
}
}