// Extraction of the LunaPack Studio release archive.
//
// Studio is shipped as a plain zip attached to the *same* GitHub release as
// this CLI. It is unpacked here rather than by an external `unzip` or `tar`:
// a command line tool that downloads a window should not also depend on
// whichever archiver happens to be installed on the machine.
//
// Only the two methods a release zip actually uses are supported: stored
// (directory entries) and raw deflate (everything else). Sizes come from the
// central directory, so entries with a data descriptor work too.
///|
/// One file in the central directory.
struct ZipEntry {
name : String
codec : Int32
compressed : Int32
size : Int32
local_off : Int32
}
///|
fn zip_u16(data : Bytes, off : Int32) -> Int32 {
data.at(off).to_int() | (data.at(off + 1).to_int() << 8)
}
///|
fn zip_u32(data : Bytes, off : Int32) -> Int64 {
zip_u16(data, off).to_int64() | (zip_u16(data, off + 2).to_int64() << 16)
}
///|
/// Offset of the end-of-central-directory record. The comment at the end of
/// a zip can be up to 65535 bytes, so the record is looked for backwards.
fn find_eocd(data : Bytes) -> Int32 raise {
let n = data.length()
if n < 22 {
fail("the downloaded Studio archive is too small to be a zip file")
}
let lower = if n > 65557 { n - 65557 } else { 0 }
let mut i = n - 22
while i >= lower {
if zip_u32(data, i) == 0x06054b50L {
return i
}
i = i - 1
}
fail("the downloaded Studio archive is not a zip file")
}
///|
fn zip_entries(data : Bytes) -> Array[ZipEntry] raise {
let eocd = find_eocd(data)
let count = zip_u16(data, eocd + 10)
let mut p = zip_u32(data, eocd + 16).to_int()
let out : Array[ZipEntry] = []
for _i = 0; _i < count; _i = _i + 1 {
if p < 0 || p + 46 > data.length() || zip_u32(data, p) != 0x02014b50L {
fail("the downloaded Studio archive has a damaged central directory")
}
let codec = zip_u16(data, p + 10)
let compressed = zip_u32(data, p + 20).to_int()
let size = zip_u32(data, p + 24).to_int()
let name_len = zip_u16(data, p + 28)
let extra_len = zip_u16(data, p + 30)
let comment_len = zip_u16(data, p + 32)
let local_off = zip_u32(data, p + 42).to_int()
if p + 46 + name_len > data.length() {
fail("the downloaded Studio archive has a truncated file name")
}
let name = @utf8.decode_lossy(data[p + 46:p + 46 + name_len])
out.push({ name, codec, compressed, size, local_off, })
p = p + 46 + name_len + extra_len + comment_len
}
out
}
///|
/// Windows packers often write `\` as the separator inside a zip. The zip
/// format specifies `/`, but a name taken verbatim would produce a path with
/// mixed separators: `dest/foo\bar\baz`. On Windows `@fs.mkdir` then reports
/// "The system cannot find the path specified", and a directory entry ending
/// in `\` is mistaken for a file. Normalise once, before any path is built.
pub fn normalized_zip_name(name : String) -> String {
name.replace_all(old="\\", new="/")
}
///|
/// A zip is data from the network, so an entry name is not allowed to escape
/// the directory it is being unpacked into.
fn safe_zip_name(name : String) -> Bool {
let n = normalized_zip_name(name)
if n.has_prefix("/") {
return false
}
if n.length() > 1 && n[1] == ':' {
return false
}
if n == ".." ||
n.has_prefix("../") ||
n.has_suffix("/..") ||
n.find("/../") is Some(_) {
return false
}
true
}
///|
/// Unpack every entry of `data` below `dest`, creating parent directories.
pub async fn extract_zip(data : Bytes, dest : String) -> Unit {
let entries = zip_entries(data)
if entries.length() == 0 {
fail("the downloaded Studio archive contains no files")
}
@fs.mkdir(dest, recursive=true, allow_exist=true)
for e in entries {
let name = normalized_zip_name(e.name)
if !safe_zip_name(name) {
fail("the downloaded Studio archive has an unsafe path: " + e.name)
}
if name == "" {
continue
}
if name.has_suffix("/") {
// A trailing slash makes some runtimes report NotFound for the last
// component. The directory itself is the same path without it.
let trimmed = name[:name.length() - 1].to_owned()
@fs.mkdir(dest + "/" + trimmed, recursive=true, allow_exist=true)
continue
}
let lh = e.local_off
if lh < 0 || lh + 30 > data.length() || zip_u32(data, lh) != 0x04034b50L {
fail("the downloaded Studio archive has a damaged local header")
}
let start = lh + 30 + zip_u16(data, lh + 26) + zip_u16(data, lh + 28)
if start < 0 || e.compressed < 0 || start + e.compressed > data.length() {
fail("the downloaded Studio archive has a truncated entry")
}
let payload = data[start:start + e.compressed]
let path = dest + "/" + name
ensure_parent_dir(path)
if e.codec == 0 {
@fs.write_binary(path, payload)
} else if e.codec == 8 {
let bytes = @flate.inflate_exact(
payload,
max_output=e.size,
two_pass=true,
) catch {
_ => fail("cannot unpack " + e.name)
}
@fs.write_binary(path, bytes)
} else {
fail("unsupported zip compression method " + e.codec.to_string())
}
// A downloaded executable must be runnable on Unix. The call is skipped
// on Windows, where chmod is a no-op the runtime does not implement.
if host_platform() != "windows" {
@fs.chmod(path, 493) catch {
_ => ()
}
}
}
}