/// # zipx - Pure MoonBit ZIP Extraction Library
///
/// A pure MoonBit implementation of ZIP archive extraction with no FFI
/// and no system command dependencies.
///
/// ## Basic Usage
///
/// Extract all files from a ZIP archive:
///
/// ```moonbit nocheck
/// let zip_data = @fs.read_file("archive.zip").binary()
/// let files = unzip_file(zip_data)
/// for (name, data) in files.iter() {
/// println("Extracted: " + name)
/// }
/// ```
///
/// List files without extracting:
///
/// ```moonbit nocheck
/// let zip = zip_file_open(zip_data)
/// let names = zip_file_list(zip)
/// for name in names.iter() {
/// println(name)
/// }
/// ```
///|
/// ## Compression Method
///
/// The compression method used in a ZIP file entry.
///
/// - `Stored`: No compression (stored as-is)
/// - `Deflated`: DEFLATE compression
/// - `Unsupported(UInt16)`: Unsupported compression method
pub enum CompressionMethod {
Stored
Deflated
Unsupported(UInt16)
}
///|
/// ## Central Directory File Header
///
/// Represents a file entry in the ZIP archive's central directory.
/// Contains metadata about each file in the archive.
pub struct CentralDirectoryFileHeader {
signature : UInt
version_made_by : UInt16
version_needed : UInt16
flags : UInt16
compression_method : CompressionMethod
last_mod_time : UInt16
last_mod_date : UInt16
crc32 : UInt
compressed_size : UInt
uncompressed_size : UInt
filename_length : UInt16
extra_field_length : UInt16
comment_length : UInt16
disk_number_start : UInt16
internal_file_attrs : UInt16
external_file_attrs : UInt
local_header_offset : UInt
filename : String
extra_field : Bytes
comment : String
}
///|
/// ## ZipFile
///
/// Represents an open ZIP archive file.
/// Use `zip_file_open` to create an instance.
pub struct ZipFile {
data : Bytes
eocd : EndOfCentralDirectoryRecord
cd_entries : Array[CentralDirectoryFileHeader]
}
///|
/// ## Open a ZIP File
///
/// Opens a ZIP archive from byte data and returns a `ZipFile` handle.
///
/// # Parameters
/// - `data`: The raw bytes of the ZIP file
///
/// # Returns
/// A `ZipFile` handle for reading the archive
///
/// # Throws
/// - If the end of central directory record cannot be found
/// - If the central directory is corrupted
///
/// # Example
/// ```moonbit nocheck
/// let zip = zip_file_open(zip_data)
///
/// let files = zip_file_list(zip)
/// ```
pub fn zip_file_open(data : Bytes) -> ZipFile raise {
match parse_end_of_central_directory_record(data) {
None => fail("Cannot find end of central directory record")
Some(e) => {
let len : Int = e.cd_entries_total.to_int()
let cd_entries : Array[CentralDirectoryFileHeader] = []
let mut offset : Int = e.cd_offset.reinterpret_as_int()
let end_offset : Int = offset + e.cd_size.reinterpret_as_int()
while offset < end_offset {
match parse_central_directory_file_header(data, offset) {
None => break
Some(entry) => {
cd_entries.push(entry)
offset = offset +
46 +
entry.filename_length.to_int() +
entry.extra_field_length.to_int() +
entry.comment_length.to_int()
}
}
}
if cd_entries.length() != len {
fail("Central directory entry count mismatch")
}
ZipFile::{ data, eocd: e, cd_entries }
}
}
}
///|
/// ## Extract a Single File
///
/// Extracts a single file from the ZIP archive by index.
///
/// # Parameters
/// - `zip`: The open ZIP file handle
/// - `index`: The zero-based index of the file to extract
///
/// # Returns
/// A tuple of `(filename, data)` containing the extracted file
///
/// # Throws
/// - If the index is out of bounds
/// - If the local file header cannot be found
/// - If decompression fails
///
/// # Example
/// ```moonbit nocheck
/// let (filename, data) = zip_file_extract(zip, 0)
/// ```
pub fn zip_file_extract(zip : ZipFile, index : Int) -> (String, Bytes) raise {
if index < 0 || index >= zip.cd_entries.length() {
fail("Invalid file index")
}
let entry : CentralDirectoryFileHeader = zip.cd_entries[index]
let (actual_compressed_size, actual_uncompressed_size) =
if entry.compressed_size == 0xFFFFFFFF || entry.uncompressed_size == 0xFFFFFFFF {
match parse_zip64_extra_info(entry.extra_field) {
Some((uncomp, comp, _)) => (comp, uncomp)
None => fail("ZIP64 sizes not found in extra field")
}
} else {
(entry.compressed_size, entry.uncompressed_size)
}
let local_offset : Int = if entry.local_header_offset == 0xFFFFFFFF {
match parse_zip64_extra_info(entry.extra_field) {
Some((_, _, offset)) => offset.reinterpret_as_int()
None => fail("ZIP64 local header offset not found in extra field")
}
} else {
entry.local_header_offset.reinterpret_as_int()
}
match parse_local_file_header(zip.data, local_offset) {
None => fail("Cannot find local file header")
Some(h) => {
let data_start : Int = local_offset +
30 +
h.filename_length.to_int() +
h.extra_field_length.to_int()
let local_compressed_size : UInt = if h.compressed_size == 0 ||
h.compressed_size == 0xFFFFFFFF {
match parse_zip64_extra_info(h.extra_field) {
Some((_, comp, _)) => comp
None => actual_compressed_size
}
} else {
h.compressed_size
}
let compressed_data_view = zip.data.get_view(
start=data_start,
end=data_start + local_compressed_size.reinterpret_as_int(),
)
match compressed_data_view {
None => fail("Cannot read compressed data")
Some(cdv) => {
let compressed_data : Bytes = cdv.to_owned()
match h.compression_method {
CompressionMethod::Stored => (entry.filename, compressed_data)
CompressionMethod::Deflated =>
(entry.filename, inflate(compressed_data, actual_uncompressed_size.reinterpret_as_int()))
CompressionMethod::Unsupported(m) =>
fail("Unsupported compression method: " + m.to_int().to_string())
}
}
}
}
}
}
///|
/// ## List Files in ZIP
///
/// Returns a list of all filenames in the ZIP archive.
///
/// # Parameters
/// - `zip`: The open ZIP file handle
///
/// # Returns
/// An array of filenames in the order they appear in the archive
///
/// # Example
/// ```moonbit nocheck
/// let files = zip_file_list(zip)
/// for filename in files.iter() {
/// println(filename)
/// }
/// ```
pub fn zip_file_list(zip : ZipFile) -> Array[String] {
let len : Int = zip.cd_entries.length()
let names : Array[String] = Array::make(len, "")
for i in 0.. Bytes raise {
let br : BitReader = BitReader::new(data)
let result : Array[Byte] = Array::make(uncompressed_size, 0b0)
let mut pos : Int = 0
while br.bits > 0 || br.pos < br.data.length() {
match inflate_block(br, result, pos) {
(Continue, new_pos) => pos = new_pos
(Done, new_pos) => {
pos = new_pos
break
}
(Error(msg), _) => fail("inflate error: " + msg)
}
}
Bytes::from_array(result[0:pos])
}
///|
/// ## Unzip All Files
///
/// Extracts all files from a ZIP archive at once.
///
/// # Parameters
/// - `data`: The raw bytes of the ZIP file
///
/// # Returns
/// An array of tuples `(filename, data)` for all extracted files
///
/// # Throws
/// - If the ZIP file is invalid or corrupted
/// - If decompression fails for any file
///
/// # Example
/// ```moonbit nocheck
/// let extracted = unzip_file(zip_data)
/// for entry in extracted.iter() {
/// let (filename, data) = entry
/// println("Extracted: " + filename)
/// }
/// ```
pub fn unzip_file(data : Bytes) -> Array[(String, Bytes)] raise {
let start = @env.now()
let zip : ZipFile = zip_file_open(data)
let open_end = @env.now()
let len : Int = zip.cd_entries.length()
let result : Array[(String, Bytes)] = Array::make(len, ("", Bytes::new(0)))
for i in 0.. Array[(String, Bytes)] {
let start_total = @env.now()
let data = @fs.read_file(path).binary()
let read_end = @env.now()
let files = unzip_file(data)
let unzip_end = @env.now()
@log.timing("[TIMING] read_file: \{(read_end - start_total).to_int()}ns")
@log.timing("[TIMING] unzip_file: \{(unzip_end - read_end).to_int()}ns")
match output_dir {
Some(dir) => {
let exists = @fs.exists(dir)
if exists {
let kind = @fs.kind(dir)
match kind {
@fs.FileKind::Directory => ()
_ => fail("Output path is not a directory: " + dir)
}
} else {
@fs.mkdir(dir, permission=0o755, recursive=true)
}
for entry in files.iter() {
let (filename, file_data) = entry
let output_path = dir + "/" + filename
if filename.has_suffix("/") {
@fs.mkdir(output_path, permission=0o755, recursive=true) catch {
_ => ()
}
} else {
let last_slash = find_last_slash(filename)
if last_slash >= 0 {
let sub_dir = dir + "/" + filename[0:last_slash].to_owned()
@fs.mkdir(sub_dir, permission=0o755, recursive=true) catch {
_ => ()
}
}
@fs.write_file(output_path, file_data, create_mode=CreateOrTruncate) catch {
_ => ()
}
}
}
files
}
None => files
}
}
///|
fn find_last_slash(s : String) -> Int {
let bytes = @utf8.encode(s)
let mut last = -1
let mut i = 0
while i < bytes.length() {
if bytes[i].to_uint() == 0x2F {
last = i
}
i = i + 1
}
last
}