Skip to main content

zoi_package/
build.rs

1//! Orchestrates the Zoi package build process.
2//!
3//! This module is responsible for turning a `.pkg.lua` definition into a
4//! distributable `.zpa` archive. It:
5//! - Executes the `prepare()`, `build()`, and `package()` Lua functions.
6//! - Manages the staging area where files are organized into Zoi's data
7//!   structure.
8//! - Generates accompanying metadata: `.hash` (SHA-512), `.size`, and `.files`.
9//! - Supports native builds, Docker-based builds, and cross-compilation via CI
10//!   tags.
11//! - Handles optional PGP signing of the resulting archive.
12
13use std::collections::BTreeMap;
14use std::fs::{self, File};
15use std::path::{Path, PathBuf};
16
17use anyhow::{Result, anyhow};
18use colored::Colorize;
19use mlua::{Lua, LuaSerdeExt, Table};
20use tar::{Archive, Builder as TarBuilder};
21use tempfile::Builder;
22use walkdir::WalkDir;
23use zoi_core::types::{
24    self, PoolFileEntry, PooledZpaManifest, Scope, ScopeMapping,
25    SubPackageMapping
26};
27use zoi_core::utils;
28use zoi_lua;
29use zoi_resolver::resolve;
30use zstd::stream::read::Decoder as ZstdDecoder;
31use zstd::stream::write::Encoder as ZstdEncoder;
32
33/// Resolves the build type to use based on requested type and supported types.
34///
35/// # Errors
36///
37/// Returns an error if the requested build type is not supported by the
38/// package.
39pub fn resolve_build_type(
40    requested: Option<&str>,
41    supported: &[String],
42    pkg_name: &str
43) -> Result<Option<String>> {
44    if let Some(t) = requested {
45        if !supported.iter().any(|s| s == t) {
46            return Err(anyhow!(
47                "Build type '{t}' not supported by package '{pkg_name}'. \
48                 Supported types: {supported:?}",
49            ));
50        }
51        return Ok(Some(t.to_string()));
52    }
53
54    if supported.iter().any(|t| t == "pre-compiled") {
55        Ok(Some("pre-compiled".to_string()))
56    } else if supported.iter().any(|t| t == "source") {
57        Ok(Some("source".to_string()))
58    } else if let Some(first) = supported.first() {
59        Ok(Some(first.clone()))
60    } else {
61        Ok(None)
62    }
63}
64
65/// Retrieves build-time dependencies for a package on a specific platform.
66///
67/// # Errors
68///
69/// Returns an error if the package file path contains invalid UTF-8 characters,
70/// or if parsing the Lua package definition fails.
71pub fn get_build_dependencies(
72    package_file: &Path,
73    build_type: Option<&str>,
74    platform: &str,
75    version_override: Option<&str>,
76    quiet: bool
77) -> Result<Option<Vec<String>>> {
78    let pkg_for_meta = zoi_lua::parser::parse_lua_package_for_platform(
79        package_file.to_str().ok_or_else(|| {
80            anyhow!(
81                "Path contains invalid UTF-8 characters: {}",
82                package_file.display()
83            )
84        })?,
85        platform,
86        version_override,
87        None,
88        quiet
89    )?;
90
91    let Some(resolved_build_type) = resolve_build_type(
92        build_type,
93        &pkg_for_meta.types,
94        &pkg_for_meta.name
95    )?
96    else {
97        return Ok(None);
98    };
99
100    if let Some(deps) = &pkg_for_meta.dependencies
101        && let Some(build_deps) = &deps.build
102    {
103        match build_deps {
104            types::BuildDependencies::Group(g) => {
105                let mut all_deps = Vec::new();
106                collect_deps_from_group_no_prompt(g, &mut all_deps);
107                return Ok(Some(all_deps));
108            }
109            types::BuildDependencies::Typed(t) => {
110                if let Some(g) = t.types.get(&resolved_build_type) {
111                    let mut all_deps = Vec::new();
112                    collect_deps_from_group_no_prompt(g, &mut all_deps);
113                    return Ok(Some(all_deps));
114                }
115            }
116            types::BuildDependencies::List(l) => {
117                let mut all_deps = Vec::new();
118                for entry in l {
119                    if entry.build_type == resolved_build_type {
120                        all_deps.extend(entry.packages.iter().cloned());
121                    }
122                }
123                if !all_deps.is_empty() {
124                    return Ok(Some(all_deps));
125                }
126            }
127        }
128    }
129
130    Ok(None)
131}
132
133/// Retrieves test-time dependencies for a package on a specific platform.
134///
135/// # Errors
136///
137/// Returns an error if the package file path contains invalid UTF-8 characters,
138/// or if parsing the Lua package definition fails.
139pub fn get_test_dependencies(
140    package_file: &Path,
141    platform: &str,
142    version_override: Option<&str>,
143    quiet: bool
144) -> Result<Option<Vec<String>>> {
145    let pkg_for_meta = zoi_lua::parser::parse_lua_package_for_platform(
146        package_file.to_str().ok_or_else(|| {
147            anyhow!(
148                "Path contains invalid UTF-8 characters: {}",
149                package_file.display()
150            )
151        })?,
152        platform,
153        version_override,
154        None,
155        quiet
156    )?;
157
158    if let Some(deps) = &pkg_for_meta.dependencies
159        && let Some(test_deps) = &deps.test
160    {
161        let mut all_deps = Vec::new();
162        collect_deps_from_group_no_prompt(test_deps, &mut all_deps);
163        return Ok(Some(all_deps));
164    }
165
166    Ok(None)
167}
168
169/// Recursively collects dependencies from a dependency group without prompting
170/// the user.
171fn collect_deps_from_group_no_prompt(
172    group: &types::DependencyGroup,
173    deps: &mut Vec<String>
174) {
175    match group {
176        types::DependencyGroup::Simple(d) => {
177            deps.extend(d.clone());
178        }
179        types::DependencyGroup::Complex(g) => {
180            deps.extend(g.required.clone());
181            deps.extend(g.optional.clone());
182            for option_group in &g.options {
183                if option_group.all {
184                    deps.extend(option_group.depends.clone());
185                } else if let Some(dep) = option_group.depends.first() {
186                    deps.push(dep.clone());
187                }
188            }
189            if let Some(sub_deps_map) = &g.sub_packages {
190                for sub_group in sub_deps_map.values() {
191                    collect_deps_from_group_no_prompt(sub_group, deps);
192                }
193            }
194        }
195    }
196}
197
198/// Processes build operations defined in the Lua environment and stages them
199/// into the target directory.
200fn process_build_operations(
201    lua: &Lua,
202    _sub_package: &str,
203    pkg_lua_dir_str: &str,
204    build_dir_path: &Path,
205    target_staging_dir: &Path,
206    quiet: bool
207) -> Result<()> {
208    if let Ok(build_ops) = lua.globals().get::<Table>("__ZoiBuildOperations") {
209        for op in build_ops.sequence_values::<Table>() {
210            let op = op.map_err(|e| anyhow!(e.to_string()))?;
211            let op_type: String =
212                op.get("op").map_err(|e| anyhow!(e.to_string()))?;
213
214            let resolve_dest = |dest: String| -> String {
215                dest.replace("${pkgstore}", "pkgstore")
216                    .replace("${createpkgdir}", "createpkgdir")
217                    .replace("${usrroot}", "usrroot")
218                    .replace("${usrhome}", "usrhome")
219            };
220
221            match op_type.as_str() {
222                "zcp" => {
223                    let source: String =
224                        op.get("source").map_err(|e| anyhow!(e.to_string()))?;
225                    let destination: String = op
226                        .get("destination")
227                        .map_err(|e| anyhow!(e.to_string()))?;
228
229                    let mut source_path = if source.contains("${pkgluadir}") {
230                        Path::new(
231                            &source.replace("${pkgluadir}", pkg_lua_dir_str)
232                        )
233                        .to_path_buf()
234                    } else {
235                        build_dir_path.join(&source)
236                    };
237
238                    if !source_path.exists() && !source.contains("${pkgluadir}")
239                    {
240                        let fallback = Path::new(pkg_lua_dir_str).join(&source);
241                        if fallback.exists() {
242                            source_path = fallback;
243                        }
244                    }
245
246                    let dest_rel = resolve_dest(destination);
247
248                    if !utils::is_safe_path(
249                        target_staging_dir,
250                        Path::new(&dest_rel)
251                    ) {
252                        return Err(anyhow!(
253                            "Path traversal detected in zcp destination: \
254                             {dest_rel}"
255                        ));
256                    }
257
258                    let dest_path = target_staging_dir.join(&dest_rel);
259
260                    let source_metadata = match source_path.symlink_metadata() {
261                        Ok(m) => m,
262                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
263                            if !quiet {
264                                println!(
265                                    "{} Skipping missing zcp source: \
266                                     '{source}' (not found in build dir or \
267                                     package dir)",
268                                    "::".bold().yellow(),
269                                );
270                            }
271                            continue;
272                        }
273                        Err(e) => {
274                            return Err(anyhow!(
275                                "Failed to get metadata for '{}' (resolved \
276                                 from '{source}'): {e}",
277                                source_path.display(),
278                            ));
279                        }
280                    };
281
282                    if source_metadata.is_dir() {
283                        for entry in WalkDir::new(&source_path)
284                            .into_iter()
285                            .filter_map(Result::ok)
286                        {
287                            let rel_entry =
288                                entry.path().strip_prefix(&source_path)?;
289                            let target_path = dest_path.join(rel_entry);
290
291                            let Ok(metadata) = entry.path().symlink_metadata()
292                            else {
293                                continue; // Skip entries we can't read
294                            };
295
296                            if metadata.is_dir() {
297                                fs::create_dir_all(&target_path)?;
298                            } else if metadata.is_symlink() {
299                                if let Some(p) = target_path.parent() {
300                                    fs::create_dir_all(p)?;
301                                }
302                                if let Ok(link_target) =
303                                    fs::read_link(entry.path())
304                                {
305                                    utils::symlink_file(
306                                        &link_target,
307                                        &target_path
308                                    )?;
309                                }
310                            } else {
311                                if let Some(p) = target_path.parent() {
312                                    fs::create_dir_all(p)?;
313                                }
314                                let _ = fs::copy(entry.path(), &target_path);
315                            }
316                        }
317                    } else if source_metadata.is_symlink() {
318                        if let Some(parent) = dest_path.parent() {
319                            fs::create_dir_all(parent)?;
320                        }
321                        if let Ok(link_target) = fs::read_link(&source_path) {
322                            utils::symlink_file(&link_target, &dest_path)?;
323                        }
324                    } else {
325                        if let Some(parent) = dest_path.parent() {
326                            fs::create_dir_all(parent)?;
327                        }
328                        fs::copy(&source_path, &dest_path)?;
329                    }
330
331                    if !quiet {
332                        println!("Staged '{source}' to '{dest_rel}'");
333                    }
334                }
335                "zln" => {
336                    let mut target: String =
337                        op.get("target").map_err(|e| anyhow!(e.to_string()))?;
338                    let link: String =
339                        op.get("link").map_err(|e| anyhow!(e.to_string()))?;
340
341                    let dest_rel = resolve_dest(link);
342
343                    target = target.replace("${pkgstore}", "pkgstore");
344                    target = target.replace("${createpkgdir}", "createpkgdir");
345                    target = target.replace("${usrroot}", "usrroot");
346                    target = target.replace("${usrhome}", "usrhome");
347
348                    if !utils::is_safe_path(
349                        target_staging_dir,
350                        Path::new(&dest_rel)
351                    ) {
352                        return Err(anyhow!(
353                            "Path traversal detected in zln link: {dest_rel}"
354                        ));
355                    }
356
357                    let link_path = target_staging_dir.join(&dest_rel);
358                    if let Some(parent) = link_path.parent() {
359                        fs::create_dir_all(parent)?;
360                    }
361
362                    utils::symlink_file(Path::new(&target), &link_path)?;
363                    if !quiet {
364                        println!("Created symlink '{dest_rel}' -> '{target}'");
365                    }
366                }
367                "zchmod" => {
368                    let path: String =
369                        op.get("path").map_err(|e| anyhow!(e.to_string()))?;
370                    let mode: u32 =
371                        op.get("mode").map_err(|e| anyhow!(e.to_string()))?;
372
373                    let dest_rel = resolve_dest(path);
374
375                    if !utils::is_safe_path(
376                        target_staging_dir,
377                        Path::new(&dest_rel)
378                    ) {
379                        return Err(anyhow!(
380                            "Path traversal detected in zchmod path: \
381                             {dest_rel}"
382                        ));
383                    }
384
385                    #[cfg(unix)]
386                    {
387                        use std::os::unix::fs::PermissionsExt;
388                        let full_path = target_staging_dir.join(&dest_rel);
389                        fs::set_permissions(
390                            full_path,
391                            fs::Permissions::from_mode(mode)
392                        )?;
393                    }
394                    if !quiet {
395                        println!("Set permissions {mode} on '{dest_rel}'");
396                    }
397                }
398                "zchown" => {
399                    let path: String =
400                        op.get("path").map_err(|e| anyhow!(e.to_string()))?;
401                    let owner: String =
402                        op.get("owner").map_err(|e| anyhow!(e.to_string()))?;
403                    let group: String =
404                        op.get("group").map_err(|e| anyhow!(e.to_string()))?;
405
406                    let dest_rel = resolve_dest(path);
407
408                    if !utils::is_safe_path(
409                        target_staging_dir,
410                        Path::new(&dest_rel)
411                    ) {
412                        return Err(anyhow!(
413                            "Path traversal detected in zchown path: \
414                             {dest_rel}"
415                        ));
416                    }
417
418                    #[cfg(unix)]
419                    {
420                        let full_path = target_staging_dir.join(&dest_rel);
421                        utils::set_path_owner(&full_path, &owner, &group)?;
422                    }
423                    if !quiet {
424                        println!(
425                            "Set ownership {owner}:{group} on '{dest_rel}'"
426                        );
427                    }
428                }
429                "zmkdir" => {
430                    let path: String =
431                        op.get("path").map_err(|e| anyhow!(e.to_string()))?;
432
433                    let dest_rel = resolve_dest(path);
434
435                    if !utils::is_safe_path(
436                        target_staging_dir,
437                        Path::new(&dest_rel)
438                    ) {
439                        return Err(anyhow!(
440                            "Path traversal detected in zmkdir path: \
441                             {dest_rel}"
442                        ));
443                    }
444
445                    let full_path = target_staging_dir.join(&dest_rel);
446                    fs::create_dir_all(full_path)?;
447                    if !quiet {
448                        println!("Created directory '{dest_rel}'");
449                    }
450                }
451                _ => {}
452            }
453        }
454    }
455    Ok(())
456}
457
458/// Core implementation of the build process for a single platform.
459fn build_for_platform(
460    package_file: &Path,
461    build_type: Option<&str>,
462    platform: &str,
463    sign_key: Option<&String>,
464    sign_mode: types::SignMode,
465    output_dir: Option<&Path>,
466    version_override: Option<&str>,
467    sub_packages: Option<&Vec<String>>,
468    quiet: bool,
469    fakeroot: bool,
470    _install_deps: bool,
471    _test: bool
472) -> Result<()> {
473    let pkg_lua_dir = package_file
474        .parent()
475        .filter(|p| !p.as_os_str().is_empty())
476        .unwrap_or(Path::new("."));
477    let pkg_lua_dir_str = pkg_lua_dir.to_str().ok_or_else(|| {
478        anyhow!("Could not get parent directory of package file")
479    })?;
480    let pkg_for_meta = zoi_lua::parser::parse_lua_package_for_platform(
481        package_file.to_str().ok_or_else(|| {
482            anyhow!(
483                "Path contains invalid UTF-8 characters: {}",
484                package_file.display()
485            )
486        })?,
487        platform,
488        version_override,
489        None,
490        quiet
491    )?;
492
493    if let Some(allowed_platforms) = &pkg_for_meta.platforms
494        && !utils::is_platform_compatible(platform, allowed_platforms)
495    {
496        if !quiet {
497            println!(
498                "{} Skipping build for platform {}: package only supports \
499                 {allowed_platforms:?}",
500                "::".bold().yellow(),
501                platform.cyan(),
502            );
503        }
504        return Ok(());
505    }
506
507    let Some(resolved_build_type) = resolve_build_type(
508        build_type,
509        &pkg_for_meta.types,
510        &pkg_for_meta.name
511    )?
512    else {
513        if !quiet {
514            println!(
515                "{} Skipping build for package '{}': no build types supported \
516                 (likely a collection or template).",
517                "::".bold().yellow(),
518                pkg_for_meta.name
519            );
520        }
521        return Ok(());
522    };
523
524    let version = if let Some(v) = version_override {
525        v.to_string()
526    } else {
527        resolve::get_default_version(&pkg_for_meta, None)?
528    };
529
530    let build_dir = Builder::new()
531        .prefix(&format!("zoi-build-{}-{platform}", pkg_for_meta.name))
532        .tempdir()?;
533    if !quiet {
534        println!("Using build directory: {}", build_dir.path().display());
535    }
536
537    let mut skip_prepare = false;
538    if let Some(parent) = package_file.parent()
539        && parent.join(".zoi-prepared").exists()
540    {
541        if !quiet {
542            println!(
543                "{} Detected pre-prepared source bundle, copying files...",
544                "::".bold().blue()
545            );
546        }
547        utils::copy_dir_all(parent, build_dir.path())?;
548        skip_prepare = true;
549    }
550
551    let staging_dir = build_dir.path().join("staging");
552    fs::create_dir_all(&staging_dir)?;
553
554    let pool_dir = staging_dir.join("pool");
555    fs::create_dir_all(&pool_dir)?;
556
557    let mut pool: BTreeMap<String, PoolFileEntry> = BTreeMap::new();
558    let mut mappings: BTreeMap<String, SubPackageMapping> = BTreeMap::new();
559
560    let subs_to_build = if let Some(subs) = sub_packages {
561        subs.clone()
562    } else if let Some(subs) = &pkg_for_meta.sub_packages {
563        if subs.contains(&String::new()) || subs.contains(&"main".to_string()) {
564            subs.clone()
565        } else {
566            let mut all_subs = vec![String::new()];
567            all_subs.extend(subs.clone());
568            all_subs
569        }
570    } else {
571        vec![String::new()]
572    };
573
574    let scopes_to_process = pkg_for_meta.scopes.clone().unwrap_or(vec![
575        Scope::User,
576        Scope::System,
577        Scope::Project,
578    ]);
579
580    let lua_code = fs::read_to_string(package_file)?;
581
582    for sub_package in subs_to_build {
583        let sub_pkg_name = if sub_package.is_empty() {
584            None
585        } else {
586            Some(sub_package.as_str())
587        };
588
589        if !quiet && let Some(sub) = sub_pkg_name {
590            println!(
591                "{} Building sub-package: {}",
592                "::".bold().blue(),
593                sub.cyan()
594            );
595        }
596
597        // Shared prepare and build for this sub-package
598        {
599            let lua_sub = Lua::new();
600            zoi_lua::functions::setup_lua_environment(
601                &lua_sub,
602                platform,
603                Some(&version),
604                package_file.to_str(),
605                None,
606                Some(build_dir.path().to_str().unwrap_or("")),
607                None, // No staging dir for shared build
608                sub_pkg_name,
609                Some(pkg_for_meta.scope),
610                Some(resolved_build_type.as_str()),
611                quiet
612            )
613            .map_err(|e| anyhow!(e.to_string()))?;
614
615            lua_sub
616                .load(&lua_code)
617                .exec()
618                .map_err(|e| anyhow!(e.to_string()))?;
619
620            let args_sub =
621                lua_sub.create_table().map_err(|e| anyhow!(e.to_string()))?;
622            if let Some(sub) = sub_pkg_name {
623                args_sub
624                    .set("sub", sub)
625                    .map_err(|e| anyhow!(e.to_string()))?;
626            }
627
628            if !skip_prepare
629                && let Ok(prepare_fn) =
630                    lua_sub.globals().get::<mlua::Function>("prepare")
631            {
632                if !quiet {
633                    println!("Running prepare()...");
634                }
635                prepare_fn
636                    .call::<()>(args_sub.clone())
637                    .map_err(|e| anyhow!(e.to_string()))?;
638            }
639
640            if let Ok(build_fn) =
641                lua_sub.globals().get::<mlua::Function>("build")
642            {
643                if !quiet {
644                    println!("Running build()...");
645                }
646                build_fn
647                    .call::<()>(args_sub)
648                    .map_err(|e| anyhow!(e.to_string()))?;
649            }
650        }
651
652        let mut sub_mapping = SubPackageMapping {
653            scopes: BTreeMap::new()
654        };
655
656        for scope in &scopes_to_process {
657            if !quiet {
658                println!(
659                    "  {} Staging for scope: {scope:?}",
660                    "::".bold().blue()
661                );
662            }
663
664            let lua = Lua::new();
665            let v_staging = Builder::new().prefix("zoi-vstage-").tempdir()?;
666
667            zoi_lua::functions::setup_lua_environment(
668                &lua,
669                platform,
670                Some(&version),
671                package_file.to_str(),
672                None,
673                Some(build_dir.path().to_str().unwrap_or("")),
674                Some(v_staging.path().to_str().unwrap_or("")),
675                sub_pkg_name,
676                Some(*scope),
677                Some(resolved_build_type.as_str()),
678                true // Always quiet for scope loops
679            )
680            .map_err(|e| anyhow!(e.to_string()))?;
681
682            let pkg_table = lua
683                .to_value(&pkg_for_meta)
684                .map_err(|e| anyhow!(e.to_string()))?;
685            lua.globals()
686                .set("PKG", pkg_table)
687                .map_err(|e| anyhow!(e.to_string()))?;
688
689            lua.load(&lua_code)
690                .exec()
691                .map_err(|e| anyhow!(e.to_string()))?;
692
693            let args =
694                lua.create_table().map_err(|e| anyhow!(e.to_string()))?;
695            if !sub_package.is_empty() {
696                args.set("sub", sub_package.clone())
697                    .map_err(|e| anyhow!(e.to_string()))?;
698            }
699
700            if let Ok(package_fn) =
701                lua.globals().get::<mlua::Function>("package")
702            {
703                package_fn
704                    .call::<()>(args.clone())
705                    .map_err(|e| anyhow!(e.to_string()))?;
706            }
707
708            process_build_operations(
709                &lua,
710                &sub_package,
711                pkg_lua_dir_str,
712                build_dir.path(),
713                v_staging.path(),
714                true
715            )?;
716
717            // Relocate ELFs before pooling so hashes and sizes are recorded
718            // from the final relocated bytes. If we relocated after pooling,
719            // the manifest sizes/hashes would refer to pre-relocation content
720            // and installation would fail its integrity checks.
721            if platform.starts_with("linux")
722                && let Err(e) =
723                    super::relocate::relocate_elfs(v_staging.path(), quiet)
724            {
725                eprintln!(
726                    "{} Failed to relocate ELF binaries in staging: {e}",
727                    "Warning:".yellow(),
728                );
729            }
730
731            let mut scope_mapping = ScopeMapping::default();
732            super::pool::pool_files(
733                v_staging.path(),
734                &pool_dir,
735                &mut pool,
736                &mut scope_mapping,
737                fakeroot
738            )?;
739
740            sub_mapping.scopes.insert(*scope, scope_mapping);
741
742            if *scope == pkg_for_meta.scope {
743                // Run verify and test only for default scope to ensure sanity
744                if let Ok(verify_fn) =
745                    lua.globals().get::<mlua::Function>("verify")
746                {
747                    let verification_passed: bool = match verify_fn
748                        .call::<mlua::Value>(args.clone())
749                    {
750                        Ok(mlua::Value::Boolean(b)) => b,
751                        Ok(_) => true, // Legacy behavior
752                        Err(e) => {
753                            return Err(anyhow!("Verification failed: {e}"));
754                        }
755                    };
756                    if !verification_passed {
757                        return Err(anyhow!("Package verification failed."));
758                    }
759                }
760            }
761        }
762        mappings.insert(sub_package, sub_mapping);
763    }
764
765    let pooled_manifest = PooledZpaManifest {
766        version: "2".to_string(),
767        pool,
768        mappings
769    };
770
771    let manifest_json = serde_json::to_string_pretty(&pooled_manifest)?;
772    fs::write(staging_dir.join("manifest.json"), manifest_json)?;
773
774    // Embedded signatures must be created BEFORE the archive is built so the
775    // signature file becomes part of the tar payload. It covers the manifest
776    // bytes verbatim, which in turn cover every pool hash.
777    if let Some(key_id) = sign_key
778        && sign_mode == types::SignMode::Embed
779        && !quiet
780    {
781        println!("Signing embedded manifest with key '{}'...", key_id.cyan());
782    }
783    if let Some(key_id) = sign_key
784        && sign_mode == types::SignMode::Embed
785    {
786        zoi_core::pgp::sign_detached(
787            &staging_dir.join("manifest.json"),
788            &staging_dir.join("manifest.sig"),
789            key_id
790        )?;
791    }
792
793    fs::copy(
794        package_file,
795        staging_dir.join(
796            package_file
797                .file_name()
798                .ok_or_else(|| anyhow!("package_file should have a name"))?
799        )
800    )?;
801
802    let output_filename =
803        format!("{}-{version}-{platform}.zpa", pkg_for_meta.name);
804    let output_base = if let Some(dir) = output_dir {
805        dir.to_path_buf()
806    } else {
807        package_file
808            .parent()
809            .ok_or_else(|| {
810                anyhow!("package_file should have a parent directory")
811            })?
812            .to_path_buf()
813    };
814    let output_path = output_base.join(output_filename);
815
816    {
817        let file = File::create(&output_path)?;
818        let encoder = ZstdEncoder::new(file, 0)?.auto_finish();
819        let mut tar_builder = TarBuilder::new(encoder);
820
821        if fakeroot {
822            if !quiet {
823                println!(
824                    "{} Applying fakeroot (UID/GID 0) to archive...",
825                    "::".bold().blue()
826                );
827            }
828            for entry in WalkDir::new(&staging_dir).min_depth(1) {
829                let entry = entry?;
830                let path = entry.path();
831                let rel_path = path.strip_prefix(&staging_dir)?;
832
833                let mut header = tar::Header::new_gnu();
834                let metadata = fs::symlink_metadata(path)?;
835
836                header.set_metadata(&metadata);
837                header.set_uid(0);
838                header.set_gid(0);
839                header.set_username("root")?;
840                header.set_groupname("root")?;
841
842                if metadata.is_dir() {
843                    tar_builder.append_data(
844                        &mut header,
845                        rel_path,
846                        std::io::empty()
847                    )?;
848                } else if metadata.is_symlink() {
849                    let target = fs::read_link(path)?;
850                    tar_builder.append_link(&mut header, rel_path, target)?;
851                } else {
852                    let mut file = File::open(path)?;
853                    tar_builder.append_data(
854                        &mut header,
855                        rel_path,
856                        &mut file
857                    )?;
858                }
859            }
860        } else {
861            tar_builder.append_dir_all(".", &staging_dir)?;
862        }
863        tar_builder.finish()?;
864    }
865
866    // Legacy metadata files for compatibility
867    let mut files_list = std::collections::HashSet::new();
868
869    // Include the actual destination paths for all sub-packages and scopes
870    for sub_pkg in pooled_manifest.mappings.values() {
871        for scope_mapping in sub_pkg.scopes.values() {
872            for f in &scope_mapping.files {
873                files_list.insert(f.dest.clone());
874            }
875            for s in &scope_mapping.symlinks {
876                files_list.insert(s.link.clone());
877            }
878            for d in &scope_mapping.dirs {
879                // Ensure directories end with / to distinguish them in search
880                let mut dir_path = d.path.clone();
881                if !dir_path.ends_with('/') {
882                    dir_path.push('/');
883                }
884                files_list.insert(dir_path);
885            }
886        }
887    }
888
889    let mut sorted_files: Vec<_> = files_list.into_iter().collect();
890    sorted_files.sort();
891
892    let files_manifest_path =
893        PathBuf::from(format!("{}.files", output_path.display()));
894    fs::write(&files_manifest_path, sorted_files.join("\n"))?;
895
896    let hash_path = PathBuf::from(format!("{}.hash", output_path.display()));
897    let output_path_str = output_path.to_str().ok_or_else(|| {
898        anyhow!(
899            "Output path contains invalid UTF-8: {}",
900            output_path.display()
901        )
902    })?;
903    let hash = zoi_core::hash::calculate_file_hash(
904        Path::new(output_path_str),
905        zoi_core::hash::HashAlgorithm::Sha512
906    )?;
907    fs::write(
908        &hash_path,
909        format!(
910            "{hash}  {}\n",
911            output_path
912                .file_name()
913                .ok_or_else(|| anyhow!("output_path should have a name"))?
914                .to_str()
915                .ok_or_else(|| anyhow!(
916                    "output_filename should be valid UTF-8"
917                ))?
918        )
919    )?;
920
921    let size_path = PathBuf::from(format!("{}.size", output_path.display()));
922    let compressed_size = fs::metadata(&output_path)?.len();
923    let uncompressed_size: u64 = WalkDir::new(&staging_dir)
924        .into_iter()
925        .filter_map(Result::ok)
926        .filter(|e| e.file_type().is_file())
927        .map(|e| e.metadata().map_or(0, |m| m.len()))
928        .sum();
929    fs::write(
930        &size_path,
931        format!("down: {compressed_size}\ninstall: {uncompressed_size}\n")
932    )?;
933
934    if !quiet {
935        println!(
936            "{}",
937            format!("Successfully built package: {}", output_path.display())
938                .green()
939        );
940    }
941
942    if let Some(key_id) = sign_key {
943        match sign_mode {
944            types::SignMode::Embed => {
945                if !quiet {
946                    println!("{}", "Embedded signature created.".green());
947                }
948            }
949            types::SignMode::File => {
950                if !quiet {
951                    println!("Signing package with key '{}'...", key_id.cyan());
952                }
953                let signature_path =
954                    PathBuf::from(format!("{}.sig", output_path.display()));
955                if signature_path.exists() {
956                    fs::remove_file(&signature_path)?;
957                }
958                zoi_core::pgp::sign_detached(
959                    &output_path,
960                    &signature_path,
961                    key_id
962                )?;
963                if !quiet {
964                    println!(
965                        "{}",
966                        format!(
967                            "Successfully created signature: {}",
968                            signature_path.display()
969                        )
970                        .green()
971                    );
972                }
973            }
974        }
975    }
976
977    Ok(())
978}
979
980/// Executes the build process for one or more platforms.
981///
982/// # Errors
983///
984/// Returns an error if:
985/// - A .zsa bundle cannot be extracted.
986/// - The required image is not specified for Docker builds.
987/// - Building for 'all' platforms is requested.
988/// - One or more platform builds fail.
989pub fn run(
990    package_file: &Path,
991    build_type: Option<&str>,
992    platforms: &[String],
993    sign_key: Option<String>,
994    sign_mode: types::SignMode,
995    output_dir: Option<&Path>,
996    version_override: Option<&str>,
997    sub_packages: Option<Vec<String>>,
998    quiet: bool,
999    method: &str,
1000    image: Option<&str>,
1001    fakeroot: bool,
1002    install_deps: bool,
1003    test: bool
1004) -> Result<()> {
1005    let mut _temp_zsa_dir = None;
1006    let mut actual_package_file = package_file.to_path_buf();
1007    let mut default_output_dir = None;
1008
1009    if package_file.to_string_lossy().ends_with(".zsa") {
1010        if !quiet {
1011            println!(
1012                "{} Extracting source bundle: {}",
1013                "::".bold().blue(),
1014                package_file.display()
1015            );
1016        }
1017
1018        if output_dir.is_none() {
1019            default_output_dir = package_file.parent().map(Path::to_path_buf);
1020        }
1021
1022        let temp_dir = Builder::new().prefix("zoi-zsa-extract-").tempdir()?;
1023        let file = File::open(package_file)?;
1024        let decoder = ZstdDecoder::new(file)?;
1025        let mut archive = Archive::new(decoder);
1026        archive.unpack(temp_dir.path())?;
1027
1028        // Locate the .pkg.lua file inside the bundle
1029        let mut pkg_lua = None;
1030        for entry in WalkDir::new(temp_dir.path())
1031            .into_iter()
1032            .filter_map(Result::ok)
1033        {
1034            if entry.file_name().to_string_lossy().ends_with(".pkg.lua") {
1035                pkg_lua = Some(entry.path().to_path_buf());
1036                break;
1037            }
1038        }
1039
1040        actual_package_file = pkg_lua.ok_or_else(|| {
1041            anyhow!("Could not find .pkg.lua file inside the .zsa bundle.")
1042        })?;
1043        _temp_zsa_dir = Some(temp_dir);
1044    }
1045
1046    let package_file = actual_package_file.as_path();
1047    let output_dir = output_dir.or(default_output_dir.as_deref());
1048
1049    if method == "docker" {
1050        let docker_image = image.ok_or_else(|| {
1051            anyhow!(
1052                "An image must be specified when using the 'docker' build \
1053                 method."
1054            )
1055        })?;
1056        return super::docker::run(
1057            package_file,
1058            build_type,
1059            platforms,
1060            sign_key,
1061            sign_mode,
1062            output_dir,
1063            version_override,
1064            sub_packages,
1065            docker_image,
1066            fakeroot,
1067            install_deps,
1068            test
1069        );
1070    }
1071
1072    if method == "bwrap" {
1073        return super::bwrap::run(
1074            package_file,
1075            build_type,
1076            platforms,
1077            sign_key,
1078            sign_mode,
1079            output_dir,
1080            version_override,
1081            sub_packages,
1082            fakeroot,
1083            install_deps,
1084            test
1085        );
1086    }
1087
1088    if !quiet {
1089        println!("Building package from: {}", package_file.display());
1090    }
1091
1092    let platforms_to_build: Vec<String> =
1093        if platforms.contains(&"current".to_string()) {
1094            let mut p = platforms.to_vec();
1095            p.retain(|x| x != "current");
1096            p.push(utils::get_platform()?);
1097            p
1098        } else {
1099            platforms.to_vec()
1100        };
1101
1102    if platforms.contains(&"all".to_string()) {
1103        return Err(anyhow!(
1104            "Building for 'all' platforms is not supported in this flow yet. \
1105             Please specify platforms explicitly."
1106        ));
1107    }
1108
1109    let mut any_failed = false;
1110
1111    for platform in &platforms_to_build {
1112        if !quiet {
1113            println!(
1114                "{} Building for platform: {}",
1115                "::".bold().blue(),
1116                platform.cyan()
1117            );
1118        }
1119        if let Err(e) = build_for_platform(
1120            package_file,
1121            build_type,
1122            platform,
1123            sign_key.as_ref(),
1124            sign_mode,
1125            output_dir,
1126            version_override,
1127            sub_packages.as_ref(),
1128            quiet,
1129            fakeroot,
1130            install_deps,
1131            test
1132        ) {
1133            eprintln!(
1134                "{}: Failed to build for platform {}: {e}",
1135                "Error".red().bold(),
1136                platform.red(),
1137            );
1138            any_failed = true;
1139        }
1140    }
1141
1142    if any_failed {
1143        return Err(anyhow!("One or more platform builds failed"));
1144    }
1145
1146    Ok(())
1147}