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            let mut scope_mapping = ScopeMapping::default();
704            super::pool::pool_files(
705                v_staging.path(),
706                &pool_dir,
707                &mut pool,
708                &mut scope_mapping,
709                fakeroot
710            )?;
711
712            sub_mapping.scopes.insert(*scope, scope_mapping);
713
714            if *scope == pkg_for_meta.scope {
715                // Run verify and test only for default scope to ensure sanity
716                if let Ok(verify_fn) =
717                    lua.globals().get::<mlua::Function>("verify")
718                {
719                    let verification_passed: bool = match verify_fn
720                        .call::<mlua::Value>(args.clone())
721                    {
722                        Ok(mlua::Value::Boolean(b)) => b,
723                        Ok(_) => true, // Legacy behavior
724                        Err(e) => {
725                            return Err(anyhow!("Verification failed: {e}"));
726                        }
727                    };
728                    if !verification_passed {
729                        return Err(anyhow!("Package verification failed."));
730                    }
731                }
732            }
733        }
734        mappings.insert(sub_package, sub_mapping);
735    }
736
737    if platform.starts_with("linux")
738        && let Err(e) = super::relocate::relocate_elfs(&pool_dir, quiet)
739    {
740        eprintln!(
741            "{} Failed to relocate ELF binaries in pool: {e}",
742            "Warning:".yellow(),
743        );
744    }
745
746    let pooled_manifest = PooledZpaManifest {
747        version: "2".to_string(),
748        pool,
749        mappings
750    };
751
752    let manifest_json = serde_json::to_string_pretty(&pooled_manifest)?;
753    fs::write(staging_dir.join("manifest.json"), manifest_json)?;
754
755    fs::copy(
756        package_file,
757        staging_dir.join(
758            package_file
759                .file_name()
760                .ok_or_else(|| anyhow!("package_file should have a name"))?
761        )
762    )?;
763
764    let output_filename =
765        format!("{}-{version}-{platform}.zpa", pkg_for_meta.name);
766    let output_base = if let Some(dir) = output_dir {
767        dir.to_path_buf()
768    } else {
769        package_file
770            .parent()
771            .ok_or_else(|| {
772                anyhow!("package_file should have a parent directory")
773            })?
774            .to_path_buf()
775    };
776    let output_path = output_base.join(output_filename);
777
778    {
779        let file = File::create(&output_path)?;
780        let encoder = ZstdEncoder::new(file, 0)?.auto_finish();
781        let mut tar_builder = TarBuilder::new(encoder);
782
783        if fakeroot {
784            if !quiet {
785                println!(
786                    "{} Applying fakeroot (UID/GID 0) to archive...",
787                    "::".bold().blue()
788                );
789            }
790            for entry in WalkDir::new(&staging_dir).min_depth(1) {
791                let entry = entry?;
792                let path = entry.path();
793                let rel_path = path.strip_prefix(&staging_dir)?;
794
795                let mut header = tar::Header::new_gnu();
796                let metadata = fs::symlink_metadata(path)?;
797
798                header.set_metadata(&metadata);
799                header.set_uid(0);
800                header.set_gid(0);
801                header.set_username("root")?;
802                header.set_groupname("root")?;
803
804                if metadata.is_dir() {
805                    tar_builder.append_data(
806                        &mut header,
807                        rel_path,
808                        std::io::empty()
809                    )?;
810                } else if metadata.is_symlink() {
811                    let target = fs::read_link(path)?;
812                    tar_builder.append_link(&mut header, rel_path, target)?;
813                } else {
814                    let mut file = File::open(path)?;
815                    tar_builder.append_data(
816                        &mut header,
817                        rel_path,
818                        &mut file
819                    )?;
820                }
821            }
822        } else {
823            tar_builder.append_dir_all(".", &staging_dir)?;
824        }
825        tar_builder.finish()?;
826    }
827
828    // Legacy metadata files for compatibility
829    let mut files_list = std::collections::HashSet::new();
830
831    // Include the actual destination paths for all sub-packages and scopes
832    for sub_pkg in pooled_manifest.mappings.values() {
833        for scope_mapping in sub_pkg.scopes.values() {
834            for f in &scope_mapping.files {
835                files_list.insert(f.dest.clone());
836            }
837            for s in &scope_mapping.symlinks {
838                files_list.insert(s.link.clone());
839            }
840            for d in &scope_mapping.dirs {
841                // Ensure directories end with / to distinguish them in search
842                let mut dir_path = d.path.clone();
843                if !dir_path.ends_with('/') {
844                    dir_path.push('/');
845                }
846                files_list.insert(dir_path);
847            }
848        }
849    }
850
851    let mut sorted_files: Vec<_> = files_list.into_iter().collect();
852    sorted_files.sort();
853
854    let files_manifest_path =
855        PathBuf::from(format!("{}.files", output_path.display()));
856    fs::write(&files_manifest_path, sorted_files.join("\n"))?;
857
858    let hash_path = PathBuf::from(format!("{}.hash", output_path.display()));
859    let output_path_str = output_path.to_str().ok_or_else(|| {
860        anyhow!(
861            "Output path contains invalid UTF-8: {}",
862            output_path.display()
863        )
864    })?;
865    let hash = zoi_core::hash::calculate_file_hash(
866        Path::new(output_path_str),
867        zoi_core::hash::HashAlgorithm::Sha512
868    )?;
869    fs::write(
870        &hash_path,
871        format!(
872            "{hash}  {}\n",
873            output_path
874                .file_name()
875                .ok_or_else(|| anyhow!("output_path should have a name"))?
876                .to_str()
877                .ok_or_else(|| anyhow!(
878                    "output_filename should be valid UTF-8"
879                ))?
880        )
881    )?;
882
883    let size_path = PathBuf::from(format!("{}.size", output_path.display()));
884    let compressed_size = fs::metadata(&output_path)?.len();
885    let uncompressed_size: u64 = WalkDir::new(&staging_dir)
886        .into_iter()
887        .filter_map(Result::ok)
888        .filter(|e| e.file_type().is_file())
889        .map(|e| e.metadata().map_or(0, |m| m.len()))
890        .sum();
891    fs::write(
892        &size_path,
893        format!("down: {compressed_size}\ninstall: {uncompressed_size}\n")
894    )?;
895
896    if !quiet {
897        println!(
898            "{}",
899            format!("Successfully built package: {}", output_path.display())
900                .green()
901        );
902    }
903
904    if let Some(key_id) = sign_key {
905        if !quiet {
906            println!("Signing package with key '{}'...", key_id.cyan());
907        }
908        let signature_path =
909            PathBuf::from(format!("{}.sig", output_path.display()));
910        if signature_path.exists() {
911            fs::remove_file(&signature_path)?;
912        }
913        zoi_core::pgp::sign_detached(&output_path, &signature_path, key_id)?;
914        if !quiet {
915            println!(
916                "{}",
917                format!(
918                    "Successfully created signature: {}",
919                    signature_path.display()
920                )
921                .green()
922            );
923        }
924    }
925
926    Ok(())
927}
928
929/// Executes the build process for one or more platforms.
930///
931/// # Errors
932///
933/// Returns an error if:
934/// - A .zsa bundle cannot be extracted.
935/// - The required image is not specified for Docker builds.
936/// - Building for 'all' platforms is requested.
937/// - One or more platform builds fail.
938pub fn run(
939    package_file: &Path,
940    build_type: Option<&str>,
941    platforms: &[String],
942    sign_key: Option<String>,
943    output_dir: Option<&Path>,
944    version_override: Option<&str>,
945    sub_packages: Option<Vec<String>>,
946    quiet: bool,
947    method: &str,
948    image: Option<&str>,
949    fakeroot: bool,
950    install_deps: bool,
951    test: bool
952) -> Result<()> {
953    let mut _temp_zsa_dir = None;
954    let mut actual_package_file = package_file.to_path_buf();
955    let mut default_output_dir = None;
956
957    if package_file.to_string_lossy().ends_with(".zsa") {
958        if !quiet {
959            println!(
960                "{} Extracting source bundle: {}",
961                "::".bold().blue(),
962                package_file.display()
963            );
964        }
965
966        if output_dir.is_none() {
967            default_output_dir = package_file.parent().map(Path::to_path_buf);
968        }
969
970        let temp_dir = Builder::new().prefix("zoi-zsa-extract-").tempdir()?;
971        let file = File::open(package_file)?;
972        let decoder = ZstdDecoder::new(file)?;
973        let mut archive = Archive::new(decoder);
974        archive.unpack(temp_dir.path())?;
975
976        // Locate the .pkg.lua file inside the bundle
977        let mut pkg_lua = None;
978        for entry in WalkDir::new(temp_dir.path())
979            .into_iter()
980            .filter_map(Result::ok)
981        {
982            if entry.file_name().to_string_lossy().ends_with(".pkg.lua") {
983                pkg_lua = Some(entry.path().to_path_buf());
984                break;
985            }
986        }
987
988        actual_package_file = pkg_lua.ok_or_else(|| {
989            anyhow!("Could not find .pkg.lua file inside the .zsa bundle.")
990        })?;
991        _temp_zsa_dir = Some(temp_dir);
992    }
993
994    let package_file = actual_package_file.as_path();
995    let output_dir = output_dir.or(default_output_dir.as_deref());
996
997    if method == "docker" {
998        let docker_image = image.ok_or_else(|| {
999            anyhow!(
1000                "An image must be specified when using the 'docker' build \
1001                 method."
1002            )
1003        })?;
1004        return super::docker::run(
1005            package_file,
1006            build_type,
1007            platforms,
1008            sign_key,
1009            output_dir,
1010            version_override,
1011            sub_packages,
1012            docker_image,
1013            fakeroot,
1014            install_deps,
1015            test
1016        );
1017    }
1018
1019    if method == "bwrap" {
1020        return super::bwrap::run(
1021            package_file,
1022            build_type,
1023            platforms,
1024            sign_key,
1025            output_dir,
1026            version_override,
1027            sub_packages,
1028            fakeroot,
1029            install_deps,
1030            test
1031        );
1032    }
1033
1034    if !quiet {
1035        println!("Building package from: {}", package_file.display());
1036    }
1037
1038    let platforms_to_build: Vec<String> =
1039        if platforms.contains(&"current".to_string()) {
1040            let mut p = platforms.to_vec();
1041            p.retain(|x| x != "current");
1042            p.push(utils::get_platform()?);
1043            p
1044        } else {
1045            platforms.to_vec()
1046        };
1047
1048    if platforms.contains(&"all".to_string()) {
1049        return Err(anyhow!(
1050            "Building for 'all' platforms is not supported in this flow yet. \
1051             Please specify platforms explicitly."
1052        ));
1053    }
1054
1055    let mut any_failed = false;
1056
1057    for platform in &platforms_to_build {
1058        if !quiet {
1059            println!(
1060                "{} Building for platform: {}",
1061                "::".bold().blue(),
1062                platform.cyan()
1063            );
1064        }
1065        if let Err(e) = build_for_platform(
1066            package_file,
1067            build_type,
1068            platform,
1069            sign_key.as_ref(),
1070            output_dir,
1071            version_override,
1072            sub_packages.as_ref(),
1073            quiet,
1074            fakeroot,
1075            install_deps,
1076            test
1077        ) {
1078            eprintln!(
1079                "{}: Failed to build for platform {}: {e}",
1080                "Error".red().bold(),
1081                platform.red(),
1082            );
1083            any_failed = true;
1084        }
1085    }
1086
1087    if any_failed {
1088        return Err(anyhow!("One or more platform builds failed"));
1089    }
1090
1091    Ok(())
1092}