Skip to content

Validation

Archive.open validates the entire index before returning. Every later lookup and read therefore works from data that has already been bounds checked, which is what lets read skip re-validating on the hot path.

Contents are not read by open. A corrupt data region surfaces from read, verify, or unpack as HashMismatch, never from opening the archive.

  1. Magic and version. The first four bytes must be ZPAK, the next u32 must be 1.
  2. Entry count against file size. A count is rejected if the file is too small to hold that many minimum-sized records. This runs before any allocation, so a header claiming four billion entries cannot make zpack allocate for four billion entries.
  3. Each entry record, in order - path length, path bytes, path validity, then the five numeric fields and the method byte.
  4. Every offset and size, once the data region’s start is known.
  5. Path conflicts, across the whole set.
Error Cause
BadMagic The file does not start with ZPAK - including a file shorter than four bytes
UnsupportedVersion The version field is not 1
UnsupportedMethod An entry’s method byte is neither 0 nor 1
CorruptArchive Truncated index; an entry_count larger than the file could hold; an offset that lands inside the index or past EOF; an offset + stored_size that overflows u64; a size larger than stored_size could ever inflate to; or a stored entry whose stored_size differs from its size
InvalidPath Empty; empty segment; a . or .. segment; a \, :, or NUL byte anywhere; or not valid UTF-8
PathTooLong Longer than 65535 bytes
DuplicatePath The same path appears twice in the index
PathConflict One entry’s path is a directory prefix of another’s, so both cannot exist on a filesystem at once
AssetIdCollision Two different paths hash to the same asset id

Full context for each, including which library calls produce it, is in the error reference.

The index is allocated up front, sized by entry_count. A corrupt or hostile header could otherwise name a count that costs gigabytes to allocate. Comparing the count against the smallest index that many entries could occupy bounds the allocation by the file’s actual size.

An offset below the data region’s start would let an entry read the index as if it were file contents. An offset past EOF would read nothing at all. Both are rejected, and offset + stored_size is checked for overflow rather than allowed to wrap into a small number.

This is the decompression-bomb bound. stored_size is already known to fit inside the file, so size is checked against the most that many bytes could possibly inflate to:

/// Densest possible deflate encoding: a 258 byte match in two bits.
pub const max_deflate_ratio: u64 = 1032;

That ratio is deflate’s theoretical ceiling - no honest entry reaches it, so a larger declared size means the index is lying. Once size is bounded, every later use of it is bounded too: a buffer length, an allocation, a running total.

Multiplication saturates rather than wrapping, so an absurd stored_size yields a huge ceiling rather than a small one.

Stored entries must not lie about their length

Section titled “Stored entries must not lie about their length”

A store entry is written verbatim, so stored_size and size describe the same bytes. A mismatch means the index is lying about how many bytes to read back, and is rejected as CorruptArchive.

An index holding both data and data/config.json describes something a filesystem cannot represent - creating either makes the other impossible. Such an archive cannot be extracted at all.

Catching it at open keeps unpack from failing partway and leaving a half-written tree behind. The check builds the set of directories every path implies, then rejects any entry whose own path appears in it.

Two paths hashing to the same u64 would make one of them unreachable through findId. Rather than serve the wrong asset, open refuses the archive - and pack refuses to create one.

Because open rejects collisions, an id obtained from an archive is unambiguous, and find can key one hash map on ids while still serving path lookups.

Validation at read time, not just open time

Section titled “Validation at read time, not just open time”

Path validity is checked when the index is read - before a single byte is written by unpack. That ordering is what confines extraction to its destination directory:

  • Absolute paths, .. segments, and empty segments are rejected, so a path cannot climb out of the destination.
  • \ is rejected so a path cannot mean one thing on Linux and another on Windows.
  • : is rejected anywhere, not just after a drive letter. On Windows a colon names an alternate data stream, so cfg:bak.txt would write bytes into a stream on cfg - somewhere unpack never reports.
  • NUL is rejected because it truncates a path at the syscall boundary.
  • Non-UTF-8 is rejected because it cannot round-trip through a filesystem reliably.

The index states each entry’s original size up front, so reading stops there. Internally the reader is given a limit of size + 1 bytes: one past what was promised, so a stream that keeps producing is detected as a length mismatch rather than read to exhaustion.

A deflated entry that expands past its declared size fails. It is never written out and never buffered.

  • Contents. open reads only the index. Use verify for the rest.
  • Overlapping entries. Two entries may point at the same or overlapping byte ranges. Each is individually in-bounds and hash-checked on read, so an overlap is odd but not unsafe.
  • Gaps. Bytes in the data region that no entry references are ignored.
  • Authenticity. See the security model.