slim64fs On-Disk Format Specification
Version: V9 (format version 0x00090002)
Status: LOCKED — no field may be added, removed, or
reinterpreted without a version bump and a new format.html revision.
Endianness: All multi-byte integers are little-endian
on disk. Block size: 4096 bytes (fixed; not
configurable post-format).
1. Invariants
These constraints are enforced by _Static_assert in
source. Any toolchain that cannot satisfy them must not build
slim64fs.
_Static_assert(S64_DISK_SUPER_SIZE == 128u, "superblock wire size changed — format version bump required"); // s64_disk.h
_Static_assert(S64_DISK_COMMIT_SIZE == 100u, "commit wire size changed — format version bump required"); // s64_disk.h
_Static_assert(S64_BLOCK_DATA_SIZE == 4088u, ...); // s64_fs.h
_Static_assert(S64_INODE_SIZE == 128u, ...); // s64_fs.h
_Static_assert(sizeof(s64_disk_inode) == S64_INODE_SIZE, ...); // s64_fs.h
_Static_assert(offsetof(s64_disk_dirent, name) == S64_DIRENT_HDR_SIZE, ...); // s64_disk.h
_Static_assert(S64_DIRLOG_BLOCK_HDR_SIZE == 16u, ...); // s64_dirlog.h
_Static_assert(S64_DIRLOG_REC_HDR_SIZE == 44u, ...); // s64_dirlog.h
_Static_assert(sizeof(s64_txn_header) == 64u, ...); // s64_txn.hAll integers are encoded/decoded exclusively through
s64_le{16,32,64}_{get,put}. No struct overlay on disk bytes
is permitted, with one exception: the transaction block
(src/s64_txn.c) uses memcpy directly for
encode/decode, which is correct on little-endian targets where struct
layout matches wire layout but is not portable to big-endian.
2. Image Layout
Blocks are numbered from 0. The layout below is computed by
s64_fs_compute_layout() and must match exactly. Fields in
the superblock are the authoritative source of truth at runtime.
Structural ordering (all counts are image-size-dependent; see superblock fields):
Block 0 : Superblock
Block 1 .. bitmap_blocks : Allocation bitmap (primary)
Block 1+bitmap_blocks .. commit_block_a − 1 : Inode table (primary)
Block commit_block_a : Commit block A
Block commit_block_b (= commit_block_a + 1) : Commit block B
Block txn_block (= commit_block_b + 1) : Transaction block (see §5)
Block shadow_bitmap_start : Shadow allocation bitmap
Block shadow_inode_start .. alloc_start − 1 : Shadow inode table
Block alloc_start .. : Data allocation region
Concrete example for a 128 MiB image (32768 blocks; bitmap_blocks=1, inode_table_blocks=128, inode_count=4096):
Block 0 : Superblock
Block 1 : Allocation bitmap (primary)
Block 2..129 : Inode table (primary) — 128 blocks × 32 inodes/block = 4096 inodes
Block 130 : Commit block A
Block 131 : Commit block B
Block 132 : Transaction block
Block 133 : Shadow allocation bitmap
Block 134..261 : Shadow inode table — mirrors primary layout
Block 262+ : Data allocation region (alloc_start = 262, alloc_len = 32506)
For larger images, bitmap_blocks and
inode_table_blocks grow (e.g. a 4 GiB image has
bitmap_blocks=32, inode_table_blocks=2048, alloc_start=4164). All
structural block numbers are computed by
s64_fs_compute_layout() at format and mount time; the
superblock stores them as the authoritative runtime values.
Root inode is always inode 1
(root_ino = 1). Inode 0 is reserved and never
allocated.
3. Superblock (Block 0)
Wire size: 128 bytes. CRC covers bytes 0–127 with
sb_crc32c zeroed during computation.
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 8 | magic |
0x534C363446533100 ("SL64FS1\0" LE) |
| 8 | 4 | version |
Format version; V9 = 0x00090002 |
| 12 | 4 | block_size |
Must equal 4096 |
| 16 | 8 | total_blocks |
Total blocks in image |
| 24 | 8 | root_ino |
Root directory inode number (always 1) |
| 32 | 8 | alloc_start |
First block of data allocation region |
| 40 | 8 | alloc_len |
Number of allocatable data blocks |
| 48 | 4 | generation |
Checkpoint counter; incremented by s64_fs_checkpoint().
Initialized to 0 on format. |
| 52 | 4 | sb_crc32c |
CRC32C of bytes 0–127 (this field zeroed during computation) |
| 56 | 8 | bitmap_start |
Block number of primary allocation bitmap |
| 64 | 8 | bitmap_blocks |
Number of bitmap blocks (variable; computed by
s64_fs_compute_layout()) |
| 72 | 8 | inode_table_start |
Block number of first primary inode table block |
| 80 | 8 | inode_table_blocks |
Number of inode table blocks (variable; minimum 128 for ≥ 4096
inodes; computed by s64_fs_compute_layout()) |
| 88 | 8 | inode_count |
Total inode slots provisioned (computed as
volume_bytes / 65536, minimum 4096) |
| 96 | 8 | shadow_bitmap_start |
Block number of shadow allocation bitmap |
| 104 | 8 | shadow_inode_start |
Block number of first shadow inode table block |
| 112 | 8 | reserved[0..7] |
commit_block_b (LE u64); 0 = legacy single-slot
image |
| 120 | 1 | reserved[8] |
Advisory active commit slot hint: 0=A, 1=B (tie-break only) |
| 121 | 7 | reserved[9..15] |
Reserved; must be zero on write; ignored on read |
Validation rules (enforced by
s64_fs_open()):
magicmust equalS64_SUPER_MAGICversionmust equalS64_SUPER_VERSION(0x00090002)sb_crc32cmust match CRC32C of bytes 0–127 (field zeroed)
Note: block_size and root_ino are
not validated by the open path; they are validated by
slim64fs-check (fsck) as BAD_SUPER_BLOCK_SIZE
and BAD_SUPER_ROOT_INO.
4. Commit Block (A and B)
Wire size: 100 bytes. The filesystem maintains two commit slots (A
and B) for atomic checkpoint. Active slot is selected by
s64_fs_select_active_commit() using generation number,
falling back to the advisory hint.
CRC covers bytes 0–99 plus 4 trailing zero bytes
(104 bytes total) with commit_crc32c zeroed during
computation. The 4 zero bytes are an enshrined legacy artifact: the
original implementation CRC'd the in-memory struct, whose tail padding
extended coverage to 104 bytes, and every existing image carries that
value. Implementers MUST append 4 zero bytes to the 100-byte wire form
before computing or verifying this CRC (see
s64_commit_crc(), src/s64_fs.c).
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 8 | magic |
0x533634434D543100 ("S64CMT1\0" LE) |
| 8 | 4 | version |
Must equal S64_SUPER_VERSION |
| 12 | 4 | generation |
Monotonically increasing; higher generation = more recent |
| 16 | 4 | bitmap_crc32c |
CRC32C of primary allocation bitmap |
| 20 | 4 | inode_crc32c |
CRC32C of primary inode table |
| 24 | 4 | shadow_bitmap_crc32c |
CRC32C of shadow allocation bitmap |
| 28 | 4 | shadow_inode_crc32c |
CRC32C of shadow inode table |
| 32 | 4 | commit_crc32c |
CRC32C of bytes 0–99 + 4 zero bytes (104 total; this field zeroed during computation) |
| 36 | 64 | reserved |
Must be zero on write; ignored on read |
Active slot selection: The slot with the higher
valid generation wins. If both are invalid (bad CRC or
magic), the filesystem is unrecoverable without fsck. If generations are
equal, the advisory hint in superblock.reserved[8] breaks
the tie.
5. Transaction Block
One block, at the fixed position commit_block_b + 1
(derived; not stored as a separate superblock field). Records the intent
of a multi-step file-append extent operation so that incomplete
operations can be identified on remount.
Wire size: 64 bytes used; block is 4096 bytes; remainder zeroed on write.
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 8 | magic |
S64_TXN_MAGIC (0x533634545800); validates
record |
| 8 | 8 | txn_id |
Monotonically increasing transaction ID;
fs->sb.generation + 1 at write time |
| 16 | 8 | parent_checkpoint_gen |
Generation of the active commit slot when this txn was written |
| 24 | 4 | kind |
1 = EXTEND (append new extents), 2 = REWRITE (full extent rewrite) |
| 28 | 4 | state |
0 = PENDING, 1 = COMMITTED |
| 32 | 4 | crc32c |
CRC32C of bytes 0–63 (this field zeroed during computation) |
| 36 | 28 | reserved |
Must be zero on write; ignored on read |
State machine (file-append path):
- PENDING written before extent allocation and data writes begin.
- COMMITTED written after
s64_fs_write_inode()completes with the new extent map. - Block zeroed unconditionally during
s64_fs_checkpoint().
Recovery on open: The block is read and classified at every open. ZERO, COMMITTED, and INVALID all proceed without action. PENDING on writable open is cleared (zeroed) — treated as stale. Shadow restore (which runs earlier in the open path) has already reverted the bitmap and inode table to the last checkpoint state, so the partial operation is fully rewound; the cleared txn block leaves the filesystem ready for the next checkpoint. PENDING on read-only open is left in place.
_Static_assert guard:
_Static_assert(sizeof(s64_txn_header) == 64u, "txn block wire size changed — format version bump required")
— in src/s64_txn.h.
6. Inode (128 bytes)
32 inodes pack into one 4096-byte block
(S64_INODES_PER_BLOCK = 32). Encoded with
s64_inode_encode() / s64_inode_decode(). The
struct is #pragma pack(1); no padding bytes exist between
fields.
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 2 | mode |
POSIX mode bits (S_IFREG, S_IFDIR, S_IFLNK, permission bits) |
| 2 | 2 | links |
Hard link count |
| 4 | 4 | uid |
Owner UID |
| 8 | 4 | gid |
Owner GID |
| 12 | 8 | size |
File size in bytes |
| 20 | 8 | first_block |
Block number of first data/extent block; 0 = empty file |
| 28 | 8 | atime_sec |
Last access time (seconds since Unix epoch) |
| 36 | 8 | mtime_sec |
Last modification time |
| 44 | 8 | ctime_sec |
Last status change time |
| 52 | 76 | reserved |
Type-specific overlay (see below); zero for unused inodes |
reserved field ownership by inode
type:
- Regular file: Extent map encoder/decoder owns bytes
52–127. Format defined by
s64_extent_map. - Directory inode: Bytes 52–59 store
dirlog_head_block(LE u64) — the block number of the head of the dirlog chain for this directory. Zero means no dirlog chain. Bytes 60–127 are reserved and must be zero. - Symlink: File data contains the link target as a
null-terminated string.
reservedis zero. - Unused inode (mode == 0, links == 0): All fields
including
reservedmust be zero.
7. Data Block
Each 4096-byte data block has an 8-byte header:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | crc32c |
CRC32C of all 4096 bytes with this field (bytes 0–3) zeroed during computation |
| 4 | 4 | reserved |
Must be zero on write |
| 8 | 4088 | payload | User data or directory entries |
Usable payload per block: 4088 bytes
(S64_BLOCK_DATA_SIZE).
8. Directory Entry (dirent)
Directory data is stored as a packed sequence of variable-length records within data block payloads. Records are not block-aligned; they are byte-packed sequentially.
Header (12 bytes,
S64_DIRENT_HDR_SIZE):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 8 | ino |
Target inode number |
| 8 | 2 | reclen |
Total record length including header and name+NUL |
| 10 | 1 | namelen |
Length of name, excluding NUL terminator |
| 11 | 1 | type |
Entry type: 1=regular, 2=directory, 3=symlink |
Name: Immediately follows header.
namelen bytes of name data followed by one NUL byte. Total
record: 12 + namelen + 1 bytes.
Constraints:
namelenin range [1, 255] (S64_NAME_MAX = 255)reclenmust equal12 + namelen + 1typemust be 1, 2, or 3- Records do not cross block boundaries
9. Dirlog Block
The dirlog is a singly-linked chain of blocks recording directory
mutation operations. The head block number is stored in the directory
inode's reserved[0..7]. A zero head means no dirlog exists
for this directory.
Block header (16 bytes,
S64_DIRLOG_BLOCK_HDR_SIZE):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 8 | next |
Block number of next dirlog block; 0 = end of chain |
| 8 | 2 | used |
Bytes of record data written into this block's payload |
| 10 | 6 | reserved |
Must be zero |
Payload begins at offset 16. Records are packed sequentially into
bytes 16 through 16 + used − 1.
Dirlog record header (44 bytes,
S64_DIRLOG_REC_HDR_SIZE):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 8 | magic |
0x00533634444C474F (S64_DIRLOG_MAGIC);
validates record boundary |
| 8 | 8 | seq |
Monotonically increasing sequence number within directory |
| 16 | 8 | parent_ino |
Inode number of the parent directory |
| 24 | 8 | child_ino |
Inode number of the child being operated on |
| 32 | 4 | op |
Operation: 1=CREATE, 2=DELETE, 3=RENAME_FROM, 4=RENAME_TO |
| 36 | 2 | name_len |
Length of name payload following this header |
| 38 | 2 | flags |
Reserved; must be zero |
| 40 | 4 | crc32c |
CRC32C of bytes 0–43 (this field zeroed) concatenated with name bytes |
Name payload: name_len bytes
immediately following the 44-byte header. No NUL terminator stored.
Total record size: 44 + name_len
bytes.
10. Allocation Bitmap
bitmap_blocks consecutive blocks (variable; computed by
s64_fs_compute_layout()) covering all block numbers 0
through total_blocks − 1. Bit N corresponds to absolute
block N. Bit = 0 means free; bit = 1 means allocated. Blocks 0 through
alloc_start − 1 (structural blocks) are pre-marked
allocated at format time. Bits for block numbers ≥
alloc_start + alloc_len are also pre-marked allocated to
prevent the allocator from issuing them.
The shadow bitmap is a byte-for-byte copy written before the primary during checkpoint, providing rollback capability.
11. Crash Windows and Ordering Constraints
The following write orderings are invariants. Violating them corrupts the image in ways that may not be fsck-recoverable.
| Window | Constraint |
|---|---|
| W1: Superblock update | Write superblock last; never update in place during operation |
| W2: Commit slot write | Write full commit block atomically; partial writes render the slot invalid |
| W3: Bitmap before inode | On alloc: write bitmap (mark used) before writing inode. On free: write inode (clear reference) before bitmap (mark free) |
| W4: Checkpoint ordering | Shadow structures written and fsync'd before commit block updated |
| W5: Dirlog head free | Zero and flush inode's dirlog_head_block to disk
before calling s64_fs_free_block on the
dirlog head. A crash between these two writes leaks a block
(fsck-visible); a reversed ordering produces a live dirent head pointing
to a freed block (silent corruption) |
| W6: Dirlog append | Write new dirlog block to disk before updating used
count in the preceding block |
12. Version History
| Format Version | Description |
|---|---|
0x00080002 |
V8: Shadow bitmap/inode table; dual commit slots; deferred orphan reclamation |
0x00090002 |
V9: Format locked; _Static_assert guards; this
document |
Images with version 0x00080002 can be migrated to V9
using s64-migrate-v8-to-v9. Images with any other version
are rejected at open time.
13. fsck Findings and Severity
Four internal severity levels (S64CHK_WARNING,
S64CHK_UNREACHABLE, S64CHK_CORRUPT,
S64CHK_UNRECOVERABLE) are collapsed into exit codes by
derive_exit_code():
| Exit code | Meaning |
|---|---|
| 0 | Clean — no findings |
| 1 | Warnings only (e.g. EMPTY_COMMIT_SLOT,
BAD_COMMIT_HINT) — no structural corruption, image safe to
mount |
| 2 | Structural issue: corrupt or unreachable blocks
present, but not both simultaneously. Includes ORPHAN_INODE
(severity UNREACHABLE) and hard corruption without orphans.
Image should not be mounted until repaired. |
| 3 | Both corruption and unreachable blocks present — compound failure. Image must not be mounted. |
| 4 | Tool/usage failure: bad arguments, internal scan error, or mutation
refused because out-of-scope corruption is present and
--force was not passed. |
Exit 1 is mount-safe. Exit 2+ requires repair before mounting. Exit 3 is the worst structural state short of tool failure.
This document is the authoritative reference for the slim64fs V9
on-disk format. The source of truth for field sizes is the encode/decode
functions in src/s64_disk.c and
src/s64_dirlog.c. In any conflict between this document and
those functions, the functions govern and this document must be
corrected.
See Also
- [[invariants|Five Invariants]] — Fill-or-Kill and Anticipatory Sequencing that the format enforces
- [[durability_contract|Durability Contract]] — durability model this format supports
- [[slim64fs_v9|V9 Spec]] — V9 milestone that locked this format version