///|
/// Archive extraction helpers.
///
/// `.zip` archives (mooncakes.io package sources) are decoded in-process with
/// `bobzhang/zip`, which keeps the tool portable to any Linux/macOS host
/// without requiring `unzip`. `.tar.gz` archives (MoonBit toolchain builds)
/// are handled by the system `tar`, which is present on every supported host.
///|
/// Extract a zip archive from memory into `dest`.
///
/// Entry paths are sanitised to prevent `..` escapes from writing outside
/// `dest`. Symlink entries are ignored: only regular files and directories are
/// materialised.
pub fn extract_zip(zip_bytes : Bytes, dest : String) -> Unit raise MbError {
let archive = @zip.Archive::of_bytes(zip_bytes) catch {
_ => raise MbError("failed to parse zip archive")
}
mkdir_p(dest)
for entry in archive.to_array() {
let raw = @fpath.sanitize(entry.path()).to_string()
if raw == "" ||
raw.has_prefix("/") ||
raw == ".." ||
raw.has_prefix("../") ||
raw.contains("/../") {
continue
}
let out = path_join(dest, raw)
if entry.is_dir() {
mkdir_p(out)
} else if entry.is_file() {
let content = match entry.kind() {
File(f) =>
f.to_bytes() catch {
_ => raise MbError("failed to decompress zip member: " + raw)
}
Dir => continue
}
write_bytes(out, content)
}
}
}
///|
/// Extract a `.tar.gz` archive on disk into `dest` using the system `tar`.
pub fn extract_tar_gz(archive : String, dest : String) -> Unit raise MbError {
mkdir_p(dest)
let cmd = "tar -xzf " + shell_quote(archive) + " -C " + shell_quote(dest)
run_checked(cmd) catch {
_ => raise MbError("failed to extract tar archive: " + archive)
}
}
///|
/// Archive suffixes `mb` knows how to unpack, longest first so that
/// `.tar.gz` is recognised before the bare `.gz`.
fn archive_suffixes() -> Array[String] {
[
".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tgz", ".txz", ".tbz2", ".zip",
".tar", ".gz", ".xz", ".bz2", ".zst",
]
}
///|
/// True when `name` ends in a suffix `mb` can unpack (`zip`, `tar`, `gz`,
/// `xz`, `bz2`, `zst`, ...). A name without such a suffix is treated as an
/// uncompressed file.
fn is_archive_name(name : String) -> Bool {
let lower = name.to_lower()
for suffix in archive_suffixes() {
if lower.has_suffix(suffix) {
return true
}
}
false
}
///|
/// Remove one archive suffix from `s`, if present.
fn strip_archive_ext(s : String) -> String? {
let lower = s.to_lower()
for suffix in archive_suffixes() {
if lower.has_suffix(suffix) {
return Some(s[:s.length() - suffix.length()].to_owned())
}
}
None
}
///|
/// True when `name` names a tar container, optionally compressed (`.tar`,
/// `.tar.gz`, `.tgz`, `.tar.xz`, `.txz`, `.tar.bz2`, `.tar.zst`).
fn is_tar_archive(name : String) -> Bool {
let lower = name.to_lower()
let suffixes = [
".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tgz", ".txz", ".tbz2", ".tar",
]
for s in suffixes {
if lower.has_suffix(s) {
return true
}
}
false
}
///|
/// Command that writes a single compressed file to stdout, for the formats
/// that are not (necessarily) tar containers.
fn decompress_command(name : String) -> String? {
let lower = name.to_lower()
if lower.has_suffix(".gz") {
Some("gzip -dc")
} else if lower.has_suffix(".xz") {
Some("xz -dc")
} else if lower.has_suffix(".bz2") {
Some("bzip2 -dc")
} else if lower.has_suffix(".zst") {
Some("zstd -dc")
} else {
None
}
}
///|
/// Unpack a downloaded archive into `dest`, regardless of whether it is a
/// `zip`, a `tar` container (optionally compressed) or a bare compressed file.
///
/// `name` is the original asset name; its suffix decides how to unpack, which
/// keeps the behaviour independent of the temporary file's own name.
fn extract_archive(
path : String,
name : String,
dest : String,
) -> Unit raise MbError {
mkdir_p(dest)
let lower = name.to_lower()
if lower.has_suffix(".zip") {
extract_zip(read_bytes(path), dest)
return
}
if is_tar_archive(name) {
// `tar -xf` auto-detects the compression of `.tar`, `.tar.gz`, `.tgz`,
// `.tar.xz`, `.txz`, `.tar.bz2` and `.tar.zst` on GNU tar and bsdtar.
run_checked("tar -xf " + shell_quote(path) + " -C " + shell_quote(dest)) catch {
_ => raise MbError("failed to extract archive: " + name)
}
return
}
// A bare compressed file: decompress it into `dest`.
match decompress_command(name) {
Some(cmd) => {
let stem = match strip_archive_ext(name) {
Some(s) => s
None => name
}
let out = path_join(dest, base_name(stem))
run_checked(cmd + " " + shell_quote(path) + " > " + shell_quote(out)) catch {
_ => raise MbError("failed to extract archive: " + name)
}
}
None => raise MbError("failed to extract archive: " + name)
}
}