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_rust;
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_rust::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 len = file
489 .metadata()
490 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?
491 .len();
492 let reader = sevenz_rust::SevenZReader::new(
493 file,
494 len,
495 sevenz_rust::Password::empty()
496 )
497 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
498 for entry in &reader.archive().files {
499 files.push(entry.name.clone());
500 }
501 } else if path_obj
502 .extension()
503 .is_some_and(|ext| ext.eq_ignore_ascii_case("rar"))
504 {
505 if zoi_core::utils::command_exists("unrar") {
506 let output = std::process::Command::new("unrar")
507 .arg("lb")
508 .arg(&path)
509 .output()
510 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
511 if output.status.success() {
512 let list = String::from_utf8_lossy(&output.stdout);
513 for line in list.lines() {
514 files.push(line.to_string());
515 }
516 }
517 }
518 } else if path_obj
519 .extension()
520 .is_some_and(|ext| ext.eq_ignore_ascii_case("deb"))
521 {
522 let mut ar = ArArchive::new(file);
523 while let Some(entry_result) = ar.next_entry() {
524 let entry = entry_result
525 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
526 let header = entry.header();
527 files.push(
528 String::from_utf8_lossy(header.identifier()).to_string()
529 );
530 }
531 } else {
532 return Err(mlua::Error::RuntimeError(format!(
533 "Unsupported archive format: {path}"
534 )));
535 }
536
537 Ok(files)
538 })?;
539 archive_table.set("list", list_fn)?;
540
541 let make_archive_fn = lua.create_function(
542 move |lua,
543 (source, output, algorithm): (
544 mlua::Value,
545 String,
546 Option<String>
547 )| {
548 let algo = algorithm
549 .unwrap_or_else(|| "zst".to_string())
550 .to_lowercase();
551 let build_dir_str: String = lua.globals().get("BUILD_DIR")?;
552 let build_dir = Path::new(&build_dir_str);
553
554 let output_path = if Path::new(&output).is_absolute() {
555 PathBuf::from(&output)
556 } else {
557 build_dir.join(&output)
558 };
559
560 if let Some(parent) = output_path.parent() {
561 fs::create_dir_all(parent)
562 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
563 }
564
565 let mut source_paths = Vec::new();
566 match source {
567 mlua::Value::String(s) => {
568 let s_borrowed = s.to_str()?;
569 let s_str_ref = s_borrowed.as_ref();
570 let p = build_dir.join(s_str_ref);
571 if p.exists() {
572 source_paths.push((p, s_str_ref.to_string()));
573 } else if Path::new(s_str_ref).exists() {
574 source_paths.push((
575 PathBuf::from(s_str_ref),
576 s_str_ref.to_string()
577 ));
578 } else {
579 return Err(mlua::Error::RuntimeError(format!(
580 "MAKE_ARCHIVE: source path does not exist: \
581 {s_str_ref}"
582 )));
583 }
584 }
585 mlua::Value::Table(t) => {
586 for val in t.sequence_values::<String>() {
587 let s_str = val?;
588 let p = build_dir.join(&s_str);
589 if p.exists() {
590 source_paths.push((p, s_str.clone()));
591 } else if Path::new(&s_str).exists() {
592 source_paths
593 .push((PathBuf::from(&s_str), s_str.clone()));
594 } else {
595 return Err(mlua::Error::RuntimeError(format!(
596 "MAKE_ARCHIVE: source path does not exist: \
597 {s_str}"
598 )));
599 }
600 }
601 }
602 _ => {
603 return Err(mlua::Error::RuntimeError(
604 "MAKE_ARCHIVE: source must be string or table"
605 .to_string()
606 ));
607 }
608 }
609
610 let file = fs::File::create(&output_path)
611 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
612
613 match algo.as_str() {
614 "gz" => {
615 let mut encoder = flate2::write::GzEncoder::new(
616 file,
617 flate2::Compression::default()
618 );
619 for (path, _) in source_paths {
620 let mut f = fs::File::open(path).map_err(|e| {
621 mlua::Error::RuntimeError(e.to_string())
622 })?;
623 std::io::copy(&mut f, &mut encoder).map_err(|e| {
624 mlua::Error::RuntimeError(e.to_string())
625 })?;
626 }
627 encoder.finish().map_err(|e| {
628 mlua::Error::RuntimeError(e.to_string())
629 })?;
630 }
631 "zip" => {
632 let mut zip = zip::ZipWriter::new(file);
633 let options = zip::write::SimpleFileOptions::default()
634 .compression_method(zip::CompressionMethod::Deflated);
635
636 for (path, rel_name) in source_paths {
637 if path.is_dir() {
638 let parent = path
639 .parent()
640 .expect("source path should have a parent");
641 for entry in walkdir::WalkDir::new(&path)
642 .into_iter()
643 .filter_map(Result::ok)
644 {
645 let rel =
646 entry.path().strip_prefix(parent).expect(
647 "entry path should be within source \
648 path"
649 );
650 if entry.file_type().is_dir() {
651 zip.add_directory(
652 rel.to_string_lossy(),
653 options
654 )
655 .map_err(|e| {
656 mlua::Error::RuntimeError(e.to_string())
657 })?;
658 } else {
659 zip.start_file(
660 rel.to_string_lossy(),
661 options
662 )
663 .map_err(|e| {
664 mlua::Error::RuntimeError(e.to_string())
665 })?;
666 let mut f = fs::File::open(entry.path())
667 .map_err(|e| {
668 mlua::Error::RuntimeError(
669 e.to_string()
670 )
671 })?;
672 std::io::copy(&mut f, &mut zip).map_err(
673 |e| {
674 mlua::Error::RuntimeError(
675 e.to_string()
676 )
677 }
678 )?;
679 }
680 }
681 } else {
682 zip.start_file(rel_name, options).map_err(|e| {
683 mlua::Error::RuntimeError(e.to_string())
684 })?;
685 let mut f = fs::File::open(path).map_err(|e| {
686 mlua::Error::RuntimeError(e.to_string())
687 })?;
688 std::io::copy(&mut f, &mut zip).map_err(|e| {
689 mlua::Error::RuntimeError(e.to_string())
690 })?;
691 }
692 }
693 zip.finish().map_err(|e| {
694 mlua::Error::RuntimeError(e.to_string())
695 })?;
696 }
697 "tar" | "tar.gz" | "tar.xz" | "tar.zst" | "zst" => {
698 let writer: Box<dyn std::io::Write> = match algo.as_str() {
699 "tar" => Box::new(file),
700 "tar.gz" => Box::new(flate2::write::GzEncoder::new(
701 file,
702 flate2::Compression::default()
703 )),
704 "tar.xz" => {
705 Box::new(xz2::write::XzEncoder::new(file, 6))
706 }
707 "tar.zst" | "zst" => Box::new(
708 zstd::stream::write::Encoder::new(file, 0)
709 .map_err(|e| {
710 mlua::Error::RuntimeError(e.to_string())
711 })?
712 .auto_finish()
713 ),
714 _ => unreachable!()
715 };
716
717 let mut tar = tar::Builder::new(writer);
718 for (path, rel_name) in source_paths {
719 if path.is_dir() {
720 tar.append_dir_all(rel_name, path).map_err(
721 |e| mlua::Error::RuntimeError(e.to_string())
722 )?;
723 } else {
724 tar.append_path_with_name(path, rel_name).map_err(
725 |e| mlua::Error::RuntimeError(e.to_string())
726 )?;
727 }
728 }
729 tar.finish().map_err(|e| {
730 mlua::Error::RuntimeError(e.to_string())
731 })?;
732 }
733 _ => {
734 return Err(mlua::Error::RuntimeError(format!(
735 "MAKE_ARCHIVE: unsupported algorithm: {algo}"
736 )));
737 }
738 }
739
740 Ok(())
741 }
742 )?;
743
744 let utils_table: Table = lua.globals().get("UTILS")?;
745 utils_table.set("ARCHIVE", archive_table)?;
746 utils_table.set("MAKE_ARCHIVE", make_archive_fn)?;
747
748 Ok(())
749}