Skip to main content

zoi_package/
bundle.rs

1//! Logic for creating Zoi Source Archives (`.zsa`).
2//!
3//! This module handles the "bundling" process, which packages a `.pkg.lua`
4//! file together with its local assets and fetched upstream sources into
5//! a single, self-contained archive. This is useful for offline builds
6//! and for distributing source code along with build instructions.
7
8use std::collections::HashSet;
9use std::fs::{self, File};
10use std::path::{Path, PathBuf};
11
12use anyhow::{Result, anyhow};
13use colored::Colorize;
14use ignore::gitignore::GitignoreBuilder;
15use mlua::{Lua, LuaSerdeExt, Table, Value};
16use tar::Builder as TarBuilder;
17use tempfile::Builder;
18use walkdir::WalkDir;
19use zstd::stream::write::Encoder as ZstdEncoder;
20
21/// Bundles a package and its dependencies into a `.zsa` archive.
22///
23/// Returns the path of the created archive.
24///
25/// # Errors
26///
27/// Returns an error if:
28/// - Parsing the `.pkg.lua` file fails.
29/// - Upstream sources cannot be fetched.
30/// - The archive cannot be created or signed.
31pub fn run(
32    package_file: &Path,
33    output_dir: Option<&Path>,
34    sign: Option<String>,
35    sign_mode: zoi_core::types::SignMode,
36    version_override: Option<&str>,
37    build_type: Option<&str>
38) -> Result<PathBuf> {
39    let pkg_dir = package_file
40        .parent()
41        .ok_or_else(|| anyhow!("Could not get parent directory"))?;
42
43    // Load .zoiignore if it exists
44    let mut ignore_builder = GitignoreBuilder::new(pkg_dir);
45    let zoiignore_path = pkg_dir.join(".zoiignore");
46    if zoiignore_path.exists()
47        && let Some(err) = ignore_builder.add(&zoiignore_path)
48    {
49        eprintln!("{}: Error parsing .zoiignore: {}", "Warning".yellow(), err);
50    }
51    let ignore = ignore_builder.build()?;
52
53    let is_ignored = |rel_path: &Path, is_dir: bool| -> bool {
54        ignore.matched(rel_path, is_dir).is_ignore()
55    };
56
57    println!(
58        "{} Bundling package: {}",
59        "::".bold().blue(),
60        package_file.display()
61    );
62
63    let lua = Lua::new();
64    let platform = zoi_core::utils::get_platform()?;
65
66    // Initialize global tables for tracking
67    let refs_table = lua.create_table().map_err(|e| anyhow!(e.to_string()))?;
68    lua.globals()
69        .set("__ZoiReferencedFiles", refs_table)
70        .map_err(|e| anyhow!(e.to_string()))?;
71
72    // Setup a mocked environment for metadata and asset discovery
73    // We run it twice: once to find local assets, and once to actually run
74    // prepare() if needed.
75
76    let bundle_type = build_type.unwrap_or("source");
77
78    // Phase 1: Metadata & Local Asset Discovery
79    zoi_lua::functions::setup_lua_environment(
80        &lua,
81        &platform,
82        version_override,
83        package_file.to_str(),
84        None,
85        Some("/tmp/mock-build"),
86        Some("/tmp/mock-staging"),
87        None,
88        None,
89        Some(bundle_type),
90        true // quiet
91    )
92    .map_err(|e| anyhow!(e.to_string()))?;
93
94    lua.globals()
95        .set("BUILD_TYPE", bundle_type)
96        .map_err(|e| anyhow!(e.to_string()))?;
97
98    // Mock UTILS.EXTRACT to record local references but avoid downloads (in
99    // this phase)
100    if let Ok(utils) = lua.globals().get::<Table>("UTILS") {
101        let mock_extract = lua
102            .create_function(|_, (_source, _out_dir): (String, String)| Ok(()))
103            .map_err(|e| anyhow!(e.to_string()))?;
104        utils
105            .set("EXTRACT", mock_extract)
106            .map_err(|e| anyhow!(e.to_string()))?;
107    }
108
109    // Mock cmd to avoid shell execution in this phase
110    let mock_cmd = lua
111        .create_function(|_, _command: String| {
112            Ok((String::new(), String::new(), 0))
113        })
114        .map_err(|e| anyhow!(e.to_string()))?;
115    lua.globals()
116        .set("cmd", mock_cmd)
117        .map_err(|e| anyhow!(e.to_string()))?;
118
119    // Load and execute the package file
120    let lua_code = fs::read_to_string(package_file)?;
121    lua.load(&lua_code).exec().map_err(|e| {
122        anyhow!(
123            "Failed to execute Lua package file '{}' for bundling:\n{}",
124            package_file.display(),
125            e
126        )
127    })?;
128
129    let args = lua.create_table().map_err(|e| anyhow!(e.to_string()))?;
130
131    // Call lifecycle functions to find ${pkgluadir} references
132    if let Ok(pkg_fn) = lua.globals().get::<mlua::Function>("package") {
133        let _ = pkg_fn.call::<()>(args.clone());
134    }
135
136    let mut files_to_include = HashSet::new();
137
138    // Always include the package file itself
139    let pkg_filename = package_file
140        .file_name()
141        .ok_or_else(|| anyhow!("Invalid package file"))?;
142    files_to_include.insert(pkg_filename.to_string_lossy().to_string());
143
144    // Collect from __ZoiReferencedFiles (IMPORT/INCLUDE)
145    if let Ok(refs) = lua.globals().get::<Table>("__ZoiReferencedFiles") {
146        for val in refs.sequence_values::<String>() {
147            files_to_include.insert(val.map_err(|e| anyhow!(e.to_string()))?);
148        }
149    }
150
151    // Collect from __ZoiBuildOperations (zcp/zln with ${pkgluadir})
152    if let Ok(ops) = lua.globals().get::<Table>("__ZoiBuildOperations") {
153        for op in ops.sequence_values::<Table>() {
154            let op = op.map_err(|e| anyhow!(e.to_string()))?;
155
156            // Check 'source' (used by zcp)
157            if let Ok(source) = op.get::<String>("source")
158                && let Some(rel) = source.strip_prefix("${pkgluadir}/")
159            {
160                files_to_include.insert(rel.to_string());
161            }
162
163            // Check 'target' (used by zln)
164            if let Ok(target) = op.get::<String>("target")
165                && let Some(rel) = target.strip_prefix("${pkgluadir}/")
166            {
167                files_to_include.insert(rel.to_string());
168            }
169        }
170    }
171
172    // Phase 2: Fetching Upstream Sources (Running prepare)
173    println!("{} Fetching upstream sources...", "::".bold().blue());
174    let fetch_dir = Builder::new().prefix("zoi-bundle-fetch-").tempdir()?;
175
176    // Setup a real environment for prepare()
177    let lua_fetch = Lua::new();
178
179    // Initialize package metadata tables for the fetch state
180    let pkg_meta_table_f = lua_fetch
181        .create_table()
182        .map_err(|e| anyhow!(e.to_string()))?;
183    let pkg_deps_table_f = lua_fetch
184        .create_table()
185        .map_err(|e| anyhow!(e.to_string()))?;
186    let pkg_updates_table_f = lua_fetch
187        .create_table()
188        .map_err(|e| anyhow!(e.to_string()))?;
189    let pkg_hooks_table_f = lua_fetch
190        .create_table()
191        .map_err(|e| anyhow!(e.to_string()))?;
192    let pkg_service_table_f = lua_fetch
193        .create_table()
194        .map_err(|e| anyhow!(e.to_string()))?;
195    lua_fetch
196        .globals()
197        .set("__ZoiPackageMeta", pkg_meta_table_f)
198        .map_err(|e| anyhow!(e.to_string()))?;
199    lua_fetch
200        .globals()
201        .set("__ZoiPackageDeps", pkg_deps_table_f)
202        .map_err(|e| anyhow!(e.to_string()))?;
203    lua_fetch
204        .globals()
205        .set("__ZoiPackageUpdates", pkg_updates_table_f)
206        .map_err(|e| anyhow!(e.to_string()))?;
207    lua_fetch
208        .globals()
209        .set("__ZoiPackageHooks", pkg_hooks_table_f)
210        .map_err(|e| anyhow!(e.to_string()))?;
211    lua_fetch
212        .globals()
213        .set("__ZoiPackageService", pkg_service_table_f)
214        .map_err(|e| anyhow!(e.to_string()))?;
215
216    let pkg_global_table_f = lua_fetch
217        .create_table()
218        .map_err(|e| anyhow!(e.to_string()))?;
219    lua_fetch
220        .globals()
221        .set("PKG", pkg_global_table_f)
222        .map_err(|e| anyhow!(e.to_string()))?;
223
224    zoi_lua::functions::setup_lua_environment(
225        &lua_fetch,
226        &platform,
227        version_override,
228        package_file.to_str(),
229        None,
230        Some(fetch_dir.path().to_str().unwrap_or("")),
231        Some("/tmp/mock-staging"),
232        None,
233        None,
234        Some(bundle_type),
235        true // quiet
236    )
237    .map_err(|e| anyhow!(e.to_string()))?;
238
239    lua_fetch
240        .globals()
241        .set("BUILD_TYPE", bundle_type)
242        .map_err(|e| anyhow!(e.to_string()))?;
243
244    lua_fetch
245        .globals()
246        .set(
247            "BUILD_DIR",
248            fetch_dir
249                .path()
250                .to_str()
251                .ok_or_else(|| anyhow!("Invalid fetch path"))?
252        )
253        .map_err(|e| anyhow!(e.to_string()))?;
254
255    // We use the real cmd implementation for fetching
256    zoi_lua::api::system::add_cmd_util(&lua_fetch, true)
257        .map_err(|e| anyhow!(e.to_string()))?;
258
259    // Reload script in the fetch environment
260    lua_fetch.load(&lua_code).exec().map_err(|e| {
261        anyhow!(
262            "Failed to execute Lua package file '{}' during fetch:\n{}",
263            package_file.display(),
264            e
265        )
266    })?;
267
268    if let Ok(prep_fn) = lua_fetch.globals().get::<mlua::Function>("prepare") {
269        println!("  Running prepare()...");
270        let args_fetch = lua_fetch
271            .create_table()
272            .map_err(|e| anyhow!(e.to_string()))?;
273        prep_fn.call::<()>(args_fetch).map_err(|e| {
274            anyhow!(
275                "The 'prepare' function in '{}' failed during bundling:\n{}",
276                package_file.display(),
277                e
278            )
279        })?;
280    }
281
282    // Determine output path
283    let pkg_dir = package_file
284        .parent()
285        .ok_or_else(|| anyhow!("Could not get parent directory"))?;
286
287    let final_pkg_meta: Table = lua
288        .globals()
289        .get("__ZoiPackageMeta")
290        .map_err(|e| anyhow!(e.to_string()))?;
291    let pkg_meta: zoi_core::types::Package = lua
292        .from_value(Value::Table(final_pkg_meta))
293        .map_err(|e| anyhow!(e.to_string()))?;
294
295    let version = version_override
296        .map(ToString::to_string)
297        .or(pkg_meta.version)
298        .unwrap_or_else(|| "unknown".to_string());
299    let output_filename = format!("{}-{}.zsa", pkg_meta.name, version);
300    let output_base =
301        output_dir.map_or_else(|| pkg_dir.to_path_buf(), Path::to_path_buf);
302    let output_path = output_base.join(output_filename);
303
304    let file = File::create(&output_path)?;
305    let encoder = ZstdEncoder::new(file, 0)?.auto_finish();
306    let mut tar_builder = TarBuilder::new(encoder);
307
308    // Include local files
309    let mut sorted_files: Vec<_> = files_to_include.into_iter().collect();
310    sorted_files.sort();
311
312    for rel_path_str in sorted_files {
313        let rel_path = Path::new(&rel_path_str);
314        let abs_path = pkg_dir.join(rel_path);
315        let is_dir = abs_path.is_dir();
316
317        if is_ignored(rel_path, is_dir) {
318            println!("  Ignored: {rel_path_str}");
319            continue;
320        }
321
322        if abs_path.exists() {
323            if is_dir {
324                // Manually walk local directories to respect ignores
325                // recursively
326                let mut it = WalkDir::new(&abs_path).into_iter();
327                loop {
328                    let entry = match it.next() {
329                        None => break,
330                        Some(Err(e)) => return Err(e.into()),
331                        Some(Ok(e)) => e
332                    };
333                    let entry_rel = entry.path().strip_prefix(pkg_dir)?;
334                    let entry_is_dir = entry.file_type().is_dir();
335                    if is_ignored(entry_rel, entry_is_dir) {
336                        if entry_is_dir {
337                            it.skip_current_dir();
338                        }
339                        continue;
340                    }
341
342                    if entry.file_type().is_file() {
343                        tar_builder
344                            .append_path_with_name(entry.path(), entry_rel)?;
345                        println!("  Included local: {}", entry_rel.display());
346                    }
347                }
348            } else {
349                tar_builder.append_path_with_name(&abs_path, &rel_path_str)?;
350                println!("  Included local: {rel_path_str}");
351            }
352        }
353    }
354
355    // Include fetched files from BUILD_DIR
356    let mut it = WalkDir::new(fetch_dir.path()).into_iter();
357    loop {
358        let entry = match it.next() {
359            None => break,
360            Some(Err(e)) => return Err(e.into()),
361            Some(Ok(e)) => e
362        };
363
364        if entry.depth() == 0 {
365            continue;
366        }
367
368        let rel_path = entry.path().strip_prefix(fetch_dir.path())?;
369        let rel_path_str = rel_path.to_string_lossy();
370        let is_dir = entry.file_type().is_dir();
371
372        if is_ignored(rel_path, is_dir) {
373            if is_dir {
374                it.skip_current_dir();
375            }
376            println!("  Ignored fetch: {rel_path_str}");
377            continue;
378        }
379
380        if entry.file_type().is_dir() {
381            // We'll add directories as we encounter their files or empty dirs
382            continue;
383        }
384
385        tar_builder.append_path_with_name(entry.path(), rel_path)?;
386        println!("  Included fetch: {rel_path_str}");
387    }
388
389    // Mark as a full bundle so build knows to skip prepare
390    let mut header = tar::Header::new_gnu();
391    header.set_path(".zoi-prepared")?;
392    header.set_size(0);
393    header.set_cksum();
394    tar_builder.append(&header, &[][..])?;
395
396    // Embedded signatures are added to the archive itself so they travel
397    // with the bundle. The anchor is the `.pkg.lua` definition, which
398    // identifies the bundle contents.
399    if let Some(key_id) = &sign
400        && sign_mode == zoi_core::types::SignMode::Embed
401    {
402        let pkg_filename_str = pkg_filename.to_string_lossy();
403        let sig_name = format!("{pkg_filename_str}.sig");
404        let temp_sig = tempfile::Builder::new()
405            .prefix("zoi-embed-sig-")
406            .tempfile()?;
407        zoi_core::pgp::sign_detached(package_file, temp_sig.path(), key_id)?;
408        let mut header = tar::Header::new_gnu();
409        let metadata = std::fs::metadata(temp_sig.path())?;
410        header.set_metadata(&metadata);
411        header.set_path(&sig_name)?;
412        header.set_cksum();
413        let mut sig_file = File::open(temp_sig.path())?;
414        tar_builder.append_data(&mut header, &sig_name, &mut sig_file)?;
415        println!("  Embedded signature for {sig_name}");
416    }
417
418    tar_builder.finish()?;
419    println!(
420        "{} Successfully created bundle: {}",
421        "::".bold().green(),
422        output_path.display()
423    );
424
425    if let Some(key_id) = sign
426        && sign_mode == zoi_core::types::SignMode::File
427    {
428        println!(
429            "{} Signing bundle with key '{}'...",
430            "::".bold().blue(),
431            key_id.cyan()
432        );
433        let signature_path =
434            PathBuf::from(format!("{}.sig", output_path.display()));
435        if signature_path.exists() {
436            fs::remove_file(&signature_path)?;
437        }
438        zoi_core::pgp::sign_detached(&output_path, &signature_path, &key_id)?;
439        println!(
440            "{} Successfully created signature: {}",
441            "::".bold().green(),
442            signature_path.display()
443        );
444    }
445
446    Ok(output_path)
447}