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