Skip to main content

zoi_lua/api/
archive.rs

1//! Archive utilities for Lua scripts.
2//!
3//! This module provides functions for extracting and creating various archive
4//! formats (Zip, Tar, Zstd, Xz, etc.) within the Lua environment.
5
6use 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
17/// Exposes the `UTILS.EXTRACT` function to the Lua environment.
18///
19/// This utility provides a unified interface for downloading and extracting
20/// various archive formats. It handles:
21/// - Remote Fetching: If the source starts with http(s), it downloads the file
22///   to `BUILD_DIR`.
23/// - Format Detection: Dispatches to the appropriate decoder (Zip, Tar, Zstd,
24///   Xz, 7z, etc.).
25/// - Error Propagation: Any failure (network, filesystem, or corruption) is
26///   converted into an `mlua::Error::RuntimeError`, which halts the Lua
27///   execution and is caught by the Rust build engine to trigger a rollback.
28///
29/// # Errors
30///
31/// Returns an `mlua::Error` if:
32/// - The `UTILS` table cannot be found.
33/// - The output directory is invalid (not a subdirectory of `BUILD_DIR`).
34/// - Filesystem operations (create dir, open file, copy, remove) fail.
35/// - Network download fails.
36/// - Archive extraction fails.
37/// - The archive format is unsupported.
38///
39/// # Panics
40///
41/// This function may panic if:
42/// - Parsing the source URL fails to yield a filename.
43/// - Executing external commands (`hdiutil`, `pkgutil`, `unrar`) fails or their
44///   output cannot be parsed.
45pub 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
363/// Exposes the `UTILS.ARCHIVE` table and `UTILS.MAKE_ARCHIVE` function to the
364/// Lua environment.
365///
366/// `UTILS.ARCHIVE` includes:
367/// - `list(path)`: Lists the contents of an archive.
368///
369/// `UTILS.MAKE_ARCHIVE(source, output, algorithm)`: Creates an archive from a
370/// source path.
371///
372/// # Errors
373///
374/// Returns an `mlua::Error` if:
375/// - The `UTILS` table cannot be found.
376/// - Filesystem operations (open, create, metadata, read, write) fail.
377/// - Archive processing (zip, tar, etc.) fails.
378/// - Unsupported archive format or algorithm is provided.
379///
380/// # Panics
381///
382/// This function may panic if:
383/// - Stripping path prefixes fails during ZIP creation.
384/// - The parent of a source path cannot be determined.
385pub 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}