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