use anyhow::{Result, anyhow};
use colored::*;
use mlua::{Lua, LuaSerdeExt, Table};
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use tar::Builder as TarBuilder;
use tempfile::Builder;
use walkdir::WalkDir;
use zoi_core::{types, utils};
use zoi_lua;
use zoi_resolver::resolve;
use zstd::stream::write::Encoder as ZstdEncoder;
pub fn resolve_build_type(
requested: Option<&str>,
supported: &[String],
pkg_name: &str,
) -> Result<String> {
if let Some(t) = requested {
if !supported.iter().any(|s| s == t) {
return Err(anyhow!(
"Build type '{}' not supported by package '{}'. Supported types: {:?}",
t,
pkg_name,
supported
));
}
return Ok(t.to_string());
}
if supported.iter().any(|t| t == "pre-compiled") {
Ok("pre-compiled".to_string())
} else if supported.iter().any(|t| t == "source") {
Ok("source".to_string())
} else if let Some(first) = supported.first() {
Ok(first.clone())
} else {
Err(anyhow!(
"No build types supported by package '{}'.",
pkg_name
))
}
}
pub fn get_build_dependencies(
package_file: &Path,
build_type: Option<&str>,
platform: &str,
version_override: Option<&str>,
quiet: bool,
) -> Result<Option<Vec<String>>> {
let pkg_for_meta = zoi_lua::parser::parse_lua_package_for_platform(
package_file
.to_str()
.ok_or_else(|| anyhow!("Path contains invalid UTF-8 characters: {:?}", package_file))?,
platform,
version_override,
quiet,
)?;
let resolved_build_type =
resolve_build_type(build_type, &pkg_for_meta.types, &pkg_for_meta.name)?;
if let Some(deps) = &pkg_for_meta.dependencies
&& let Some(build_deps) = &deps.build
{
let group = match build_deps {
types::BuildDependencies::Group(g) => Some(g),
types::BuildDependencies::Typed(t) => t.types.get(&resolved_build_type),
};
if let Some(g) = group {
let mut all_deps = Vec::new();
collect_deps_from_group_no_prompt(g, &mut all_deps);
return Ok(Some(all_deps));
}
}
Ok(None)
}
fn collect_deps_from_group_no_prompt(group: &types::DependencyGroup, deps: &mut Vec<String>) {
match group {
types::DependencyGroup::Simple(d) => {
deps.extend(d.clone());
}
types::DependencyGroup::Complex(g) => {
deps.extend(g.required.clone());
deps.extend(g.optional.clone());
for option_group in &g.options {
if option_group.all {
deps.extend(option_group.depends.clone());
} else if let Some(dep) = option_group.depends.first() {
deps.push(dep.clone());
}
}
if let Some(sub_deps_map) = &g.sub_packages {
for sub_group in sub_deps_map.values() {
collect_deps_from_group_no_prompt(sub_group, deps);
}
}
}
}
}
fn build_for_platform(
package_file: &Path,
build_type: Option<&str>,
platform: &str,
sign_key: &Option<String>,
output_dir: Option<&Path>,
version_override: Option<&str>,
sub_packages: Option<&Vec<String>>,
quiet: bool,
fakeroot: bool,
) -> Result<()> {
let pkg_lua_dir_str = package_file
.parent()
.and_then(Path::to_str)
.ok_or_else(|| anyhow!("Could not get parent directory of package file"))?;
let pkg_for_meta = zoi_lua::parser::parse_lua_package_for_platform(
package_file
.to_str()
.ok_or_else(|| anyhow!("Path contains invalid UTF-8 characters: {:?}", package_file))?,
platform,
version_override,
quiet,
)?;
if let Some(allowed_platforms) = &pkg_for_meta.platforms
&& !utils::is_platform_compatible(platform, allowed_platforms)
{
if !quiet {
println!(
"{} Skipping build for platform {}: package only supports {:?}",
"::".bold().yellow(),
platform.cyan(),
allowed_platforms
);
}
return Ok(());
}
let resolved_build_type =
resolve_build_type(build_type, &pkg_for_meta.types, &pkg_for_meta.name)?;
let version = if let Some(v) = version_override {
v.to_string()
} else {
resolve::get_default_version(&pkg_for_meta, None)?
};
let build_dir = Builder::new()
.prefix(&format!("zoi-build-{}-{}", pkg_for_meta.name, platform))
.tempdir()?;
if !quiet {
println!("Using build directory: {}", build_dir.path().display());
}
let staging_dir = build_dir.path().join("staging");
fs::create_dir_all(&staging_dir)?;
let subs_to_build = if let Some(subs) = sub_packages {
subs.clone()
} else if let Some(subs) = &pkg_for_meta.sub_packages {
subs.clone()
} else {
vec!["".to_string()]
};
for sub_package in subs_to_build {
let sub_pkg_name = if sub_package.is_empty() {
None
} else {
Some(sub_package.as_str())
};
if !sub_package.is_empty() && !quiet {
println!(
"{} Building sub-package: {}",
"::".bold().blue(),
sub_package.cyan()
);
}
let lua = Lua::new();
zoi_lua::functions::setup_lua_environment(
&lua,
platform,
Some(&version),
package_file.to_str(),
None,
Some(build_dir.path().to_str().unwrap_or("")),
Some(staging_dir.to_str().unwrap_or("")),
sub_pkg_name,
quiet,
)
.map_err(|e| {
anyhow!(
"Failed to setup Lua build environment for '{}': {}",
package_file.display(),
e
)
})?;
let pkg_table = lua
.to_value(&pkg_for_meta)
.map_err(|e| anyhow!(e.to_string()))?;
lua.globals()
.set("PKG", pkg_table)
.map_err(|e| anyhow!(e.to_string()))?;
lua.globals()
.set(
"BUILD_DIR",
build_dir
.path()
.to_str()
.ok_or_else(|| anyhow!("build_dir path contains invalid UTF-8"))?,
)
.map_err(|e| anyhow!(e.to_string()))?;
lua.globals()
.set(
"STAGING_DIR",
staging_dir
.to_str()
.ok_or_else(|| anyhow!("staging_dir path contains invalid UTF-8"))?,
)
.map_err(|e| anyhow!(e.to_string()))?;
lua.globals()
.set("BUILD_TYPE", resolved_build_type.as_str())
.map_err(|e| anyhow!(e.to_string()))?;
let lua_code = fs::read_to_string(package_file)?;
lua.load(&lua_code).exec().map_err(|e| {
anyhow!(
"Failed to execute Lua package file '{}' during build:\n{}",
package_file.display(),
e
)
})?;
let args = lua.create_table().map_err(|e| anyhow!(e.to_string()))?;
if !sub_package.is_empty() {
args.set("sub", sub_package.clone())
.map_err(|e| anyhow!(e.to_string()))?;
}
if let Ok(prepare_fn) = lua.globals().get::<mlua::Function>("prepare") {
if !quiet {
println!("Running prepare()...");
}
prepare_fn.call::<()>(args.clone()).map_err(|e| {
anyhow!(
"The 'prepare' function in '{}' failed for sub-package '{}':\n{}",
package_file.display(),
sub_package,
e
)
})?;
}
if let Ok(build_fn) = lua.globals().get::<mlua::Function>("build") {
if !quiet {
println!("Running build()...");
}
build_fn.call::<()>(args.clone()).map_err(|e| {
anyhow!(
"The 'build' function in '{}' failed for sub-package '{}':\n{}",
package_file.display(),
sub_package,
e
)
})?;
}
if let Ok(package_fn) = lua.globals().get::<mlua::Function>("package") {
if !quiet {
println!("Running package()...");
}
package_fn.call::<()>(args.clone()).map_err(|e| {
anyhow!(
"The 'package' function in '{}' failed for sub-package '{}':\n{}",
package_file.display(),
sub_package,
e
)
})?;
}
if let Ok(build_ops) = lua.globals().get::<Table>("__ZoiBuildOperations") {
for op in build_ops.sequence_values::<Table>() {
let op = op.map_err(|e| anyhow!(e.to_string()))?;
let op_type: String = op.get("op").map_err(|e| anyhow!(e.to_string()))?;
let data_prefix = if sub_package.is_empty() {
"data".to_string()
} else {
format!("data/{}", sub_package)
};
match op_type.as_str() {
"zcp" => {
let source: String =
op.get("source").map_err(|e| anyhow!(e.to_string()))?;
let mut destination: String =
op.get("destination").map_err(|e| anyhow!(e.to_string()))?;
let source_path = if source.contains("${pkgluadir}") {
Path::new(&source.replace("${pkgluadir}", pkg_lua_dir_str))
.to_path_buf()
} else {
build_dir.path().join(&source)
};
destination = destination
.replace("${pkgstore}", &format!("{}/pkgstore", data_prefix));
destination = destination
.replace("${createpkgdir}", &format!("{}/createpkgdir", data_prefix));
destination =
destination.replace("${usrroot}", &format!("{}/usrroot", data_prefix));
destination =
destination.replace("${usrhome}", &format!("{}/usrhome", data_prefix));
if !utils::is_safe_path(&staging_dir, Path::new(&destination)) {
return Err(anyhow!(
"Path traversal detected in zcp destination: {}",
destination
));
}
let dest_path = staging_dir.join(&destination);
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
if source_path.is_dir() {
for entry in WalkDir::new(&source_path)
.into_iter()
.filter_map(|e| e.ok())
{
let target_path =
dest_path.join(entry.path().strip_prefix(&source_path)?);
if entry.file_type().is_dir() {
fs::create_dir_all(&target_path)?;
} else {
if let Some(p) = target_path.parent() {
fs::create_dir_all(p)?;
}
fs::copy(entry.path(), &target_path)?;
}
}
} else {
fs::copy(&source_path, &dest_path)?;
}
if !quiet {
println!("Staged '{}' to '{}'", source, destination);
}
}
"zln" => {
let mut target: String =
op.get("target").map_err(|e| anyhow!(e.to_string()))?;
let mut link: String =
op.get("link").map_err(|e| anyhow!(e.to_string()))?;
let pkgstore_prefix = format!("{}/pkgstore", data_prefix);
if target.contains("${pkgstore}") && link.contains("${pkgstore}") {
let target_rel = target.replace("${pkgstore}/", "");
let link_rel = link.replace("${pkgstore}/", "");
let link_path = Path::new(&link_rel);
if let Some(parent) = link_path.parent() {
let mut rel_target = String::new();
for _ in parent.components() {
rel_target.push_str("../");
}
rel_target.push_str(&target_rel);
target = rel_target;
} else {
target = target_rel;
}
} else {
target = target.replace("${pkgstore}", &pkgstore_prefix);
}
link = link.replace("${pkgstore}", &pkgstore_prefix);
link = link
.replace("${createpkgdir}", &format!("{}/createpkgdir", data_prefix));
link = link.replace("${usrroot}", &format!("{}/usrroot", data_prefix));
link = link.replace("${usrhome}", &format!("{}/usrhome", data_prefix));
if !utils::is_safe_path(&staging_dir, Path::new(&link)) {
return Err(anyhow!("Path traversal detected in zln link: {}", link));
}
let target_path = Path::new(&target);
if target_path.is_absolute() {
let permitted_roots = [
"/bin",
"/sbin",
"/usr/bin",
"/usr/sbin",
"/lib",
"/lib64",
"/usr/lib",
"/usr/lib64",
"/etc",
"/var",
"/opt",
"/usr/share",
];
let is_permitted =
permitted_roots.iter().any(|p| target_path.starts_with(p));
if !is_permitted && !utils::is_safe_path(&staging_dir, target_path) {
return Err(anyhow!(
"Untrusted absolute symlink target: {}",
target
));
}
} else {
let link_dir = Path::new(&link).parent().unwrap_or(Path::new(""));
if !utils::is_safe_path(&staging_dir, &link_dir.join(target_path)) {
return Err(anyhow!(
"Relative symlink target '{}' escapes staging area via '{}'",
target,
link
));
}
}
let link_path = staging_dir.join(&link);
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent)?;
}
utils::symlink_file(Path::new(&target), &link_path)?;
if !quiet {
println!("Created symlink '{}' -> '{}'", link, target);
}
}
"zchmod" => {
let mut path: String =
op.get("path").map_err(|e| anyhow!(e.to_string()))?;
let mode: u32 = op.get("mode").map_err(|e| anyhow!(e.to_string()))?;
path = path.replace("${pkgstore}", &format!("{}/pkgstore", data_prefix));
path = path
.replace("${createpkgdir}", &format!("{}/createpkgdir", data_prefix));
path = path.replace("${usrroot}", &format!("{}/usrroot", data_prefix));
path = path.replace("${usrhome}", &format!("{}/usrhome", data_prefix));
if !utils::is_safe_path(&staging_dir, Path::new(&path)) {
return Err(anyhow!(
"Path traversal detected in zchmod path: {}",
path
));
}
let _full_path = staging_dir.join(&path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(_full_path, fs::Permissions::from_mode(mode))?;
}
if !quiet {
println!("Set permissions {} on '{}'", mode, path);
}
}
"zchown" => {
let mut path: String =
op.get("path").map_err(|e| anyhow!(e.to_string()))?;
let owner: String = op.get("owner").map_err(|e| anyhow!(e.to_string()))?;
let group: String = op.get("group").map_err(|e| anyhow!(e.to_string()))?;
path = path.replace("${pkgstore}", &format!("{}/pkgstore", data_prefix));
path = path
.replace("${createpkgdir}", &format!("{}/createpkgdir", data_prefix));
path = path.replace("${usrroot}", &format!("{}/usrroot", data_prefix));
path = path.replace("${usrhome}", &format!("{}/usrhome", data_prefix));
if !utils::is_safe_path(&staging_dir, Path::new(&path)) {
return Err(anyhow!(
"Path traversal detected in zchown path: {}",
path
));
}
#[cfg(unix)]
let full_path = staging_dir.join(&path);
#[cfg(unix)]
utils::set_path_owner(&full_path, &owner, &group)?;
if !quiet {
println!("Set ownership {}:{} on '{}'", owner, group, path);
}
}
"zmkdir" => {
let mut path: String =
op.get("path").map_err(|e| anyhow!(e.to_string()))?;
path = path.replace("${pkgstore}", &format!("{}/pkgstore", data_prefix));
path = path
.replace("${createpkgdir}", &format!("{}/createpkgdir", data_prefix));
path = path.replace("${usrroot}", &format!("{}/usrroot", data_prefix));
path = path.replace("${usrhome}", &format!("{}/usrhome", data_prefix));
if !utils::is_safe_path(&staging_dir, Path::new(&path)) {
return Err(anyhow!(
"Path traversal detected in zmkdir path: {}",
path
));
}
let full_path = staging_dir.join(&path);
fs::create_dir_all(full_path)?;
if !quiet {
println!("Created directory '{}'", path);
}
}
_ => {}
}
}
}
if let Ok(verify_fn) = lua.globals().get::<mlua::Function>("verify") {
if !quiet {
println!("Running verify()...");
}
let verification_passed: bool = match verify_fn.call::<mlua::Value>(args.clone()) {
Ok(mlua::Value::Boolean(b)) => b,
Ok(mlua::Value::Nil) => {
return Err(anyhow!(
"The 'verify' function in '{}' returned nil. It must explicitly return a boolean (true or false).\nHint: Did you forget to add 'return' before your verification function (e.g. return verifyHash(...))?",
package_file.display()
));
}
Ok(v) => {
return Err(anyhow!(
"The 'verify' function in '{}' returned a non-boolean value of type {:?}. It must return true or false.",
package_file.display(),
v.type_name()
));
}
Err(e) => {
return Err(anyhow!(
"The 'verify' function in '{}' failed for sub-package '{}':\n{}",
package_file.display(),
sub_package,
e
));
}
};
if !verification_passed {
if !utils::ask_for_confirmation(
"Package verification failed. This package may be unsafe. Continue?",
false,
) {
return Err(anyhow!(
"Build aborted by user due to verification failure."
));
}
} else if !quiet {
println!("Package verification passed.");
}
}
}
let mut files_list = Vec::new();
for entry in WalkDir::new(&staging_dir) {
let entry = entry?;
if entry.file_type().is_file()
&& let Ok(relative_path) = entry.path().strip_prefix(&staging_dir)
{
files_list.push(relative_path.to_string_lossy().replace('\\', "/"));
}
}
files_list.sort();
let manifest_content = files_list.join("\n - ").to_string();
fs::write(staging_dir.join("manifest.yaml"), manifest_content)?;
fs::copy(
package_file,
staging_dir.join(
package_file
.file_name()
.ok_or_else(|| anyhow!("package_file should have a name"))?,
),
)?;
let output_filename = format!("{}-{}-{}.pkg.tar.zst", pkg_for_meta.name, version, platform);
let output_base = if let Some(dir) = output_dir {
dir.to_path_buf()
} else {
package_file
.parent()
.ok_or_else(|| anyhow!("package_file should have a parent directory"))?
.to_path_buf()
};
let output_path = output_base.join(output_filename);
{
let file = File::create(&output_path)?;
let encoder = ZstdEncoder::new(file, 0)?.auto_finish();
let mut tar_builder = TarBuilder::new(encoder);
if fakeroot {
if !quiet {
println!(
"{} Applying fakeroot (UID/GID 0) to archive...",
"::".bold().blue()
);
}
for entry in WalkDir::new(&staging_dir).min_depth(1) {
let entry = entry?;
let path = entry.path();
let rel_path = path.strip_prefix(&staging_dir)?;
let mut header = tar::Header::new_gnu();
let metadata = fs::symlink_metadata(path)?;
header.set_metadata(&metadata);
header.set_uid(0);
header.set_gid(0);
header.set_username("root")?;
header.set_groupname("root")?;
if metadata.is_dir() {
tar_builder.append_data(&mut header, rel_path, std::io::empty())?;
} else if metadata.is_symlink() {
let target = fs::read_link(path)?;
tar_builder.append_link(&mut header, rel_path, target)?;
} else {
let mut file = File::open(path)?;
tar_builder.append_data(&mut header, rel_path, &mut file)?;
}
}
} else {
tar_builder.append_dir_all(".", &staging_dir)?;
}
tar_builder.finish()?;
}
let files_manifest_path = PathBuf::from(format!("{}.files", output_path.display()));
fs::write(&files_manifest_path, files_list.join("\n"))?;
let hash_path = PathBuf::from(format!("{}.hash", output_path.display()));
let output_path_str = output_path
.to_str()
.ok_or_else(|| anyhow!("Output path contains invalid UTF-8: {:?}", output_path))?;
let hash = zoi_core::hash::calculate_file_hash(
Path::new(output_path_str),
zoi_core::hash::HashAlgorithm::Sha512,
)?;
fs::write(
&hash_path,
format!(
"{} {}\n",
hash,
output_path
.file_name()
.ok_or_else(|| anyhow!("output_path should have a name"))?
.to_str()
.ok_or_else(|| anyhow!("output_filename should be valid UTF-8"))?
),
)?;
let size_path = PathBuf::from(format!("{}.size", output_path.display()));
let compressed_size = fs::metadata(&output_path)?.len();
let uncompressed_size: u64 = WalkDir::new(&staging_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.metadata().map(|m| m.len()).unwrap_or(0))
.sum();
fs::write(
&size_path,
format!(
"down: {}\ninstall: {}\n",
compressed_size, uncompressed_size
),
)?;
if !quiet {
println!(
"{}",
format!("Successfully built package: {}", output_path.display()).green()
);
}
if let Some(key_id) = sign_key {
if !quiet {
println!("Signing package with key '{}'...", key_id.cyan());
}
let signature_path = PathBuf::from(format!("{}.sig", output_path.display()));
if signature_path.exists() {
fs::remove_file(&signature_path)?;
}
zoi_core::pgp::sign_detached(&output_path, &signature_path, key_id)?;
if !quiet {
println!(
"{}",
format!(
"Successfully created signature: {}",
signature_path.display()
)
.green()
);
}
}
Ok(())
}
pub fn run(
package_file: &Path,
build_type: Option<&str>,
platforms: &[String],
sign_key: Option<String>,
output_dir: Option<&Path>,
version_override: Option<&str>,
sub_packages: Option<Vec<String>>,
quiet: bool,
method: &str,
image: Option<&str>,
fakeroot: bool,
install_deps: bool,
) -> Result<()> {
if method == "docker" {
let docker_image = image.ok_or_else(|| {
anyhow!("An image must be specified when using the 'docker' build method.")
})?;
return super::docker::run(
package_file,
build_type,
platforms,
sign_key,
output_dir,
version_override,
sub_packages,
docker_image,
fakeroot,
install_deps,
);
}
if !quiet {
println!("Building package from: {}", package_file.display());
}
let platforms_to_build: Vec<String> = if platforms.contains(&"current".to_string()) {
let mut p = platforms.to_vec();
p.retain(|x| x != "current");
p.push(utils::get_platform()?);
p
} else {
platforms.to_vec()
};
if platforms.contains(&"all".to_string()) {
return Err(anyhow!(
"Building for 'all' platforms is not supported in this flow yet. Please specify platforms explicitly."
));
}
let mut any_failed = false;
for platform in &platforms_to_build {
if !quiet {
println!(
"{} Building for platform: {}",
"::".bold().blue(),
platform.cyan()
);
}
if let Err(e) = build_for_platform(
package_file,
build_type,
platform,
&sign_key,
output_dir,
version_override,
sub_packages.as_ref(),
quiet,
fakeroot,
) {
eprintln!(
"{}: Failed to build for platform {}: {}",
"Error".red().bold(),
platform.red(),
e
);
any_failed = true;
}
}
if any_failed {
return Err(anyhow!("One or more platform builds failed"));
}
Ok(())
}