1use 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
21pub 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 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 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 let bundle_type = build_type.unwrap_or("source");
77
78 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 )
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 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 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 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 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 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 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 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 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 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 println!("{} Fetching upstream sources...", "::".bold().blue());
174 let fetch_dir = Builder::new().prefix("zoi-bundle-fetch-").tempdir()?;
175
176 let lua_fetch = Lua::new();
178
179 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 )
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 zoi_lua::api::system::add_cmd_util(&lua_fetch, true)
257 .map_err(|e| anyhow!(e.to_string()))?;
258
259 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 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 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 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 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 continue;
383 }
384
385 tar_builder.append_path_with_name(entry.path(), rel_path)?;
386 println!(" Included fetch: {rel_path_str}");
387 }
388
389 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 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}