This document is the binary format specification for .vfs archives. The read/write implementation lives in src/vfs/archive (constants and field offsets are defined in ArchiveFormat.h).
- Byte order: every integer is little-endian (independent of the platform; reads and writes use
ByteIO). - Offsets: unless noted otherwise, every offset is relative to the archive start (the first byte of the header). An archive may be appended after another file (for example an exe); see section 6.
- Paths: UTF-8, separated by
/, already normalized (no leading/, no./.., no empty segments, no\), case-sensitive, length 1..4096 bytes. - Version: there is a single version number (currently
1). A mismatch is rejected immediately (VERSION_MISMATCH). There is no major/minor split and no feature bits. - Integrity: only the header and the index are checked (XXH3-64), so corruption is found quickly. Content hashes are used only by offline checks (
vfstool verify/ArchiveReader::verify); runtime reads do not verify them. - Security: XOR and ChaCha20 with "the key stored in the file" are obfuscation only. They do not stop a determined reverse engineer. For confidentiality, use an external key (not stored in the file).
+----------------------+ 0
| Header (128 B) |
+----------------------+ dataOffset (= 128)
| Data | stored bytes of each file, aligned to alignment, padding bytes are 0
| ... |
+----------------------+ indexOffset (= dataOffset + dataSize)
| Index (indexSize B) | Entry[entryCount] (96 B each) + StringPool; optional ChaCha20 encryption
+----------------------+ indexOffset + indexSize
| Footer (16 B) |
+----------------------+ archiveSize
Regions must appear in this order and must not run past the end: 128 <= dataOffset, dataOffset + dataSize <= indexOffset, indexOffset + indexSize + 16 <= available length.
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | u8[8] | magic | 89 56 46 53 0D 0A 1A 0A (\x89VFS\r\n\x1A\n; like PNG, this catches newline conversion and truncation) |
| 8 | u32 | version | 1 |
| 12 | u32 | headerSize | 128 |
| 16 | u32 | flags | ArchiveFlags: bit0 IndexEncrypted, bit1 KeyStored; every other bit must be 0 |
| 20 | u32 | alignment | data alignment, a power of two, 1..65536 (1 = no alignment) |
| 24 | u32 | entryCount | number of entries |
| 28 | u32 | entrySize | 96 |
| 32 | u64 | dataOffset | start of the data region (writers always use 128) |
| 40 | u64 | dataSize | length of the data region (including padding) |
| 48 | u64 | indexOffset | start of the index |
| 56 | u64 | indexSize | index length (entryCount*96 + StringPool length), at most 512 MB |
| 64 | u8[32] | indexKey | ChaCha20 key when KeyStored, otherwise all zeros |
| 96 | u8[12] | indexNonce | ChaCha20 nonce (0 when the index is not encrypted) |
| 108 | u32 | keyCheck | low 32 bits of XXH3-64(plaintext index); compared after decrypt to tell "wrong key" from "corrupt data" |
| 112 | u64 | indexHash | XXH3-64(stored index bytes) (the ciphertext when encrypted); corruption can be detected without the key |
| 120 | u64 | headerChecksum | XXH3-64(header[0..120)) |
Parse order: magic, then version (not equal to 1 means VERSION_MISMATCH), then headerChecksum, then headerSize/entrySize/alignment/unknown flags (any illegal value means CORRUPTED). The version is checked first so a future-version file gets a clear "version mismatch" error rather than "corrupt".
The plaintext index is Entry[entryCount] immediately followed by StringPool. Entries are sorted by path in strict ascending byte order (duplicates are not allowed), so the runtime can binary-search or build a hash table directly.
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | u8 | type | 1 FILE, 2 DIR (only for empty directories kept explicitly) |
| 1 | u8 | cipher | 0 NONE, 1 XOR |
| 2 | u8 | compression | 0 NONE, 1 LZ4 (independent blocks, see 5.2) |
| 3 | u8 | keyLength | 1..32 when cipher=XOR, otherwise 0 |
| 4 | u32 | nameOffset | offset of the path inside the StringPool |
| 8 | u32 | nameLength | path length in bytes, 1..4096 |
| 12 | u8 | blockLog2 | LZ4: log2 of the block size, 12..22 (4 KiB..4 MiB); otherwise 0 |
| 13 | u8[3] | reserved | 0 |
| 16 | u64 | offset | start of stored data (relative to the archive start) |
| 24 | u64 | storedSize | stored byte count (after compression, including the LZ4 block table) |
| 32 | u64 | size | original byte count |
| 40 | i64 | mtime | Unix seconds; 0 = unknown (writers default to 0 so output is reproducible; runtime stat falls back to the archive file's own mtime) |
| 48 | u64 | hash | low 64 bits of XXH3-128(original content) |
| 56 | u64 | reserved | 0 |
| 64 | u8[32] | key | XOR key; the first keyLength bytes are significant, the rest are 0 |
Validation rules (the reader treats every field as untrusted input): type/cipher/compression are legal values; [nameOffset, nameOffset+nameLength) lies inside the StringPool; the path is normalized; entries are strictly sorted; the file data range [offset, offset+storedSize) lies inside the data region; when uncompressed, storedSize == size; when uncompressed, blockLog2 == 0; when compressed, size > 0, blockLog2 is in 12..22, and storedSize holds at least the block table plus one byte per block, and size is at most about 255 times the block payload (the LZ4 expansion limit; size never exceeds INT64_MAX) (block spans are validated when the table is read, see 5.2); the same path cannot be both a file and an ancestor of a directory. Data ranges of different entries may overlap (shared by dedup).
All paths are concatenated in entry order, with no trailing 0.
- When
IndexEncryptedis set, the whole index region (Entry + StringPool) is encrypted with ChaCha20, initial counter1, nonce taken from the header'sindexNonce. - Key source (highest priority first): an external key passed at runtime or on the CLI, then
indexKeyin the header (KeyStored), thenKEY_REQUIREDif neither exists. - After decryption,
low32(XXH3-64(plaintext)) != keyCheckmeansKEY_INVALID. - The implementation is in
src/vfs/crypto/ChaCha20.cpp. Tests check every vector from RFC 8439 section 2.3.2, section 2.4.2, and appendix A.1/A.2.
After entries are sorted by path, data is written in that order. The start of each non-empty block is rounded up to alignment (pad with 0). An empty file takes no space (offset is the current position, storedSize = 0).
- Optional per file, off by default, decided by pack rules. The file is split into blocks of
2^blockLog2bytes (default 64 KiB; the last block may be shorter), and each block is compressed independently with LZ4 HC (default level 9). - Stored data of a compressed entry = block table + blocks. The table has one u64 (little-endian) per block,
blockCount = ceil(size / blockSize):- bits 0..62: end offset of the block's stored bytes, relative to the end of the table (block
istarts where blocki-1ends; block 0 starts at 0); - bit 63: the block is stored raw (it did not shrink); its stored length then equals its original length.
- bits 0..62: end offset of the block's stored bytes, relative to the end of the table (block
- Reader checks per block: end offsets strictly increase, the last block ends exactly at
storedSize - tableSize, a raw block's length equals its original length, and a compressed block is no longer thanLZ4_compressBound(blockLength). A violation or an LZ4 decode failure is reported as corruption (EIOfrom a stream read,CORRUPTEDfrom verify/readEntry). - If the whole compressed form (table + blocks) is not smaller than the original, the original is stored (
compression = 0,blockLog2 = 0). - At runtime a stream loads the table on its first read and then decompresses only the blocks a read touches, caching just the current block. Opening a file reads nothing; seeking is O(1).
- Order: compress first, then encrypt.
- Optional per file. Key length is 1..32 (default 32; the CLI allows 16..32).
- Stored byte
s[i] = p[i] ^ key[i % keyLength], whereiis the offset inside the stored data (for a compressed file, the offset inside the stored bytes, block table included). Decryption can therefore start at any position and random seek is supported. - The key is derived deterministically (so output is reproducible):
key[16k..16k+15] = LE128(XXH3-128(LE64(h.low) || LE64(h.high), seed = 0x5646532D4B455931 + 16k)),h = XXH3-128(original content). Readers do not depend on the derivation; they use only the key stored in the index.
Files with the same content (XXH3-128 plus length) and the same storage mode (cipher, compression, keyLength, blockLog2) are written once; several entries point at the same offset. Because the XOR key is derived from the content, identical content encrypts to identical bytes, so encrypted files can be deduplicated too.
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | u8[8] | magic | 89 56 46 45 0D 0A 1A 0A (\x89VFE\r\n\x1A\n) |
| 8 | u64 | archiveSize | total length from the start of the header through the end of the footer |
On open, if ArchiveOptions::baseOffset is not set: header magic at offset 0 means the start is 0; otherwise the footer at the end of the file is read and the start is file length minus archiveSize. An archive can therefore be appended directly after an exe or similar file.
The same input produces byte-identical output every time:
- Entries are sorted by path bytes, independent of insertion order;
- mtime is written as 0 by default (only
vfstool pack --keep-mtimewrites the real value); - The XOR key is derived from the content;
- When no external key is supplied, the index key and nonce are derived from the plaintext index:
key = XXH3-128(index, seed=0x5646532D49445831+0) || XXH3-128(index, seed=...+16),nonce = low 12 bytes(XXH3-128(index, seed=0x5646532D4E4F4E31)); with an external key the nonce is derived the same way; - LZ4 HC output is deterministic; padding bytes are 0.
| Condition | Error |
|---|---|
| wrong magic, wrong checksum, region out of range, illegal entry | CORRUPTED |
| version is not 1 | VERSION_MISMATCH |
| index is encrypted but no key is available | KEY_REQUIRED |
| key length is not 32, or keyCheck does not match | KEY_INVALID |
| content hash mismatch, or LZ4 decompress failed (verify only) | CORRUPTED |
- Each open stream holds its own source handle (
IArchiveSource::openStream) and reads atoffset. Streams do not interfere and need no lock; sequential reads cache the file position so extra seeks are skipped. - The index (
ArchiveIndex) is read-only and shared between the backend and every stream viashared_ptr<const>. After unmount, streams that are still open keep it alive. - At runtime the archive is read-only: opening for write returns
READ_ONLY, andwrite()returns -1 /EROFS.