format
zpack.format holds the wire format: the constants that define it, the
functions that serialize it, and the two helpers most callers actually want -
assetId and validatePath.
const format = zpack.format;For the byte layout these describe, see version 1 layout.
assetId
Section titled “assetId”pub fn assetId(path: []const u8) u64The stable u64 handle for an archive path.
const id = zpack.format.assetId("textures/player.png");const entry = archive.findId(id) orelse return error.MissingAsset;It is XxHash64 of the path bytes under a fixed seed, so:
- The same path always yields the same id, on every platform and every version
- Handles survive a repack - nothing about them is stored in the archive
- It is
comptime-evaluable, which is what lets a generated enum bake handles into the binary:
const Asset = enum(u64) { @"jimmy.png" = zpack.format.assetId("jimmy.png"),};Both pack and Archive.open reject a set of paths in which two
different paths hash alike, so a collision is a build error rather than a wrong
asset at runtime. See asset handles.
validatePath
Section titled “validatePath”pub fn validatePath(p: []const u8) Error!voidRejects anything that could resolve outside the archive root or that cannot round-trip through a filesystem.
zpack.format.validatePath("textures/player.png") catch |err| { std.log.err("unusable path: {t}", .{err});};| Rejected | Error | Why |
|---|---|---|
| Empty | InvalidPath |
Names nothing |
| Over 65535 bytes | PathTooLong |
Cannot be described by the u16 length prefix |
| Not valid UTF-8 | InvalidPath |
Cannot round-trip through a filesystem reliably |
Contains \ |
InvalidPath |
Would mean one thing on Linux, another on Windows |
| Contains NUL | InvalidPath |
Truncates the path at the syscall boundary |
Contains : |
InvalidPath |
On Windows, names an alternate data stream |
An empty segment (a//b) |
InvalidPath |
Ambiguous |
A . or .. segment |
InvalidPath |
Could resolve outside the root |
The colon rule covers a leading C:/ drive letter, but applies to a colon
anywhere - cfg:bak.txt would write into a stream on cfg, somewhere
unpack never reports. See the security model.
Constants
Section titled “Constants”pub const magic = "ZPAK".*;pub const version: u32 = 1;pub const endian: std.builtin.Endian = .little;
/// magic + version + entry_countpub const header_size: u64 = 12;
/// A path longer than this cannot be described by the u16 length prefix.pub const max_path_len: usize = 65535;
/// Upper bound on how far one stored byte can expand when inflated.pub const max_deflate_ratio: u64 = 1032;
pub const Hasher = std.hash.XxHash64;pub const hash_seed: u64 = 0;
/// Separate from `hash_seed` so path handles and content digests never/// collide with each other.pub const id_seed: u64 = 0x5a7061636b496473;max_deflate_ratio is deflate’s theoretical ceiling - its densest possible
encoding is a 258-byte match in two bits. No honest entry approaches it, so a
declared size beyond it means the index is lying. That is the
decompression-bomb bound.
pub const Method = enum(u8) { store = 0, deflate = 1,};
pub const Entry = struct { path: []const u8, offset: u64, size: u64, stored_size: u64, hash: u64, method: Method,};Both are re-exported from the root module as zpack.Method and zpack.Entry.
Size helpers
Section titled “Size helpers”pub fn entrySize(path_len: usize) u64pub fn indexSize(paths: []const []const u8) u64pub fn maxOriginalSize(method: Method, stored_size: u64) u64entrySize returns 35 + path_len, the size of one entry record.
indexSize returns the header plus every entry record - what pack reserves
before streaming, since the index is fixed-size once the paths are known.
maxOriginalSize returns the largest original size that stored_size bytes
could legitimately hold: stored_size for store, and stored_size * 1032 for
deflate. The multiplication saturates rather than wrapping, so an absurd
stored size yields a usable ceiling rather than a small one.
Serialization
Section titled “Serialization”pub fn writeHeader(w: *Writer, entry_count: u32) Writer.Error!voidpub fn readHeader(r: *Reader, file_size: u64) (Reader.Error || Error)!u32
pub fn writeEntry(w: *Writer, e: Entry) Writer.Error!voidpub fn readEntry(r: *Reader, gpa: Allocator) (Allocator.Error || Reader.Error || Error)!EntryField by field, over any std.Io.Reader or std.Io.Writer. Zig structs are
never written to disk directly, so struct layout, padding, and field-order
changes cannot alter the format by accident.
readHeader takes file_size so it can reject an entry_count the file is far
too small to hold - before any allocation, which is what stops a corrupt header
from causing a huge one.
readEntry allocates the path, which the caller owns. It also validates the
path and rejects a store entry whose stored_size differs from its size.
These are what you need to write an independent reader. Most callers should use
Archive instead.
pub const Error = error{ BadMagic, UnsupportedVersion, UnsupportedMethod, CorruptArchive, InvalidPath, DuplicatePath, PathTooLong, AssetIdCollision, PathConflict,};Every structural error the format can produce. See the error reference for causes, and validation for when each is checked.
lessThanPath
Section titled “lessThanPath”pub fn lessThanPath(_: void, a: []const u8, b: []const u8) boolByte-order comparison, for sorting paths the way the format requires. Pass it to
std.mem.sort to reproduce archive ordering.