///|
/// Check if a ZIP entry path is unsafe (path traversal or absolute path)
fn is_unsafe_path(path : String) -> Bool {
// Reject absolute paths
if path.length() > 0 && (path[0] == '/' || path[0] == '\\') {
return true
}
// Reject Windows absolute paths like "C:\..."
if path.length() >= 3 &&
((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) &&
path[1] == ':' &&
(path[2] == '/' || path[2] == '\\') {
return true
}
// Check for path traversal components ("../", "..\")
let l = path.length()
let mut i = 0
while i < l {
// Check if we are at the start of a path component
let at_start = i == 0 ||
(i > 0 && (path[i - 1] == '/' || path[i - 1] == '\\'))
if at_start && i + 1 < l && path[i] == '.' && path[i + 1] == '.' {
// ".." at end of string, or followed by separator
if i + 2 >= l || path[i + 2] == '/' || path[i + 2] == '\\' {
return true
}
}
i += 1
}
false
}
///|
/// Validate a local file header and return the offset of the entry's data.
///
/// Replaces the old `slzh`. The previous helper only added 30 + filename +
/// extra to the local offset without validating the local header signature,
/// the general-purpose bit flag, or that the data range stayed inside the
/// archive. This helper performs all of those checks before returning.
///
/// Specifically:
/// * `local_offset >= 0` and `local_offset + 30 <= data.length()` so the
/// fixed-size header is fully present.
/// * The 4-byte signature equals `zip_local_signature`.
/// * General-purpose bit 0 (encryption) is not set — fzip does not implement
/// ZIP encryption.
/// * General-purpose bit 3 (data descriptor) is accepted. The sync reader
/// already has the authoritative compressed size, uncompressed size, and CRC
/// from the central directory, so it can locate and verify the entry data
/// without scanning the post-data descriptor.
/// * Filename and extra lengths fit inside `data`.
/// * `data_offset + compressed_size <= data.length()`, so slicing or
/// inflating the entry's bytes never reads past the archive.
fn local_data_offset(
data : FixedArray[Byte],
local_offset : Int,
compressed_size : Int,
) -> Int raise FzipError {
// Use subtraction-form for the upper bound: an additive form
// (`local_offset + 30 > data.length()`) wraps to a negative Int when
// `local_offset` is near `Int` max — for example, a malformed central
// directory whose 32-bit local header offset is `0x7FFFFFFF` reinterprets
// to `2^31 - 1`. The wrap silently bypasses the check and lets the parser
// index past the archive end.
if local_offset < 0 || local_offset > data.length() - 30 {
raise fzip_err(InvalidZipData, msg="local file header out of bounds")
}
if b4(data, local_offset) != zip_local_signature {
raise fzip_err(InvalidZipData, msg="invalid local file header signature")
}
let flag = b2(data, local_offset + 6)
if (flag & 1) != 0 {
raise fzip_err(
InvalidZipData,
msg="encrypted ZIP entries are not supported",
)
}
let fnl = b2(data, local_offset + 26)
let exl = b2(data, local_offset + 28)
if exl > max_extra_field_length {
raise fzip_err(
ExtraFieldTooLong,
msg="local-header extra field length exceeds maximum",
)
}
// `fnl` and `exl` are 16-bit fields, so this sum cannot overflow `Int`.
// Keep the hot classic-ZIP path in `Int`; use subtraction-form checks to
// avoid `local_offset + header_len` overflow on malformed offsets.
let header_len = 30 + fnl + exl
if local_offset > data.length() - header_len {
raise fzip_err(
InvalidZipData,
msg="local header filename/extra exceed bounds",
)
}
let data_off = local_offset + header_len
if compressed_size < 0 {
raise fzip_err(InvalidZipData, msg="negative compressed size")
}
if data_off > data.length() - compressed_size {
raise fzip_err(InvalidZipData, msg="entry data range exceeds bounds")
}
data_off
}
///|
/// Resolved central directory entry. Replaces the old positional 6-tuple
/// returned by `zh` with named fields so callers can't accidentally swap
/// `compressed_size` and `uncompressed_size` (or `local_offset` and either
/// of the sizes — they're all `Int`).
priv struct ZipEntryHeader {
compression : Int
crc : UInt
compressed_size : Int
uncompressed_size : Int
name : String
next_offset : Int
local_offset : Int
}
///|
/// Parse one central directory file header into a `ZipEntryHeader`.
///
/// Differences from the previous `zh`/`z64e` pair:
/// * Validates the central directory header signature (`zip_cd_signature`).
/// * Bounds-checks the full entry size (header + filename + extra + comment)
/// against the central directory's declared end (`cd_end`).
/// * Rejects per-entry multi-disk indicators (non-zero disk-number-start).
/// * Detects ZIP64 by checking ALL three sentinels (compressed size,
/// uncompressed size, local header offset) instead of just compressed
/// size, then resolves them via `read_zip64_entry_extra` so a sentinel
/// value never propagates as a real number.
fn read_central_directory_entry(
data : FixedArray[Byte],
offset : Int,
cd_end : Int,
zip64 : Bool,
read_crc? : Bool = false,
) -> ZipEntryHeader raise FzipError {
// Subtraction-form bounds checks. `offset + 46 > cd_end` would wrap on
// `offset >= 2^31 - 46`, which is reachable in a multi-GiB archive whose
// CD lives near the upper edge of `Int` range; silently bypassing the
// check would let the parser index past `cd_end`.
if offset < 0 || cd_end < 46 || offset > cd_end - 46 {
raise fzip_err(
InvalidZipData,
msg="central directory header exceeds CD bounds",
)
}
if b4(data, offset) != zip_cd_signature {
raise fzip_err(
InvalidZipData,
msg="invalid central directory header signature",
)
}
let flag = b2(data, offset + 8)
if (flag & 1) != 0 {
raise fzip_err(
InvalidZipData,
msg="encrypted ZIP entries are not supported",
)
}
let compression = b2(data, offset + 10)
let crc = if read_crc { b4(data, offset + 16) } else { 0U }
let comp_size_classic = b4(data, offset + 20)
let uncomp_size_classic = b4(data, offset + 24)
let filename_len = b2(data, offset + 28)
let extra_len = b2(data, offset + 30)
let comment_len = b2(data, offset + 32)
let disk_number_start = b2(data, offset + 34)
let local_offset_classic = b4(data, offset + 42)
if filename_len > max_filename_length {
raise fzip_err(InvalidZipData, msg="zip filename too long")
}
if extra_len > max_extra_field_length {
raise fzip_err(
ExtraFieldTooLong,
msg="central-directory extra field length exceeds maximum",
)
}
if disk_number_start != 0 {
raise fzip_err(
InvalidZipData,
msg="multi-disk ZIP archives are not supported",
)
}
// The three variable-length fields are 16-bit values, so `entry_total`
// cannot overflow `Int`. Check with subtraction-form before adding to the
// untrusted `offset`.
let entry_total = 46 + filename_len + extra_len + comment_len
if offset > cd_end - entry_total {
raise fzip_err(
InvalidZipData,
msg="central directory entry exceeds CD bounds",
)
}
let is_utf8 = (flag & 2048) != 0
let name = str_from_u8(
data,
latin1=!is_utf8,
offset=offset + 46,
len=filename_len,
)
let need_uncomp = uncomp_size_classic == zip_uint32_max
let need_comp = comp_size_classic == zip_uint32_max
let need_local = local_offset_classic == zip_uint32_max
let any_zip64 = need_uncomp || need_comp || need_local
let (compressed_size, uncompressed_size, local_offset) = if any_zip64 {
if !zip64 {
// The archive's EOCD said this isn't a ZIP64 archive, but this entry
// uses ZIP64 sentinels. Without the ZIP64 EOCD/locator we cannot
// safely resolve them, so reject rather than treat sentinels as real
// sizes.
raise fzip_err(
InvalidZipData,
msg="entry uses ZIP64 sentinels but archive is not ZIP64",
)
}
let extra_offset = offset + 46 + filename_len
let sizes = read_zip64_entry_extra(
data,
extra_offset,
extra_len,
classic_compressed=comp_size_classic,
classic_uncompressed=uncomp_size_classic,
classic_local_offset=local_offset_classic,
)
(sizes.compressed, sizes.uncompressed, sizes.local_offset)
} else {
(
zip32_to_int(comp_size_classic, "classic entry compressed size"),
zip32_to_int(uncomp_size_classic, "classic entry uncompressed size"),
zip32_to_int(local_offset_classic, "classic entry local header offset"),
)
}
{
compression,
crc,
compressed_size,
uncompressed_size,
name,
next_offset: offset + entry_total,
local_offset,
}
}
///|
/// Validate a value that will be written to a 16-bit ZIP metadata field.
fn validate_zip_u16(value : Int, context : String) -> Unit raise FzipError {
if value < 0 || value > zip_uint16_max {
raise fzip_err(InvalidZipData, msg=context + " exceeds 16-bit ZIP field")
}
}
///|
/// Calculate and validate total ZIP extra-field length.
///
/// The total is accumulated in `Int64` and capped as it grows, so many small
/// caller-provided fields cannot wrap the `Int` sum and later make `wzh` write
/// past the allocated output buffer.
fn exfl(
ex : Array[(Int, FixedArray[Byte])],
context? : String = "ZIP extra fields",
) -> Int raise FzipError {
let mut total = 0L
let max_extra = max_extra_field_length.to_int64()
for i in 0.. zip_uint16_max {
raise fzip_err(
ExtraFieldTooLong,
msg=context + " payload exceeds 16-bit ZIP field",
)
}
total += data.length().to_int64() + 4L
if total > max_extra {
raise fzip_err(ExtraFieldTooLong, msg=context + " exceeds maximum")
}
}
total.to_int()
}
///|
/// Validate sync ZIP input size before scanning metadata.
fn validate_zip_input_len(len : Int) -> Unit raise FzipError {
if len > default_max_input_size {
raise fzip_err(
InvalidZipData,
msg="ZIP input exceeds default_max_input_size",
)
}
}
///|
/// Validate entry count before allocating result arrays or writer layout state.
fn validate_zip_entry_count(entries : Int) -> Unit raise FzipError {
if entries > max_zip_entries {
raise fzip_err(
InvalidZipData,
msg="ZIP entry count exceeds max_zip_entries",
)
}
}
///|
/// Add an entry's uncompressed size to the sync extraction budget.
fn add_zip_output_budget(total : Int, entry_size : Int) -> Int raise FzipError {
if entry_size < 0 {
raise fzip_err(InvalidZipData, msg="negative ZIP entry output size")
}
if entry_size > default_max_output_size {
raise fzip_err(
InvalidZipData,
msg="entry uncompressed size exceeds default_max_output_size",
)
}
if total > max_zip_total_output_size - entry_size {
raise fzip_err(
InvalidZipData,
msg="total ZIP output exceeds max_zip_total_output_size",
)
}
total + entry_size
}
///|
/// Validate a deflated ZIP entry's declared compression ratio without using
/// multiplication that can overflow `Int`.
fn validate_zip_compression_ratio(
compressed_size : Int,
uncompressed_size : Int,
) -> Unit raise FzipError {
if compressed_size < 0 || uncompressed_size < 0 {
raise fzip_err(InvalidZipData, msg="negative ZIP entry size")
}
if compressed_size == 0 {
if uncompressed_size > 0 {
raise fzip_err(
InvalidZipData,
msg="zip compressed size is zero for non-empty deflated entry",
)
}
return
}
let max_ratio = 1000
let limit = if compressed_size > max_int_val() / max_ratio {
max_int_val()
} else {
compressed_size * max_ratio
}
if uncompressed_size > limit {
raise fzip_err(
InvalidZipData,
msg="zip compression ratio too high (potential zip bomb)",
)
}
}
///|
/// Validate extracted ZIP entry content against the central-directory CRC-32.
fn validate_zip_crc(actual : UInt, expected : UInt) -> Unit raise FzipError {
if actual != expected {
raise fzip_err(InvalidChecksum, msg="ZIP entry CRC-32 mismatch")
}
}
///|
/// Deflate bit flag for ZIP
fn dbf(l : Int) -> Int {
if l == 1 {
3
} else if l < 6 {
2
} else if l == 9 {
1
} else {
0
}
}
///|
/// Write ZIP file header (local or central directory)
///
/// `cd_local_offset` decides which header layout to emit:
/// * `None` writes a local file header (no central-only fields).
/// * `Some(off)` writes a central directory entry; `off` is the value to
/// stamp in the central directory's "relative offset of local header"
/// field. PR5c writes `0xffffffffU` here when the entry's local-header
/// offset has been promoted to ZIP64 (the real offset lives in the entry's
/// ZIP64 extra payload).
///
/// `c_size` and `u_size` are pre-computed `UInt` values: callers pass
/// `0xffffffffU` when the matching field has been promoted to a ZIP64
/// sentinel and the real value lives in the entry's ZIP64 extra; otherwise
/// the actual classic-range size.
///
/// `version_needed` and `version_made_by_low` are the low ZIP-spec bytes of
/// the matching APPNOTE §4.4.2 / §4.4.3.2 fields. PR5b stamps them at the
/// classic baseline of 20; PR5c flips them to 45 for entries that carry
/// ZIP64 sentinels or a ZIP64 extra field, while still preserving `os` in
/// the high byte of `version made by`.
fn wzh(
d : FixedArray[Byte],
b : Int,
fn_data : FixedArray[Byte],
is_utf8 : Bool,
compression : Int,
flag : Int,
crc_val : Int,
c_size : UInt,
u_size : UInt,
mtime : Int,
os : Int,
attrs : Int,
extra : Array[(Int, FixedArray[Byte])],
cd_local_offset : UInt?,
comment : FixedArray[Byte]?,
version_needed? : Int = 20,
version_made_by_low? : Int = 20,
) -> Int raise FzipError {
let fl = fn_data.length()
let exl = exfl(extra)
let col = match comment {
Some(c) => c.length()
None => 0
}
let mut b = b
let is_central = cd_local_offset is Some(_)
// signature
if is_central {
w4(d, b, zip_cd_signature)
} else {
w4(d, b, zip_local_signature)
}
b += 4
if is_central {
d[b] = version_made_by_low.to_byte() // ZIP-spec byte
b += 1
d[b] = os.to_byte() // host OS
b += 1
}
w2(d, b, version_needed)
b += 2
// flags
d[b] = (flag << 1).to_byte()
d[b + 1] = if is_utf8 { b'\x08' } else { b'\x00' }
b += 2
// compression method
w2(d, b, compression)
b += 2
// mtime: caller supplies the raw 4-byte value. The Unix-seconds-to-DOS
// conversion is a separate pre-existing bug tracked outside the ZIP64
// work; this writer only guarantees the field width is invariant.
w4(d, b, mtime.reinterpret_as_uint())
b += 4
// CRC-32
w4(d, b, crc_val.reinterpret_as_uint())
b += 4
// compressed size (sentinel `0xffffffffU` when promoted to ZIP64)
w4(d, b, c_size)
b += 4
// uncompressed size (sentinel `0xffffffffU` when promoted to ZIP64)
w4(d, b, u_size)
b += 4
// filename length
w2(d, b, fl)
b += 2
// extra field length
w2(d, b, exl)
b += 2
if is_central {
w2(d, b, col) // comment length
b += 2
// disk number start (2) + internal attrs (2): zero
b += 4
w4(d, b, attrs.reinterpret_as_uint()) // external attrs
b += 4
match cd_local_offset {
Some(off) => w4(d, b, off)
None => ()
}
b += 4
}
// filename
fn_data.blit_to(d, len=fl, src_offset=0, dst_offset=b)
b += fl
// extra fields
if exl > 0 {
for i in 0.. {
co.blit_to(d, len=col, src_offset=0, dst_offset=b)
b += col
}
None => ()
}
b
}
///|
/// Write the classic end-of-central-directory record.
///
/// The four 16/32-bit overflow fields (entries on disk + total entries +
/// central directory size + central directory offset) are independently
/// promoted to their respective 0xffff / 0xffffffff sentinels when the
/// real value crosses the classic limit OR when `force_all_sentinels` is
/// true (test-only forced ZIP64). When any field is sentinel-promoted the
/// archive must already include a ZIP64 EOCD record + locator immediately
/// before this classic EOCD; the writer caller is responsible for that
/// ordering.
fn wzf(
o : FixedArray[Byte],
b : Int,
entries : Int64,
cd_size : Int64,
cd_offset : Int64,
force_all_sentinels : Bool,
) -> Unit raise FzipError {
w4(o, b, zip_eocd_signature)
// disk numbers (4 bytes = 0)
let entry_field = if force_all_sentinels ||
entries >= zip_uint16_max.to_int64() {
zip_uint16_max
} else {
entries.to_int()
}
w2(o, b + 8, entry_field) // entries on this disk
w2(o, b + 10, entry_field) // total entries
let cd_size_field = if force_all_sentinels ||
cd_size >= zip_uint32_max.to_int64() {
zip_uint32_max
} else {
let v = zip64_to_int(cd_size, "central directory size")
v.reinterpret_as_uint()
}
w4(o, b + 12, cd_size_field) // central directory size
let cd_offset_field = if force_all_sentinels ||
cd_offset >= zip_uint32_max.to_int64() {
zip_uint32_max
} else {
let v = zip64_to_int(cd_offset, "central directory offset")
v.reinterpret_as_uint()
}
w4(o, b + 16, cd_offset_field) // offset of central directory
// comment length (2 bytes = 0)
}
///|
/// Knobs that change `compute_zip_layout` / `build_zip_sync` behavior
/// independently of the user-facing `ZipEntryOptions`. Today only
/// `force_zip64` is exposed; tests use it to drive the ZIP64 emission
/// paths with small inputs, since real archives never hit the 4 GiB / 64 K
/// thresholds inside the sync API's `Int`-bounded budget.
priv struct ZipBuildOpts {
force_zip64 : Bool
}
///|
/// Default writer options: classic emission, ZIP64 only when sizes/offsets
/// or the entry count actually overflow.
fn ZipBuildOpts::default() -> ZipBuildOpts {
{ force_zip64: false }
}
///|
/// Pass-A intermediate result: per-entry compressed bytes + CRC + the
/// `zip64_local` decision (sizes-only, fixed once compression is done).
/// `zip64_cd` flags depend on the cumulative local-header offset and are
/// resolved in pass B inside `compute_zip_layout`.
priv struct ZipEntryComputed {
fn_data : FixedArray[Byte]
compressed : FixedArray[Byte]
is_utf8 : Bool
crc_val : Int
compressed_size : Int64
uncompressed_size : Int64
zip64_local : Bool
}
///|
/// Per-entry layout decision computed during the writer's two passes.
///
/// `zip64_local` is true when the LOCAL file header carries a ZIP64 extra
/// (16-byte payload with both 8-byte size values, never an offset).
///
/// `cd_overflow_*` are the per-field overflow flags for the CENTRAL
/// directory entry. Each flag independently controls whether the
/// matching classic field is written as the 32-bit sentinel and whether
/// its 8-byte value is appended to the central-directory ZIP64 extra
/// payload. `zip64_cd` (= any of the three) is computed on demand in
/// the build phase.
priv struct ZipEntryLayout {
fn_data : FixedArray[Byte]
compressed : FixedArray[Byte]
is_utf8 : Bool
crc_val : Int
compressed_size : Int64
uncompressed_size : Int64
local_offset : Int64
zip64_local : Bool
cd_overflow_uncompressed : Bool
cd_overflow_compressed : Bool
cd_overflow_local_offset : Bool
}
///|
/// Aggregate layout for a whole archive. All offsets and sizes are
/// `Int64` so the writer can detect ZIP64 thresholds without hitting `Int`
/// overflow during arithmetic. Each `Int64` value gets range-checked
/// before it is converted back for `FixedArray` indexing.
///
/// `archive_zip64` is true when the writer must emit a ZIP64 EOCD record
/// and locator before the classic EOCD: any per-entry promotion, any
/// archive-level field overflow, or `build_opts.force_zip64`.
///
/// `sanitized_extra` is the user's `opts.extra` with reserved-id `0x0001`
/// (ZIP64 extended information) entries removed — fzip owns that id.
priv struct ZipLayout {
entries : Array[ZipEntryLayout]
cd_offset : Int64
cd_size : Int64
total_size : Int64
archive_zip64 : Bool
sanitized_extra : Array[(Int, FixedArray[Byte])]
}
///|
/// Drop user-provided extra fields whose header id is reserved by fzip
/// for its own ZIP64 metadata (`0x0001`). PR5b documents the policy and
/// removes any conflicting entries; PR5c emits a real ZIP64 extra field
/// for entries that need one. Other extra fields pass through.
fn sanitize_user_extra(
extra : Array[(Int, FixedArray[Byte])],
) -> Array[(Int, FixedArray[Byte])] raise FzipError {
let result : Array[(Int, FixedArray[Byte])] = []
for kv in extra {
let (id, data) = kv
validate_zip_u16(id, "ZIP extra field id")
if data.length() > zip_uint16_max {
raise fzip_err(
ExtraFieldTooLong,
msg="ZIP extra field payload exceeds 16-bit ZIP field",
)
}
if id != zip64_extra_field_id {
result.push(kv)
}
}
result
}
///|
/// Two-pass layout calculation. Pass A compresses each entry, computes
/// CRCs, and decides per-entry `zip64_local` from sizes alone. Pass B
/// walks the computed entries to assign cumulative local offsets,
/// resolve per-field central-directory overflow flags, validate the
/// per-header extra-field length, and accumulate the total archive size.
///
/// Convergence note: `zip64_local` depends only on per-entry sizes (fixed
/// after compression), so pass A's local-header sizes are final on first
/// computation. `zip64_cd` flags depend on the (already final) local
/// offsets and entry sizes, so pass B is also one-shot. There is no
/// fixpoint iteration because adding a ZIP64 extra in the central
/// directory does not shift any local-header offset.
fn compute_zip_layout(
files : Array[(String, FixedArray[Byte])],
opts : ZipEntryOptions,
build_opts : ZipBuildOpts,
) -> ZipLayout raise FzipError {
let force = build_opts.force_zip64
let sanitized_extra = sanitize_user_extra(opts.extra)
let comment_len_per_entry = if opts.comment.length() > 0 {
str_to_u8(opts.comment).length()
} else {
0
}
validate_zip_u16(comment_len_per_entry, "ZIP entry comment length")
let user_extra_len = exfl(sanitized_extra)
// Pass A: compress entries; decide zip64_local from sizes alone.
let computed : Array[ZipEntryComputed] = []
validate_zip_entry_count(files.length())
for i in 0.. max_filename_length {
raise fzip_err(
FilenameTooLong,
msg="ZIP filename exceeds max_filename_length",
)
}
if fn_data.length() > zip_uint16_max {
raise fzip_err(
FilenameTooLong,
msg="ZIP filename exceeds 16-bit ZIP field",
)
}
let crc_c = CRC32State::new()
let (compressed, c_len) = if compression != 0 {
dopt(
file,
{ level: opts.level, mem: opts.mem, dictionary: None },
0,
0,
None,
crc_state=Some(crc_c),
)
} else {
crc_c.push(file)
(file, file.length())
}
let crc_val = crc_c.digest().reinterpret_as_int()
let is_utf8 = fn_data.length() != fn_str.length()
let compressed_size = c_len.to_int64()
let uncompressed_size = file.length().to_int64()
let zip64_local = force ||
compressed_size >= zip_uint32_max.to_int64() ||
uncompressed_size >= zip_uint32_max.to_int64()
computed.push({
fn_data,
compressed,
is_utf8,
crc_val,
compressed_size,
uncompressed_size,
zip64_local,
})
}
// Pass B: assign local offsets + cd overflow flags, accumulate sizes.
let entries : Array[ZipEntryLayout] = []
let mut local_offset = 0L
let mut cd_size = 0L
let mut any_entry_zip64 = false
let max_extra_64 = max_extra_field_length.to_int64()
let user_extra_len_64 = user_extra_len.to_int64()
let comment_len_per_entry_64 = comment_len_per_entry.to_int64()
for c in computed {
let cd_overflow_uncompressed = force ||
c.uncompressed_size >= zip_uint32_max.to_int64()
let cd_overflow_compressed = force ||
c.compressed_size >= zip_uint32_max.to_int64()
let cd_overflow_local_offset = force ||
local_offset >= zip_uint32_max.to_int64()
let zip64_cd = cd_overflow_uncompressed ||
cd_overflow_compressed ||
cd_overflow_local_offset
if c.zip64_local || zip64_cd {
any_entry_zip64 = true
}
// Local-header ZIP64 extra: 4-byte field header + 16-byte payload.
let local_zip64_bytes = if c.zip64_local { 20L } else { 0L }
// Central-header ZIP64 extra: 4-byte header + 8 bytes per overflowing field.
let cd_zip64_bytes = if zip64_cd {
let mut total = 4L
if cd_overflow_uncompressed {
total += 8L
}
if cd_overflow_compressed {
total += 8L
}
if cd_overflow_local_offset {
total += 8L
}
total
} else {
0L
}
let total_extra_local = user_extra_len_64 + local_zip64_bytes
if total_extra_local > max_extra_64 {
raise fzip_err(
ExtraFieldTooLong,
msg="local-header extra fields exceed maximum",
)
}
let total_extra_cd = user_extra_len_64 + cd_zip64_bytes
if total_extra_cd > max_extra_64 {
raise fzip_err(
ExtraFieldTooLong,
msg="central-directory extra fields exceed maximum",
)
}
entries.push({
fn_data: c.fn_data,
compressed: c.compressed,
is_utf8: c.is_utf8,
crc_val: c.crc_val,
compressed_size: c.compressed_size,
uncompressed_size: c.uncompressed_size,
local_offset,
zip64_local: c.zip64_local,
cd_overflow_uncompressed,
cd_overflow_compressed,
cd_overflow_local_offset,
})
let local_total = 30L +
c.fn_data.length().to_int64() +
total_extra_local +
c.compressed_size
let cd_total = 46L +
c.fn_data.length().to_int64() +
total_extra_cd +
comment_len_per_entry_64
local_offset = local_offset + local_total
cd_size = cd_size + cd_total
}
let cd_offset = local_offset
let archive_zip64 = force ||
any_entry_zip64 ||
entries.length() >= zip_uint16_max ||
cd_size >= zip_uint32_max.to_int64() ||
cd_offset >= zip_uint32_max.to_int64()
// ZIP64 EOCD record (56) + ZIP64 EOCD locator (20) when promoted.
let zip64_section = if archive_zip64 { 76L } else { 0L }
let total_size = cd_offset + cd_size + zip64_section + 22L
// Validate the final archive size fits the sync API's `Int` budget.
let _ = zip64_to_int(total_size, "archive total size")
{ entries, cd_offset, cd_size, total_size, archive_zip64, sanitized_extra }
}
///|
/// Build a complete ZIP archive byte buffer. Shared raising builder used by
/// both the non-raising `zip_sync` (which traps on raise) and the public
/// raising `zip_sync_checked`. Emits ZIP64 sentinels, ZIP64 extra fields,
/// and ZIP64 EOCD record + locator whenever `compute_zip_layout` decides
/// the archive needs them.
fn build_zip_sync(
files : Array[(String, FixedArray[Byte])],
opts : ZipEntryOptions,
build_opts : ZipBuildOpts,
) -> FixedArray[Byte] raise FzipError {
let layout = compute_zip_layout(files, opts, build_opts)
let total_int = zip64_to_int(layout.total_size, "archive total size")
let out = FixedArray::make(total_int, b'\x00')
let comment_bytes : FixedArray[Byte]? = if opts.comment.length() > 0 {
Some(str_to_u8(opts.comment))
} else {
None
}
let flag = dbf(opts.level)
let compression = if opts.level == 0 { 0 } else { 8 }
// Local headers + compressed data
for entry in layout.entries {
let lo = entry.local_offset.to_int()
let local_extras : Array[(Int, FixedArray[Byte])] = []
for kv in layout.sanitized_extra {
local_extras.push(kv)
}
if entry.zip64_local {
let payload = build_zip64_extra_local_payload(
entry.uncompressed_size,
entry.compressed_size,
)
local_extras.push((zip64_extra_field_id, payload))
}
let c_size_field = if entry.zip64_local {
zip_uint32_max
} else {
entry.compressed_size.to_int().reinterpret_as_uint()
}
let u_size_field = if entry.zip64_local {
zip_uint32_max
} else {
entry.uncompressed_size.to_int().reinterpret_as_uint()
}
let version_needed = if entry.zip64_local { 45 } else { 20 }
let end_local = wzh(
out,
lo,
entry.fn_data,
entry.is_utf8,
compression,
flag,
entry.crc_val,
c_size_field,
u_size_field,
opts.mtime,
opts.os,
opts.attrs,
local_extras,
None, // local file header
None, // no comment in local header
version_needed~,
)
entry.compressed.blit_to(
out,
len=entry.compressed_size.to_int(),
src_offset=0,
dst_offset=end_local,
)
}
// Central directory entries
let mut cd_off = layout.cd_offset.to_int()
for entry in layout.entries {
let zip64_cd = entry.cd_overflow_uncompressed ||
entry.cd_overflow_compressed ||
entry.cd_overflow_local_offset
let cd_extras : Array[(Int, FixedArray[Byte])] = []
for kv in layout.sanitized_extra {
cd_extras.push(kv)
}
if zip64_cd {
let u = if entry.cd_overflow_uncompressed {
Some(entry.uncompressed_size)
} else {
None
}
let c = if entry.cd_overflow_compressed {
Some(entry.compressed_size)
} else {
None
}
let lo_v = if entry.cd_overflow_local_offset {
Some(entry.local_offset)
} else {
None
}
let payload = build_zip64_extra_cd_payload(u, c, lo_v)
cd_extras.push((zip64_extra_field_id, payload))
}
let c_size_field = if entry.cd_overflow_compressed {
zip_uint32_max
} else {
entry.compressed_size.to_int().reinterpret_as_uint()
}
let u_size_field = if entry.cd_overflow_uncompressed {
zip_uint32_max
} else {
entry.uncompressed_size.to_int().reinterpret_as_uint()
}
let local_offset_field = if entry.cd_overflow_local_offset {
zip_uint32_max
} else {
entry.local_offset.to_int().reinterpret_as_uint()
}
// version_needed: 45 if the entry's local OR central header carries
// ZIP64 metadata. version_made_by low byte: 45 only when this entry
// itself is ZIP64 in the central directory; otherwise classic 20 with
// opts.os in the high byte.
let version_needed = if entry.zip64_local || zip64_cd { 45 } else { 20 }
let version_made_by_low = if zip64_cd { 45 } else { 20 }
cd_off = wzh(
out,
cd_off,
entry.fn_data,
entry.is_utf8,
compression,
flag,
entry.crc_val,
c_size_field,
u_size_field,
opts.mtime,
opts.os,
opts.attrs,
cd_extras,
Some(local_offset_field),
comment_bytes,
version_needed~,
version_made_by_low~,
)
}
// ZIP64 EOCD record + locator (only when the archive needs ZIP64 metadata)
if layout.archive_zip64 {
let zip64_eocd_pos = cd_off
write_zip64_eocd_record(
out,
zip64_eocd_pos,
layout.entries.length().to_int64(),
layout.cd_size,
layout.cd_offset,
)
let locator_pos = zip64_eocd_pos + 56
write_zip64_locator(out, locator_pos, zip64_eocd_pos.to_int64())
cd_off = locator_pos + 20
}
// Classic EOCD (always last). When the archive is ZIP64, the writer
// sentinel-promotes overflowing entry-count / cd-size / cd-offset
// fields so a classic-only reader can locate the record while a ZIP64
// reader follows the locator above.
wzf(
out,
cd_off,
layout.entries.length().to_int64(),
layout.cd_size,
layout.cd_offset,
build_opts.force_zip64,
)
out
}
///|
/// Create a ZIP archive from `(filename, data)` entries.
///
/// Each entry is written with the same `ZipEntryOptions`. Entry names are
/// encoded as UTF-8 when needed, and `level = 0` stores files without DEFLATE
/// compression. The returned bytes are a complete ZIP archive containing local
/// file headers, central directory entries, and the end-of-central-directory
/// record. ZIP64 metadata (per-entry ZIP64 extras, ZIP64 EOCD record, ZIP64
/// locator) is emitted automatically when an entry crosses the classic 32-bit
/// size limit, when the archive carries 65 535 or more entries, or when the
/// central directory itself crosses the 32-bit size or offset limit.
///
/// This function preserves its existing non-raising signature for source
/// compatibility. The shared raising builder it delegates to may detect
/// metadata or layout values that exceed the sync API's `Int`/`FixedArray`
/// budget. When that happens this wrapper traps deterministically with a
/// stable abort message rather than returning a partial or corrupt archive.
/// Callers that prefer to recover from such failures should use
/// `zip_sync_checked`.
pub fn zip_sync(
files : Array[(String, FixedArray[Byte])],
opts? : ZipEntryOptions = ZipEntryOptions::default(),
) -> FixedArray[Byte] {
build_zip_sync(files, opts, ZipBuildOpts::default()) catch {
FzipError(message~, ..) =>
abort(
"fzip.zip_sync failed; use zip_sync_checked for recoverable errors: " +
message,
)
}
}
///|
/// Create a ZIP archive from `(filename, data)` entries with recoverable
/// failure semantics.
///
/// Behaves exactly like `zip_sync` but raises `FzipError` instead of trapping
/// when the writer encounters a value or layout that cannot be represented in
/// the current sync API, or when caller-provided ZIP metadata cannot be encoded
/// safely. Common failure modes include:
///
/// * `Zip64ValueTooLarge` — a metadata count, size, offset, or the final
/// archive size cannot fit in MoonBit's 32-bit signed `Int` or be safely
/// indexed in a `FixedArray`.
/// * `ExtraFieldTooLong` — the user's `opts.extra` plus the writer-generated
/// ZIP64 extra would exceed `max_extra_field_length` for a local or central
/// directory entry.
/// * `FilenameTooLong` / `InvalidZipData` — filename, comment, or extra-field
/// metadata cannot fit in the ZIP format's fixed-width fields.
///
/// Both APIs share the same underlying builder, so any successful build
/// produces byte-identical output.
pub fn zip_sync_checked(
files : Array[(String, FixedArray[Byte])],
opts? : ZipEntryOptions = ZipEntryOptions::default(),
) -> FixedArray[Byte] raise FzipError {
build_zip_sync(files, opts, ZipBuildOpts::default())
}
///|
/// Information collected from a ZIP archive's central directory.
///
/// Phase 1 stores values as `Int`; structurally valid ZIP64 metadata that
/// cannot be safely indexed by the current sync API is rejected at parse
/// time with `Zip64ValueTooLarge` rather than landing here as out-of-range.
/// `entries` stays `Int` even though the spec field is 64 bits because real
/// archives never come close to `max_int_val()` entries — even at 30 bytes
/// per local header, that would be tens of GiB of metadata.
priv struct ZipCdInfo {
entries : Int
offset : Int
size : Int
zip64 : Bool
}
///|
/// Build classic central-directory metadata after all EOCD disk/sentinel
/// decisions have been made.
fn read_classic_cd_info(
data : FixedArray[Byte],
entries_disk : Int,
entries_total : Int,
cd_size_classic : UInt,
cd_offset_classic : UInt,
) -> ZipCdInfo raise FzipError {
if entries_disk != entries_total {
raise fzip_err(
InvalidZipData,
msg="multi-disk ZIP archives are not supported",
)
}
let size = zip32_to_int(cd_size_classic, "classic central directory size")
let offset = zip32_to_int(
cd_offset_classic, "classic central directory offset",
)
if offset > data.length() || size > data.length() - offset {
raise fzip_err(
InvalidZipData,
msg="central directory bounds exceed archive",
)
}
validate_zip_entry_count(entries_total)
{ entries: entries_total, size, offset, zip64: false }
}
///|
/// Parse the ZIP64 EOCD locator + record immediately before a classic EOCD.
fn read_zip64_cd_info(
data : FixedArray[Byte],
eocd : Int,
) -> ZipCdInfo raise FzipError {
if eocd < 20 || b4(data, eocd - 20) != zip64_locator_signature {
raise fzip_err(InvalidZipData, msg="ZIP64 locator missing")
}
// Locator layout:
// +0 signature (4) = 0x07064B50
// +4 disk-with-zip64-EOCD (4)
// +8 ZIP64-EOCD offset (8)
// +16 total disks (4)
let locator_off = eocd - 20
let zip64_eocd_disk = b4(data, locator_off + 4)
let total_disks = b4(data, locator_off + 16)
if zip64_eocd_disk != 0U || total_disks != 1U {
raise fzip_err(
InvalidZipData,
msg="multi-disk ZIP archives are not supported",
)
}
let zip64_eocd_off = read_zip64_int(
data,
locator_off + 8,
"zip64 EOCD offset",
)
// The ZIP64 EOCD record must precede the locator with at least 12 bytes
// for its own header (signature + size field).
if zip64_eocd_off > locator_off - 12 {
raise fzip_err(
InvalidZipData,
msg="ZIP64 EOCD record offset overlaps locator",
)
}
if b4(data, zip64_eocd_off) != zip64_eocd_signature {
raise fzip_err(InvalidZipData, msg="ZIP64 EOCD record signature missing")
}
// ZIP64 EOCD record layout:
// +0 signature (4) +4 record size (8) — bytes following
// +12 version made by (2) +14 version needed (2)
// +16 disk number (4) +20 disk-with-CD (4)
// +24 entries on this disk (8) +32 total entries (8)
// +40 CD size (8) +48 CD offset (8)
let record_size_raw = read_zip64_int(
data,
zip64_eocd_off + 4,
"ZIP64 EOCD record size",
)
if record_size_raw < 44 {
raise fzip_err(InvalidZipData, msg="ZIP64 EOCD record size too small")
}
let max_record_size = locator_off - zip64_eocd_off - 12
if record_size_raw > max_record_size {
raise fzip_err(
InvalidZipData,
msg="ZIP64 EOCD record exceeds locator boundary",
)
}
let z64_disk = b4(data, zip64_eocd_off + 16)
let z64_disk_with_cd = b4(data, zip64_eocd_off + 20)
let z64_entries_disk = read_zip64_int(
data,
zip64_eocd_off + 24,
"ZIP64 entries on disk",
)
let z64_entries_total = read_zip64_int(
data,
zip64_eocd_off + 32,
"ZIP64 total entries",
)
let z64_size = read_zip64_int(
data,
zip64_eocd_off + 40,
"ZIP64 central directory size",
)
let z64_offset = read_zip64_int(
data,
zip64_eocd_off + 48,
"ZIP64 central directory offset",
)
if z64_disk != 0U || z64_disk_with_cd != 0U {
raise fzip_err(
InvalidZipData,
msg="multi-disk ZIP archives are not supported",
)
}
if z64_entries_disk != z64_entries_total {
raise fzip_err(
InvalidZipData,
msg="multi-disk ZIP archives are not supported",
)
}
// Validate central directory bounds against the archive.
if z64_offset > data.length() || z64_size > data.length() - z64_offset {
raise fzip_err(
InvalidZipData,
msg="ZIP64 central directory bounds exceed archive",
)
}
validate_zip_entry_count(z64_entries_total)
{
entries: z64_entries_total,
size: z64_size,
offset: z64_offset,
zip64: true,
}
}
///|
/// Validate that ZIP64 central-directory metadata does not contradict any
/// classic EOCD field that was still representable without a sentinel. This
/// prevents parser-differential archives where classic readers use one central
/// directory while fzip follows a valid but inconsistent ZIP64 locator.
fn validate_zip64_cd_matches_classic(
info : ZipCdInfo,
entries_disk : Int,
entries_total : Int,
cd_size_classic : UInt,
cd_offset_classic : UInt,
count_sentinel : Bool,
size_sentinel : Bool,
offset_sentinel : Bool,
) -> Unit raise FzipError {
if !count_sentinel {
if entries_disk != entries_total {
raise fzip_err(
InvalidZipData,
msg="multi-disk ZIP archives are not supported",
)
}
if info.entries != entries_total {
raise fzip_err(
InvalidZipData,
msg="ZIP64 EOCD entry count disagrees with classic EOCD",
)
}
}
if !size_sentinel {
let classic_size = zip32_to_int(
cd_size_classic, "classic central directory size",
)
if info.size != classic_size {
raise fzip_err(
InvalidZipData,
msg="ZIP64 EOCD central directory size disagrees with classic EOCD",
)
}
}
if !offset_sentinel {
let classic_offset = zip32_to_int(
cd_offset_classic, "classic central directory offset",
)
if info.offset != classic_offset {
raise fzip_err(
InvalidZipData,
msg="ZIP64 EOCD central directory offset disagrees with classic EOCD",
)
}
}
}
///|
/// Locate the End-Of-Central-Directory record by scanning back from the end
/// of the input.
///
/// The record may carry up to 65535 bytes of comment between the EOCD
/// signature and the end of the input, so the search window is
/// `22 + 65535 + 1` bytes (the `+1` is the loop sentinel from the original
/// implementation).
///
/// A matching signature is only treated as a real EOCD when its declared
/// comment length is consistent with the candidate's distance from the end
/// of `data`. Without that check, a `0x06054B50` byte sequence inside the
/// EOCD comment or earlier payload could be mistaken for the record itself.
fn find_eocd(data : FixedArray[Byte]) -> Int raise FzipError {
if data.length() < 22 {
raise fzip_err(InvalidZipData, msg="data too short for zip archive")
}
let mut e = data.length() - 22
while e >= 0 {
if b4(data, e) == zip_eocd_signature {
let comment_length = b2(data, e + 20)
if e + 22 + comment_length == data.length() {
return e
}
}
e -= 1
if data.length() - e > 65558 {
break
}
}
raise fzip_err(InvalidZipData, msg="EOCD signature not found")
}
///|
/// Parse the End-Of-Central-Directory record (and ZIP64 EOCD when present)
/// into a unified `ZipCdInfo`.
///
/// Multi-disk archives are rejected. ZIP64 metadata is parsed via
/// `read_zip64_int`, so adversarial size/offset/count values that cannot be
/// represented by the sync API are rejected with `Zip64ValueTooLarge` before
/// any allocation happens.
///
/// Compatibility note: a classic archive whose only sentinel-like value is
/// the entry-count field hitting exactly `0xffff` is treated as a regular
/// 65535-entry classic archive when no valid ZIP64 locator precedes the EOCD.
/// If any other classic field is a sentinel, ZIP64 metadata is required.
fn read_central_directory_info(
data : FixedArray[Byte],
eocd : Int,
) -> ZipCdInfo raise FzipError {
// Classic EOCD layout (offsets relative to the signature):
// +0 signature (4) +4 disk number (2)
// +6 start-disk-of-CD (2) +8 entries on this disk (2)
// +10 total entries (2) +12 CD size (4)
// +16 CD offset (4) +20 comment length (2)
let disk_num = b2(data, eocd + 4)
let cd_disk = b2(data, eocd + 6)
let entries_disk = b2(data, eocd + 8)
let entries_total = b2(data, eocd + 10)
let cd_size_classic = b4(data, eocd + 12)
let cd_offset_classic = b4(data, eocd + 16)
if disk_num != 0 || cd_disk != 0 {
raise fzip_err(
InvalidZipData,
msg="multi-disk ZIP archives are not supported",
)
}
let count_sentinel = entries_total == zip_uint16_max
let size_sentinel = cd_size_classic == zip_uint32_max
let offset_sentinel = cd_offset_classic == zip_uint32_max
let any_sentinel = count_sentinel || size_sentinel || offset_sentinel
let locator_candidate = eocd >= 20 &&
b4(data, eocd - 20) == zip64_locator_signature
if !any_sentinel && !locator_candidate {
return read_classic_cd_info(
data, entries_disk, entries_total, cd_size_classic, cd_offset_classic,
)
}
if !locator_candidate {
// Sentinel set — try to find a valid ZIP64 locator immediately before EOCD.
// Compatibility: a classic archive with exactly 65535 entries (and no
// other sentinel) is valid even without ZIP64 metadata.
if count_sentinel && !size_sentinel && !offset_sentinel {
return read_classic_cd_info(
data, entries_disk, zip_uint16_max, cd_size_classic, cd_offset_classic,
)
}
raise fzip_err(
InvalidZipData,
msg="ZIP64 sentinel set but no valid ZIP64 locator",
)
}
let z64_result : Result[ZipCdInfo, FzipError] = fzip_result(() => {
read_zip64_cd_info(data, eocd)
})
match z64_result {
Ok(info) => {
validate_zip64_cd_matches_classic(
info, entries_disk, entries_total, cd_size_classic, cd_offset_classic, count_sentinel,
size_sentinel, offset_sentinel,
)
info
}
Err(err) => {
if !any_sentinel {
// A classic central-directory comment or other trailing CD data can
// legitimately place `0x07064B50` immediately before EOCD. Treat it
// as ZIP64 only if the full locator + EOCD record validates.
return read_classic_cd_info(
data, entries_disk, entries_total, cd_size_classic, cd_offset_classic,
)
}
raise err
}
}
}
///|
/// Extract all supported files from a ZIP archive.
///
/// The result preserves archive order as `(filename, data)` pairs. fzip supports
/// stored entries (`method 0`) and deflated entries (`method 8`), rejects unsafe
/// paths such as absolute paths or `..` components, and applies decompression
/// ratio checks to reduce zip-bomb risk.
pub fn unzip_sync(
data : FixedArray[Byte],
opts? : UnzipOptions = UnzipOptions::default(),
) -> Array[(String, FixedArray[Byte])] raise FzipError {
validate_zip_input_len(data.length())
let files : Array[(String, FixedArray[Byte])] = []
let eocd = find_eocd(data)
let cd = read_central_directory_info(data, eocd)
if cd.entries == 0 {
return files
}
let cd_end = cd.offset + cd.size
let mut o = cd.offset
let mut total_output = 0
for _i in 0.. 1000x compressed size
validate_zip_compression_ratio(sc, su)
let out = FixedArray::make(su, b'\x00')
let (buf, len) = inflt(
data,
InflateState::new(2),
Some(out),
None,
default_max_input_size,
default_max_output_size,
dat_off=b_off,
dat_end=b_off + sc,
)
if len != su {
raise fzip_err(InvalidZipData, msg="inflated ZIP entry size mismatch")
}
if opts.verify_checksum {
validate_zip_crc(crc32(buf), header.crc)
}
files.push((header.name, buf))
} else {
raise fzip_err(
UnknownCompressionMethod,
msg="unknown compression type " + header.compression.to_string(),
)
}
}
if o != cd_end {
raise fzip_err(
InvalidZipData,
msg="central directory has trailing or missing bytes",
)
}
files
}
///|
/// List ZIP entries without extracting their contents.
///
/// This reads the central directory and returns entry names, compressed sizes,
/// original sizes, and compression methods. It is useful for inspecting an
/// archive before deciding whether to call `unzip_sync`.
pub fn unzip_list(
data : FixedArray[Byte],
) -> Array[UnzipFileInfo] raise FzipError {
validate_zip_input_len(data.length())
let infos : Array[UnzipFileInfo] = []
let eocd = find_eocd(data)
let cd = read_central_directory_info(data, eocd)
if cd.entries == 0 {
return infos
}
let cd_end = cd.offset + cd.size
let mut o = cd.offset
for _i in 0..