1use std::fs;
7use std::path::{Path, PathBuf};
8
9use ar::Archive as ArArchive;
10use flate2::read::GzDecoder;
11use mlua::{self, Lua, Table};
12use sevenz_rust2;
13use xz2::read::XzDecoder;
14use zip::ZipArchive;
15use zstd::stream::read::Decoder as ZstdDecoder;
16
17pub fn add_extract_util(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
46 let extract_fn = lua.create_function(
47 move |lua, (source, out_name): (String, Option<String>)| {
48 let build_dir_str: String = lua.globals().get("BUILD_DIR")?;
49 let build_dir = Path::new(&build_dir_str);
50
51 let archive_file = if source.starts_with("http") {
52 let file_name =
53 source.split('/').next_back().unwrap_or("download.tmp");
54 let temp_path = build_dir.join(file_name);
55 super::download::download_with_progress(
56 &source, &temp_path, quiet
57 )?;
58
59 temp_path
60 } else {
61 PathBuf::from(source)
62 };
63
64 let out_dir_name =
65 out_name.unwrap_or_else(|| "extracted".to_string());
66 let out_dir = build_dir.join(&out_dir_name);
67
68 if !out_dir.starts_with(build_dir) || out_dir == build_dir {
69 return Err(mlua::Error::RuntimeError(format!(
70 "Invalid output directory: {out_dir_name}. Extraction \
71 must be into a subdirectory of the build directory."
72 )));
73 }
74
75 fs::create_dir_all(&out_dir)
76 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
77
78 if !quiet {
79 println!(
80 "Extracting {} to {}",
81 archive_file.display(),
82 out_dir.display()
83 );
84 }
85
86 let file = fs::File::open(&archive_file)
87 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
88
89 let archive_path = Path::new(&archive_file);
90 let archive_path_str = archive_file.to_string_lossy();
91
92 if archive_path
93 .extension()
94 .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
95 {
96 let mut archive = ZipArchive::new(file)
97 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
98 archive
99 .extract(&out_dir)
100 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
101 } else if archive_path_str.ends_with(".tar.gz")
102 || archive_path
103 .extension()
104 .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
105 {
106 let tar_gz = GzDecoder::new(file);
107 let mut archive = tar::Archive::new(tar_gz);
108 archive
109 .unpack(&out_dir)
110 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
111 } else if archive_path_str.ends_with(".tar.zst")
112 || archive_path
113 .extension()
114 .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
115 || archive_path
116 .extension()
117 .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa"))
118 {
119 let tar_zst = ZstdDecoder::new(file)
120 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
121 let mut archive = tar::Archive::new(tar_zst);
122 archive
123 .unpack(&out_dir)
124 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
125 } else if archive_path_str.ends_with(".tar.xz") {
126 let tar_xz = XzDecoder::new(file);
127 let mut archive = tar::Archive::new(tar_xz);
128 archive
129 .unpack(&out_dir)
130 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
131 } else if archive_path
132 .extension()
133 .is_some_and(|ext| ext.eq_ignore_ascii_case("7z"))
134 {
135 sevenz_rust2::decompress_file(&archive_file, &out_dir)
136 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
137 } else if archive_path
138 .extension()
139 .is_some_and(|ext| ext.eq_ignore_ascii_case("dmg"))
140 {
141 if !cfg!(target_os = "macos") {
142 return Err(mlua::Error::RuntimeError(
143 "Extracting .dmg files is only supported on macOS."
144 .to_string()
145 ));
146 }
147 let output = std::process::Command::new("hdiutil")
148 .arg("attach")
149 .arg("-nobrowse")
150 .arg("-readonly")
151 .arg(&archive_file)
152 .output()
153 .map_err(|e| {
154 mlua::Error::RuntimeError(format!(
155 "Failed to execute hdiutil: {e}"
156 ))
157 })?;
158 if !output.status.success() {
159 let stderr = String::from_utf8_lossy(&output.stderr);
160 return Err(mlua::Error::RuntimeError(format!(
161 "hdiutil failed: {stderr}"
162 )));
163 }
164 let output_str = String::from_utf8_lossy(&output.stdout);
165 let mut mount_point = None;
166 for line in output_str.lines() {
167 if line.contains("/Volumes/")
168 && let Some(idx) = line.find("/Volumes/")
169 {
170 mount_point = Some(line[idx..].trim().to_string());
171 break;
172 }
173 }
174 let mount_point = mount_point.ok_or_else(|| {
175 mlua::Error::RuntimeError(
176 "Failed to parse mount point from hdiutil output."
177 .to_string()
178 )
179 })?;
180 let mount_path = std::path::Path::new(&mount_point);
181 if let Err(e) =
182 zoi_core::utils::copy_dir_all(mount_path, &out_dir)
183 {
184 let _ = std::process::Command::new("hdiutil")
185 .arg("detach")
186 .arg(&mount_point)
187 .status();
188 return Err(mlua::Error::RuntimeError(format!(
189 "Failed to copy contents from dmg: {e}"
190 )));
191 }
192 let detach_status = std::process::Command::new("hdiutil")
193 .arg("detach")
194 .arg(&mount_point)
195 .status()
196 .map_err(|e| {
197 mlua::Error::RuntimeError(format!(
198 "Failed to execute hdiutil detach: {e}"
199 ))
200 })?;
201 if !detach_status.success() {
202 eprintln!(
203 "Warning: failed to detach dmg volume at {mount_point}"
204 );
205 }
206 } else if archive_path
207 .extension()
208 .is_some_and(|ext| ext.eq_ignore_ascii_case("pkg"))
209 {
210 if !cfg!(target_os = "macos") {
211 return Err(mlua::Error::RuntimeError(
212 "Extracting .pkg files natively is only supported on \
213 macOS."
214 .to_string()
215 ));
216 }
217 let temp_extract_dir = out_dir.join(".pkg_extract_tmp");
218 let status = std::process::Command::new("pkgutil")
219 .arg("--expand-full")
220 .arg(&archive_file)
221 .arg(&temp_extract_dir)
222 .status()
223 .map_err(|e| {
224 mlua::Error::RuntimeError(format!(
225 "Failed to execute pkgutil: {e}"
226 ))
227 })?;
228 if !status.success() {
229 return Err(mlua::Error::RuntimeError(
230 "pkgutil failed to expand the package.".to_string()
231 ));
232 }
233 zoi_core::utils::copy_dir_all(&temp_extract_dir, &out_dir)
234 .map_err(|e| {
235 mlua::Error::RuntimeError(format!(
236 "Failed to copy pkg contents: {e}"
237 ))
238 })?;
239 let _ = fs::remove_dir_all(&temp_extract_dir);
240 } else if archive_path
241 .extension()
242 .is_some_and(|ext| ext.eq_ignore_ascii_case("rar"))
243 {
244 if zoi_core::utils::command_exists("unrar") {
245 let status = std::process::Command::new("unrar")
246 .arg("x")
247 .arg("-y")
248 .arg(&archive_file)
249 .arg(&out_dir)
250 .status()
251 .map_err(|e| {
252 mlua::Error::RuntimeError(e.to_string())
253 })?;
254 if !status.success() {
255 return Err(mlua::Error::RuntimeError(
256 "unrar failed".to_string()
257 ));
258 }
259 } else {
260 return Err(mlua::Error::RuntimeError(
261 "unrar command not found. Please install unrar to \
262 extract .rar files."
263 .to_string()
264 ));
265 }
266 } else if archive_path
267 .extension()
268 .is_some_and(|ext| ext.eq_ignore_ascii_case("deb"))
269 {
270 let mut ar = ArArchive::new(file);
271 while let Some(entry_result) = ar.next_entry() {
272 let mut entry = entry_result.map_err(|e| {
273 mlua::Error::RuntimeError(e.to_string())
274 })?;
275 let name =
276 String::from_utf8_lossy(entry.header().identifier())
277 .trim()
278 .trim_end_matches('/')
279 .to_string();
280 if name.starts_with("data.tar") {
281 let temp_data_path = build_dir.join(&name);
282 let mut temp_file = fs::File::create(&temp_data_path)
283 .map_err(|e| {
284 mlua::Error::RuntimeError(format!(
285 "Failed to create temp file for {name}: {e}"
286 ))
287 })?;
288 std::io::copy(&mut entry, &mut temp_file).map_err(
289 |e| {
290 mlua::Error::RuntimeError(format!(
291 "Failed to copy entry data for {name}: {e}"
292 ))
293 }
294 )?;
295
296 let data_file = fs::File::open(&temp_data_path)
297 .map_err(|e| {
298 mlua::Error::RuntimeError(format!(
299 "Failed to reopen temp file for {name}: \
300 {e}"
301 ))
302 })?;
303 let data_path = Path::new(&name);
304 if data_path
305 .extension()
306 .is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
307 {
308 let mut archive =
309 tar::Archive::new(GzDecoder::new(data_file));
310 archive.unpack(&out_dir).map_err(|e| {
311 mlua::Error::RuntimeError(format!(
312 "Failed to unpack {name}: {e}"
313 ))
314 })?;
315 } else if data_path
316 .extension()
317 .is_some_and(|ext| ext.eq_ignore_ascii_case("xz"))
318 {
319 let mut archive =
320 tar::Archive::new(XzDecoder::new(data_file));
321 archive.unpack(&out_dir).map_err(|e| {
322 mlua::Error::RuntimeError(format!(
323 "Failed to unpack {name}: {e}"
324 ))
325 })?;
326 } else if data_path
327 .extension()
328 .is_some_and(|ext| ext.eq_ignore_ascii_case("zst"))
329 {
330 let mut archive = tar::Archive::new(
331 ZstdDecoder::new(data_file).map_err(|e| {
332 mlua::Error::RuntimeError(format!(
333 "Failed to initialize zstd for \
334 {name}: {e}"
335 ))
336 })?
337 );
338 archive.unpack(&out_dir).map_err(|e| {
339 mlua::Error::RuntimeError(format!(
340 "Failed to unpack {name}: {e}"
341 ))
342 })?;
343 }
344 fs::remove_file(temp_data_path).ok();
345 }
346 }
347 } else {
348 return Err(mlua::Error::RuntimeError(format!(
349 "Unsupported archive format for file: {archive_path_str}"
350 )));
351 }
352
353 Ok(())
354 }
355 )?;
356
357 let utils_table: Table = lua.globals().get("UTILS")?;
358 utils_table.set("EXTRACT", extract_fn)?;
359
360 Ok(())
361}
362
363pub fn add_archive_util(lua: &Lua) -> Result<(), mlua::Error> {
386 let archive_table = lua.create_table()?;
387
388 let list_fn = lua.create_function(|lua, path: String| {
389 let p = Path::new(&path);
390 let actual_path = if p.exists() {
391 p.to_path_buf()
392 } else if let Ok(build_dir) = lua.globals().get::<String>("BUILD_DIR") {
393 Path::new(&build_dir).join(p)
394 } else {
395 p.to_path_buf()
396 };
397
398 let file = fs::File::open(&actual_path).map_err(|e| {
399 mlua::Error::RuntimeError(format!(
400 "Failed to open archive {}: {e}",
401 actual_path.display()
402 ))
403 })?;
404 let mut files = Vec::new();
405
406 let path_obj = Path::new(&path);
407 if path_obj
408 .extension()
409 .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"))
410 {
411 let mut archive = ZipArchive::new(file)
412 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
413 for i in 0..archive.len() {
414 let file = archive
415 .by_index(i)
416 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
417 files.push(file.name().to_string());
418 }
419 } else if path.ends_with(".tar.gz")
420 || path_obj
421 .extension()
422 .is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
423 {
424 let tar_gz = GzDecoder::new(file);
425 let mut archive = tar::Archive::new(tar_gz);
426 for entry in archive
427 .entries()
428 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
429 {
430 let entry = entry
431 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
432 files.push(
433 entry
434 .path()
435 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
436 .to_string_lossy()
437 .to_string()
438 );
439 }
440 } else if path.ends_with(".tar.zst")
441 || path_obj
442 .extension()
443 .is_some_and(|ext| ext.eq_ignore_ascii_case("zpa"))
444 || path_obj
445 .extension()
446 .is_some_and(|ext| ext.eq_ignore_ascii_case("zsa"))
447 {
448 let tar_zst = ZstdDecoder::new(file)
449 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
450 let mut archive = tar::Archive::new(tar_zst);
451 for entry in archive
452 .entries()
453 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
454 {
455 let entry = entry
456 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
457 files.push(
458 entry
459 .path()
460 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
461 .to_string_lossy()
462 .to_string()
463 );
464 }
465 } else if path.ends_with(".tar.xz") {
466 let tar_xz = XzDecoder::new(file);
467 let mut archive = tar::Archive::new(tar_xz);
468 for entry in archive
469 .entries()
470 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
471 {
472 let entry = entry
473 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
474 files.push(
475 entry
476 .path()
477 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
478 .to_string_lossy()
479 .to_string()
480 );
481 }
482 } else if path_obj
483 .extension()
484 .is_some_and(|ext| ext.eq_ignore_ascii_case("7z"))
485 {
486 let file = fs::File::open(&path)
487 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
488 let reader = sevenz_rust2::ArchiveReader::new(
489 file,
490 sevenz_rust2::Password::empty()
491 )
492 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
493 for entry in &reader.archive().files {
494 files.push(entry.name.clone());
495 }
496 } else if path_obj
497 .extension()
498 .is_some_and(|ext| ext.eq_ignore_ascii_case("rar"))
499 {
500 if zoi_core::utils::command_exists("unrar") {
501 let output = std::process::Command::new("unrar")
502 .arg("lb")
503 .arg(&path)
504 .output()
505 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
506 if output.status.success() {
507 let list = String::from_utf8_lossy(&output.stdout);
508 for line in list.lines() {
509 files.push(line.to_string());
510 }
511 }
512 }
513 } else if path_obj
514 .extension()
515 .is_some_and(|ext| ext.eq_ignore_ascii_case("deb"))
516 {
517 let mut ar = ArArchive::new(file);
518 while let Some(entry_result) = ar.next_entry() {
519 let entry = entry_result
520 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
521 let header = entry.header();
522 files.push(
523 String::from_utf8_lossy(header.identifier()).to_string()
524 );
525 }
526 } else {
527 return Err(mlua::Error::RuntimeError(format!(
528 "Unsupported archive format: {path}"
529 )));
530 }
531
532 Ok(files)
533 })?;
534 archive_table.set("list", list_fn)?;
535
536 let make_archive_fn = lua.create_function(
537 move |lua,
538 (source, output, algorithm): (
539 mlua::Value,
540 String,
541 Option<String>
542 )| {
543 let algo = algorithm
544 .unwrap_or_else(|| "zst".to_string())
545 .to_lowercase();
546 let build_dir_str: String = lua.globals().get("BUILD_DIR")?;
547 let build_dir = Path::new(&build_dir_str);
548
549 let output_path = if Path::new(&output).is_absolute() {
550 PathBuf::from(&output)
551 } else {
552 build_dir.join(&output)
553 };
554
555 if let Some(parent) = output_path.parent() {
556 fs::create_dir_all(parent)
557 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
558 }
559
560 let mut source_paths = Vec::new();
561 match source {
562 mlua::Value::String(s) => {
563 let s_borrowed = s.to_str()?;
564 let s_str_ref = s_borrowed.as_ref();
565 let p = build_dir.join(s_str_ref);
566 if p.exists() {
567 source_paths.push((p, s_str_ref.to_string()));
568 } else if Path::new(s_str_ref).exists() {
569 source_paths.push((
570 PathBuf::from(s_str_ref),
571 s_str_ref.to_string()
572 ));
573 } else {
574 return Err(mlua::Error::RuntimeError(format!(
575 "MAKE_ARCHIVE: source path does not exist: \
576 {s_str_ref}"
577 )));
578 }
579 }
580 mlua::Value::Table(t) => {
581 for val in t.sequence_values::<String>() {
582 let s_str = val?;
583 let p = build_dir.join(&s_str);
584 if p.exists() {
585 source_paths.push((p, s_str.clone()));
586 } else if Path::new(&s_str).exists() {
587 source_paths
588 .push((PathBuf::from(&s_str), s_str.clone()));
589 } else {
590 return Err(mlua::Error::RuntimeError(format!(
591 "MAKE_ARCHIVE: source path does not exist: \
592 {s_str}"
593 )));
594 }
595 }
596 }
597 _ => {
598 return Err(mlua::Error::RuntimeError(
599 "MAKE_ARCHIVE: source must be string or table"
600 .to_string()
601 ));
602 }
603 }
604
605 let file = fs::File::create(&output_path)
606 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
607
608 match algo.as_str() {
609 "gz" => {
610 let mut encoder = flate2::write::GzEncoder::new(
611 file,
612 flate2::Compression::default()
613 );
614 for (path, _) in source_paths {
615 let mut f = fs::File::open(path).map_err(|e| {
616 mlua::Error::RuntimeError(e.to_string())
617 })?;
618 std::io::copy(&mut f, &mut encoder).map_err(|e| {
619 mlua::Error::RuntimeError(e.to_string())
620 })?;
621 }
622 encoder.finish().map_err(|e| {
623 mlua::Error::RuntimeError(e.to_string())
624 })?;
625 }
626 "zip" => {
627 let mut zip = zip::ZipWriter::new(file);
628 let options = zip::write::SimpleFileOptions::default()
629 .compression_method(zip::CompressionMethod::Deflated);
630
631 for (path, rel_name) in source_paths {
632 if path.is_dir() {
633 let parent = path
634 .parent()
635 .expect("source path should have a parent");
636 for entry in walkdir::WalkDir::new(&path)
637 .into_iter()
638 .filter_map(Result::ok)
639 {
640 let rel =
641 entry.path().strip_prefix(parent).expect(
642 "entry path should be within source \
643 path"
644 );
645 if entry.file_type().is_dir() {
646 zip.add_directory(
647 rel.to_string_lossy(),
648 options
649 )
650 .map_err(|e| {
651 mlua::Error::RuntimeError(e.to_string())
652 })?;
653 } else {
654 zip.start_file(
655 rel.to_string_lossy(),
656 options
657 )
658 .map_err(|e| {
659 mlua::Error::RuntimeError(e.to_string())
660 })?;
661 let mut f = fs::File::open(entry.path())
662 .map_err(|e| {
663 mlua::Error::RuntimeError(
664 e.to_string()
665 )
666 })?;
667 std::io::copy(&mut f, &mut zip).map_err(
668 |e| {
669 mlua::Error::RuntimeError(
670 e.to_string()
671 )
672 }
673 )?;
674 }
675 }
676 } else {
677 zip.start_file(rel_name, options).map_err(|e| {
678 mlua::Error::RuntimeError(e.to_string())
679 })?;
680 let mut f = fs::File::open(path).map_err(|e| {
681 mlua::Error::RuntimeError(e.to_string())
682 })?;
683 std::io::copy(&mut f, &mut zip).map_err(|e| {
684 mlua::Error::RuntimeError(e.to_string())
685 })?;
686 }
687 }
688 zip.finish().map_err(|e| {
689 mlua::Error::RuntimeError(e.to_string())
690 })?;
691 }
692 "tar" | "tar.gz" | "tar.xz" | "tar.zst" | "zst" => {
693 let writer: Box<dyn std::io::Write> = match algo.as_str() {
694 "tar" => Box::new(file),
695 "tar.gz" => Box::new(flate2::write::GzEncoder::new(
696 file,
697 flate2::Compression::default()
698 )),
699 "tar.xz" => {
700 Box::new(xz2::write::XzEncoder::new(file, 6))
701 }
702 "tar.zst" | "zst" => Box::new(
703 zstd::stream::write::Encoder::new(file, 0)
704 .map_err(|e| {
705 mlua::Error::RuntimeError(e.to_string())
706 })?
707 .auto_finish()
708 ),
709 _ => unreachable!()
710 };
711
712 let mut tar = tar::Builder::new(writer);
713 for (path, rel_name) in source_paths {
714 if path.is_dir() {
715 tar.append_dir_all(rel_name, path).map_err(
716 |e| mlua::Error::RuntimeError(e.to_string())
717 )?;
718 } else {
719 tar.append_path_with_name(path, rel_name).map_err(
720 |e| mlua::Error::RuntimeError(e.to_string())
721 )?;
722 }
723 }
724 tar.finish().map_err(|e| {
725 mlua::Error::RuntimeError(e.to_string())
726 })?;
727 }
728 _ => {
729 return Err(mlua::Error::RuntimeError(format!(
730 "MAKE_ARCHIVE: unsupported algorithm: {algo}"
731 )));
732 }
733 }
734
735 Ok(())
736 }
737 )?;
738
739 let utils_table: Table = lua.globals().get("UTILS")?;
740 utils_table.set("ARCHIVE", archive_table)?;
741 utils_table.set("MAKE_ARCHIVE", make_archive_fn)?;
742
743 Ok(())
744}