mkext4 0.0.3

Deterministic, streaming, pure-Rust ext4 image builder with a verification-grade reader
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
# mkext4 — design

A deterministic, streaming, pure-Rust ext4 image writer (plus a
verification-grade reader). Input: an abstract namespace declared through a
builder API plus content streams. Output: a byte-exact ext4 image emitted
through a dumb positional sink. No C dependencies, no mounts, no clocks, no
RNG. Apache-2.0.

Every on-disk claim in this document marked **[verified]** has been checked
against real `mke2fs 1.47.4` images by `tools/check_vectors.py` (an
independent reimplementation of every checksum/hash algorithm, run against
the image matrix built by `tools/mkrefs.sh`; all checks pass on all images).
Golden byte fixtures live in `testdata/vectors/`.

---

## 1. Goals, non-goals, fixed constraints

**Goals**
- Byte-deterministic images: identical builder-call sequence + identical
  options ⇒ identical bytes (§2).
- Streaming: all metadata bytes are emitted the moment the namespace is
  sealed, before any file content arrives; data blocks then stream in
  declaration order at ascending offsets (§4).
- Faster than `mke2fs -F -t ext4 -d` (single pass, no staged directory tree).
- Every image passes `e2fsck -fn` clean and mounts correctly on Linux.

**Fixed constraints**
- Block size 4096 only; other sizes are a constructor error.
- Feature set (constant; word values [verified]):
  - `s_feature_compat = 0x2C` — has_journal (0x4), ext_attr (0x8),
    dir_index (0x20)
  - `s_feature_incompat = 0x242` — filetype (0x2), extents (0x40),
    flex_bg (0x200)
  - `s_feature_ro_compat = 0x46B` — sparse_super (0x1), large_file (0x2),
    huge_file (0x8), **dir_nlink (0x20)**, extra_isize (0x40),
    metadata_csum (0x400). dir_nlink is included so that a flat directory
    of > ~65k subdirectories (pnpm stores, content-addressed caches) is
    representable instead of a builder error — it costs one ro_compat bit
    and a store-nlink-1-on-overflow rule (§9).
- Explicitly **off**: 64bit, resize_inode, orphan_file,
  metadata_csum_seed, meta_bg, inline_data, bigalloc, largedir, ea_inode,
  quota, mmp, encrypt, casefold.
- Image size is an explicit, required option (4096-aligned byte count).
  Practical ceiling from the off-features: < 2^32 blocks (16 TiB); design
  target ≤ 64 GiB. No online growth support (no resize_inode; reserved GDT
  blocks = 0).

**Non-goals**
- Reading/writing feature combinations the writer does not emit is a
  non-goal for the writer; the **reader** additionally handles what stock
  `mke2fs` emits (it is the differential oracle and must read mke2fs
  images), but not the full historical ext2/3 matrix.
- No journal replay in the reader; the writer's journal is always empty.

## 2. Determinism contract

The output image is a pure function of:

1. **Options**: image size in bytes; filesystem UUID; htree hash seed
   (4×u32, stored as `s_hash_seed`); epoch (superblock timestamp fields);
   volume label; inode count policy (`Exact(n)` or `Auto`); reserved-blocks
   percent; journal size override (blocks) if given.
2. **The declaration sequence, including order**: every `mkdir` / `file` /
   `hardlink` / `symlink` / `mknod` / `set_meta` / `set_xattr` / `remove`
   call with its arguments
   (names as byte strings, per-file metadata: mode, uid, gid, mtime — and
   optional atime/ctime/crtime, each with nanosecond precision — xattrs,
   declared sizes and hole maps).
3. **File content bytes** — which affect *only* the corresponding data
   blocks: ext4 has no data checksums, so no metadata byte depends on
   content (§4).

Consequences and rules:
- The crate never reads a clock, RNG, process ID, hostname, or environment.
- No iteration over hash-ordered containers reaches the output path
  (implementation rule: `BTreeMap`/`Vec` + stable sorts only).
- The htree hash uses **signed-char** `str2hashbuf` as a fixed constant
  (`s_flags = 0x0001`, `signed_directory_hash`), independent of build
  platform (ADR-6).
- Declaration order is semantic input: it determines inode numbering and
  data-block placement. Re-ordering declarations produces a different
  (equally valid) image.
- xattrs are canonicalized by sorting (§13), so xattr declaration order does
  *not* affect output — one less footgun; everything else is order-exact.
- **Cross-version**: byte-stability is guaranteed for the same crate
  version only. Layout policy (allocation order, htree packing, extent
  splits, xattr placement) may change between versions; what never changes
  is semantic equivalence (same namespace, fsck-clean). Golden-hash tests
  are updated deliberately when policy changes.

The contract is *sensitivity*, not just stability: changing any single
input (one mtime, one byte of one name, declaration order) must be able to
change the output. A test asserts a one-field change produces a different
image hash.

## 3. Three-phase API

```rust
let mut b = FsBuilder::new(Options {
    size_bytes: 30 << 30,                  // required, multiple of 4096
    block_size: BlockSize::B4096,          // only accepted value
    fs_uuid: [u8; 16],
    hash_seed: [u32; 4],
    epoch: 1_704_067_200,                  // superblock times (s_mkfs_time etc.)
    inodes: InodeCount::Auto,              // or Exact(n)
    label: Option<&str>,                   // ≤16 bytes
    reserved_percent: 5,                   // s_r_blocks_count = 5% (mke2fs default)
    journal_blocks: None,                  // None = size-tiered default (§15)
    features: Features::LINUX_ROOTFS,      // the fixed set of §1 (validated)
})?;

let usr = b.mkdir(ROOT, "usr", meta)?;             // handles, not paths
let f   = b.file(usr, "cat", meta, 8_192)?;        // size required now
let s   = b.file_sparse(usr, "img", meta, &[Data(4096), Hole(1 << 30), Data(4096)])?;
b.hardlink(usr, "dog", f)?;                        // shared inode, nlink 2
b.symlink(usr, "sh", "bash", meta)?;
b.mknod(usr, "null", meta, NodeKind::Char { major: 1, minor: 3 })?;
b.set_xattr(f, "security.selinux", value)?;
b.set_meta(ROOT, root_meta)?;                      // root is declarable too
b.remove(usr, "stale")?;                           // recursive tombstone

let layout = b.seal()?;                            // freeze EVERYTHING (§4)
let mut w = layout.writer(&mut sink)?;             // emits all metadata + zeros here
w.fill(f, &mut reader)?;                           // exactly 8_192 bytes
w.fill(s, &mut reader)?;                           // only the Data segments: 8_192 bytes
let summary = w.finish()?;                         // error if any file unfilled
```

- **Handles** (`InodeHandle`) are indices into the builder's table; hardlinks
  are first-class (`hardlink` bumps nlink, adds a dirent).
- **Root metadata**: `ROOT` is a real handle. `set_meta(ROOT, meta)` and
  `set_xattr(ROOT, …)` cover OCI layers whose `./` entry carries explicit
  root mode/owner/mtime. If never declared, root defaults to mode 0o755,
  uid 0, gid 0, all timestamps = `epoch`, no xattrs. `set_meta(h, meta)`
  works on *any* live handle before seal (last call wins) — it is part of
  the declaration sequence and thus of the determinism input.
- **`remove(parent, name)` is a recursive tombstone**: removing a
  directory removes its entire subtree (the opaque-whiteout case). Any
  inode whose link count drops to zero leaves the layout entirely — no
  inode number, no blocks, no dirent. Hardlinked files survive subtree
  removal if a link outside the subtree remains. **Data-order rule**:
  data-block order is declaration order *filtered to surviving files*  removing a subtree deletes its slots from the sequence (no holes, no
  renumbering of others), and re-added files occupy their new declaration
  position. Whiteout/subtree-removal patterns are part of the proptest
  namespace generator (§19.7).
- **Sizes are final**: `fill` must supply exactly the declared byte count
  (short/long reads are errors). Sparse files declare an explicit
  data/hole segment map; only data segments are filled.
- **Fill completeness**: zero-length files, all-hole sparse files, and
  every non-regular inode are complete at seal — `finish()` does not
  require a `fill` for them (a `fill` supplying exactly 0 bytes is an
  allowed no-op). If a `fill` fails partway (source error, short read,
  sink error), the **writer is poisoned**: the failed file's remaining
  bytes are unemitted, and every subsequent call including `finish()`
  returns an error identifying the poisoned state. There is no partial
  recovery — callers that can retry rebuild the writer from the (still
  valid, reusable) `layout`. The already-emitted prefix is well-formed
  per the sink contract, so a consumer can safely discard by offset.
- **Push-style fill** (deferred convenience, phase 4+): `fill_writer(f) ->
  impl io::Write` alongside pull-style `fill(f, &mut impl Read)`, for
  channel-fed pipelines that push; same exactness rules, completion on
  `Write` drop/flush-to-declared-size.
- **Memory model** (the 1M-inode budget is designed, not discovered):
  per-inode fixed-size record (target ≤ 64 B: type/mode/uid/gid packed,
  times, size, nlink, parent, xattr ref); all names in a single append-only
  byte arena referenced by (u32 offset, u8 len); children stored as
  contiguous index runs (children of one dir are declared contiguously or
  gathered once at seal — no per-entry heap allocation, no PathBuf/String
  anywhere); xattrs deduplicated through an interning arena; extent lists
  computed streaming during the seal sweep, never materialized per-block.
  Budget: ~100–150 B/inode + name bytes ⇒ ≈ 200 MiB for 1M inodes; hard
  design ceiling ≤ 1 GiB including htree build scratch (hash-sort of the
  largest single directory).
- `seal()` freezes: inode numbers, every block address, extent trees,
  dirent/htree bytes, bitmaps, group descriptors, superblock — the complete
  metadata image and the offset of every future data byte
  (`layout.extents(f)` exposes them).
- Validation (builder-time or seal-time errors): name length 1..=255 bytes,
  no `/` or NUL, not `.`/`..`; duplicate names only via explicit overwrite
  semantics of `remove`+re-add; symlink target 1..=4095 bytes; regular-file
  hardlink count ≤ 65,000 (directories have no subdir bound — dir_nlink,
  §9); hardlinks to directories rejected; size overflow vs image size; inode
  exhaustion vs `InodeCount`; xattr total size vs in-inode + one block
  (§13); device numbers within Linux `dev_t` range (major ≤ 4095 for the
  new encoding, minor ≤ 2^20−1).
- Everything is synchronous; the API is usable from a blocking thread. No
  tokio, no async traits.

## 4. Sink contract & emission order — metadata before data

```rust
pub trait RegionSink {
    fn data(&mut self, offset: u64, bytes: &[u8]) -> io::Result<()>;
    fn zeros(&mut self, offset: u64, len: u64) -> io::Result<()>;
}
```

**Invariant**: across a writer's lifetime, every byte of `[0, image_len)`
is covered exactly once, by `data` or `zeros`, and is final when emitted.
No rewrites, ever.

### Why every metadata byte is seal-computable

ext4's `metadata_csum` covers metadata only; **no structure anywhere in the
format checksums file content**. With sizes declared up front, every block
therefore falls into one of two classes:

| block type | contents depend on | class |
|---|---|---|
| superblock + backups | options, totals (sizes, counts) | seal-computable |
| group descriptors + backups | layout totals, bitmap/itable addresses | seal-computable |
| block/inode bitmaps | final allocation map | seal-computable |
| inode tables | declared metadata, sizes, extent roots, csums | seal-computable |
| extent tree interior/leaf blocks | block addresses (fixed at seal) | seal-computable |
| directory blocks (linear + htree) | names, inode numbers, hash seed | seal-computable |
| xattr blocks | declared xattrs | seal-computable |
| slow-symlink blocks | declared target | seal-computable |
| journal (empty jbd2) | uuid + journal length | seal-computable |
| free space / itable slack / journal body | nothing (zeros) | seal-computable |
| **file data blocks** | **content stream** | fill-time |

Nothing else exists in the image ⇒ all metadata and all zeros can be
emitted at `layout.writer()` time, before the first `fill`.

### Emission schedule

1. **Metadata pass** (inside `layout.writer()`): one ascending sweep over
   `[0, image_len)` that emits every byte *not owned by declared file
   data* — metadata via `data()`, everything free/slack via `zeros()`
   (coalesced into maximal runs). This includes the zeros beyond the last
   data block, journal body, itable tails, and all free groups, so a
   consumer can retire untouched regions immediately without reading.
2. **Data pass**: `fill()` calls emit declared file blocks. Because data
   blocks are allocated in declaration order by a monotone cursor (§5),
   filling in declaration order emits at strictly ascending offsets.
   Out-of-order `fill` is legal and correct — it only loses the ascending
   property. A file's final partial block is emitted as `data(content)` +
   `zeros(pad)` covering the block tail.

The overall byte stream is therefore *not* globally offset-monotonic (the
metadata pass sweeps the whole image first); each of the two passes is
individually ascending. This is exactly what the downstream chunk-hashing
consumer wants: chunk completion is offset-indexed, metadata+zeros retire
most chunks instantly, and data completes the rest in ascending order.

A `CheckingSink` test double asserts: exactly-once coverage, finality,
metadata-pass-before-data, ascending data offsets under declaration-order
fill.

## 5. Geometry & physical layout

- `blocks_per_group = 32768` (8 × 4096). `group_count =
  ceil(blocks / 32768)`; the last group may be short — **any** length ≥ 1
  block is valid ([verified]: mke2fs 1.47.4 builds a 32769-block fs — a
  1-block last group — and it passes `e2fsck -fn`; with flex_bg the group's
  bitmaps/itable live in the flex leader, so a tiny tail group holds only
  its backup superblock, if any).
- **Inodes per group**: from `InodeCount::Auto`, total inodes =
  `blocks × 4096 / 16384` (mke2fs's default bytes-per-inode ratio), or the
  explicit `Exact(n)`. Then `ipg = ceil(total / group_count)` rounded **up
  to a multiple of 16** so the 256-byte-inode table fills whole blocks
  ([verified]: 34816 requested → ipg 6976 = 436 exact itable blocks; also
  ipg 7632 = 477 blocks), capped at 32768, min 16. `itable_blocks_per_group
  = ipg / 16`. `s_inodes_count = ipg × group_count`.
- **flex_bg factor 16** (`s_log_groups_per_flex = 4`). The flex *leader*
  (first group of each flex span) packs, in order: the 16 groups' block
  bitmaps, then their 16 inode bitmaps, then their 16 inode tables
  [verified layout on all images]. A partial trailing flex span packs the
  same way with fewer members.
- **sparse_super backups**: a backup superblock (1 block: 1024-byte
  superblock at offset 0 of the block, zero-padded) + full GDT copy at the
  first blocks of groups 1, 3^n, 5^n, 7^n (1, 3, 5, 7, 9, 25, 27, 49, 81,
  125, 243, 343, …). Backup superblocks are byte-identical to the primary
  except `s_block_group_nr` (0x5A) and the recomputed `s_checksum`; backup
  GDTs are byte-identical to the primary GDT (ADR-1a; [verified] that
  mke2fs backup GDTs are identical at mkfs time, and that its backup
  *superblocks* differ only in stale free counts / `s_state=0` /
  `s_kbytes_written` — we write fresh values instead; recorded deviation
  §18. Note `e2fsck -b` reports differences even on pristine mke2fs images,
  so backup-fsck is a recovery path, not a cleanliness gate).
- **Physical order** (ascending block addresses; ADR-1):

  ```
  block 0:            [1024 zero pad][superblock][zero pad to 4096]
  blocks 1..1+G:      primary GDT   (G = ceil(group_count·32/4096))
  flex-0 metadata:    16 block bitmaps, 16 inode bitmaps, 16 inode tables
  journal:            J blocks (§15; split at reserved runs)
  namespace metadata: for each inode in inode-number order:
                        xattr block, then dir/htree/symlink content blocks
  file data:          declaration order of surviving files, single
                        ascending cursor; a file needing an extent tree
                        (> 4 extents) gets its tree blocks immediately
                        after its own data runs — the tree's size depends
                        on how the data allocation split, so placing it
                        behind the data keeps the layout single-pass
                        (blocks are still seal-computable and emitted in
                        the metadata pass; only their position is
                        interleaved)
  ```

  The cursor for namespace metadata and file data is one monotone
  allocator that **skips** reserved runs it encounters: backup sb+GDT at
  the start of backup groups, and each flex leader's packed metadata. Data
  blocks otherwise pack densely — between two flex-metadata runs there are
  ~2 GiB of contiguous allocatable space, so multi-GiB files fragment into
  at most a handful of extents plus the mandatory ≤32768-block splits.
- Neither the kernel nor e2fsck imposes locality constraints on dir/
  extent/xattr blocks beyond "in range, marked used, not overlapping other
  metadata"; with flex_bg, e2fsck widens legal bitmap/itable placement to
  the whole fs. Packing all namespace metadata early is unusual-looking
  but structurally conventional (it is what flex_bg exists to allow) and
  keeps the metadata pass one dense prefix + small per-flex islands.
- **Inode numbering**: inode 2 = root, 11 = lost+found, then namespace
  inodes in **declaration order** starting at 12 (hardlinks share their
  target's number; `remove`d-and-unreferenced files are skipped). One
  number space; no per-group balancing (deviation §18 — irrelevant to
  correctness, and `bg_used_dirs_count`/free counts are computed from the
  actual map).

## 6. Superblock

All offsets are within the 1024-byte superblock. Values [verified] against
`ref512` unless marked *policy* (our deterministic choice where mke2fs
writes something non-deterministic or environment-dependent).

| offset | field | value |
|---|---|---|
| 0x00 | s_inodes_count | ipg × group_count |
| 0x04 | s_blocks_count_lo | size_bytes / 4096 |
| 0x08 | s_r_blocks_count_lo | reserved_percent of blocks (default 5%) |
| 0x0C/0x10 | s_free_blocks/inodes_count | computed from final map |
| 0x14 | s_first_data_block | 0 |
| 0x18/0x1C | s_log_block_size / s_log_cluster_size | 2 / 2 |
| 0x20/0x24 | s_blocks/clusters_per_group | 32768 / 32768 |
| 0x28 | s_inodes_per_group | ipg |
| 0x2C | s_mtime | 0 (never mounted) |
| 0x30 | s_wtime | epoch *(policy: mke2fs uses wall clock)* |
| 0x34/0x36 | s_mnt_count / s_max_mnt_count | 0 / 0xFFFF (−1) |
| 0x38/0x3A/0x3C/0x3E | s_magic / s_state / s_errors / s_minor_rev | 0xEF53 / 1 (clean) / 1 (continue) / 0 |
| 0x40/0x44 | s_lastcheck / s_checkinterval | epoch / 0 |
| 0x48/0x4C | s_creator_os / s_rev_level | 0 (Linux) / 1 |
| 0x50/0x52 | s_def_resuid/gid | 0 / 0 |
| 0x54/0x58 | s_first_ino / s_inode_size | 11 / 256 |
| 0x5A | s_block_group_nr | 0 primary; group # in backups |
| 0x5C/0x60/0x64 | feature compat/incompat/ro | 0x2C / 0x242 / 0x46B |
| 0x68/0x78 | s_uuid / s_volume_name | options |
| 0x88 | s_last_mounted | zeros |
| 0xC8/0xCC/0xCD | s_algorithm_usage_bitmap / s_prealloc_* | 0 / 0 / 0 |
| 0xCE | s_reserved_gdt_blocks | 0 (no resize_inode) |
| 0xD0 | s_journal_uuid | zeros (internal journal) |
| 0xE0/0xE4/0xE8 | s_journal_inum / s_journal_dev / s_last_orphan | 8 / 0 / 0 |
| 0xEC | s_hash_seed[4] | options (explicit) |
| 0xFC/0xFD | s_def_hash_version / s_jnl_backup_type | 1 (half_md4) / 1 |
| 0xFE | s_desc_size | 0 (32-byte descs implied without 64bit) |
| 0x100 | s_default_mount_opts | 0x0C (user_xattr, acl) |
| 0x104 | s_first_meta_bg | 0 |
| 0x108 | s_mkfs_time | epoch |
| 0x10C | s_jnl_blocks[17] | journal i_block[0..15], i_size_high, i_size (§15) |
| 0x15C/0x15E | s_min/want_extra_isize | 32 / 32 |
| 0x160 | s_flags | 0x1 (signed_directory_hash, fixed; ADR-6) |
| 0x174/0x175 | s_log_groups_per_flex / s_checksum_type | 4 / 1 (crc32c) |
| 0x178 | s_kbytes_written (u64) | ceil((metadata bytes + Σ file sizes)/1024) *(policy; §18)* |
| 0x248 | s_overhead_clusters | computed exactly (§17) |
| 0x268/0x26C/0x270 | s_lpf_ino / s_prj_quota_inum / s_checksum_seed | 0 / 0 / 0 |
| 0x3FC | s_checksum | crc32c(~0, sb[0..0x3FC]) [verified] |

Everything not listed is zero. MMP, snapshot, error-tracking, quota, and
encryption fields are all zero [verified].

## 7. Group descriptors & UNINIT policy

32-byte descriptors ([verified] offsets): 0x00 block_bitmap, 0x04
inode_bitmap, 0x08 inode_table (all u32 block addresses), 0x0C
free_blocks (u16), 0x0E free_inodes (u16), 0x10 used_dirs (u16), 0x12
bg_flags (u16), 0x14 exclude_bitmap (0), 0x18 block_bitmap_csum_lo (u16),
0x1A inode_bitmap_csum_lo (u16), 0x1C itable_unused (u16), 0x1E
bg_checksum (u16).

- `bg_checksum = crc32c(fs_seed, le32(group_nr) ‖ desc[0x00..0x1E] ‖
  0u16) & 0xFFFF` [verified all groups, all images].
- Flags policy (ADR-3):
  - `EXT4_BG_INODE_ZEROED` (0x4) on **every** group (we emit fully zeroed
    itables — matches non-lazy mke2fs [verified]).
  - `EXT4_BG_INODE_UNINIT` (0x1) on groups with zero used inodes; their
    inode-bitmap *block* is emitted as zeros and `inode_bitmap_csum_lo = 0`
    ([verified]: mke2fs stores exactly this).
  - `EXT4_BG_BLOCK_UNINIT` (0x2): **never set** (deviation §18; mke2fs sets
    it with a zero bitmap block + zero csum on fully-free groups
    [verified]). We emit a real bitmap + real checksum for every group —
    uniform, unambiguous, still cheap because a free-group bitmap is a
    constant block.
- `bg_itable_unused = ipg − (index of highest used inode in the group + 1)`
  — counts the guaranteed-unused *tail* of the itable ([verified]: group 0
  with 332 used inodes stores 7860).
- `bg_used_dirs_count` counts directory inodes *homed* in the group;
  e2fsck pass 5 recomputes it.

## 8. Bitmaps

- **Block bitmap**: one block per group, 1 bit per block, LSB-first within
  bytes; bit set = in use. Non-final groups use all 32768 bits (the bitmap
  exactly fills its block — no padding exists). In a truncated final group,
  all bits past `s_blocks_count` through the end of the 4096-byte block are
  **set to 1** [verified on refodd].
- **Inode bitmap**: bits 0..ipg−1 meaningful; all remaining bits of the
  block set to 1 [verified] — except INODE_UNINIT groups, whose bitmap
  block is all zeros (§7).
- **Checksums** (stored per group in the descriptor, low 16 bits of crc32c
  since descs are 32-byte):
  - block bitmap: `crc32c(fs_seed, bitmap[0..4096])` — full block
    ([verified]; `clusters_per_group/8 = 4096`).
  - inode bitmap: `crc32c(fs_seed, bitmap[0..(ipg+7)/8])`**only the
    meaningful prefix** ([verified]; e.g. 1024 of 4096 bytes at ipg 8192 —
    checksumming the whole block is the classic mismatch).
- The bitmap contents must equal the allocator's final map exactly; e2fsck
  pass 5 recomputes both bitmaps and every free count from first
  principles.

## 9. Inode table

256-byte inodes, `i_extra_isize = 32` (extra area = 0x80..0xA0).
[verified] field offsets: 0x00 mode, 0x02 uid_lo, 0x04 size_lo, 0x08/0x0C/
0x10/0x14 atime/ctime/mtime/dtime, 0x18 gid_lo, 0x1A links, 0x1C
blocks_lo, 0x20 flags, 0x24 version_lo, 0x28 i_block[15] (60 bytes), 0x64
generation, 0x68 file_acl_lo, 0x6C size_high, 0x74 osd2 (blocks_high,
file_acl_high, uid_high, gid_high, **checksum_lo @0x7C**), 0x80
extra_isize, 0x82 **checksum_hi**, 0x84/0x88/0x8C ctime/mtime/atime_extra,
0x90/0x94 crtime/crtime_extra, 0x98 version_hi, 0x9C projid.

- **Checksum** [verified]: seed' = crc32c(crc32c(fs_seed, le32(ino)),
  le32(i_generation)); csum = crc32c(seed', inode[0..256] with bytes
  0x7C..0x7E zeroed, and 0x82..0x84 zeroed **iff** `i_extra_isize ≥ 4`).
  Low 16 bits → 0x7C; high 16 → 0x82 when the extra area exists.
- `i_generation = 0` everywhere (determinism).
- **Timestamps**: seconds in the classic fields; `*_extra = (nsec << 2) |
  epoch_bits` where epoch_bits extend the sign-extended 32-bit seconds
  (post-2038). Defaults: atime = ctime = crtime = mtime unless declared.
- **i_blocks** (512-byte units, `huge_file` on but `HUGE_FILE_FL` never
  set — files ≤ 64 GiB keep i_blocks < 2^32): counts data blocks + extent
  tree blocks + the xattr block + directory/htree blocks + slow-symlink
  block. Holes count nothing. Journal inode counts its whole area
  [verified: 4096-block journal → i_blocks 32768].
- **i_flags**: `EXT4_EXTENTS_FL` (0x80000) on every regular file (including
  empty ones [verified]), directory, slow symlink, and the journal inode;
  never on fast symlinks, devices, FIFOs, sockets. `EXT4_INDEX_FL` (0x1000)
  on htree directories.
- Per-type matrix:

| type | mode high bits | i_block | i_size | i_blocks |
|---|---|---|---|---|
| regular | 0o10 | extent root | bytes | blocks×8 |
| dir | 0o04 | extent root | blocks×4096 | blocks×8 |
| fast symlink (target ≤ 59 B) | 0o12 | target bytes, zero-padded | target len | 0 |
| slow symlink (60..=4095 B) | 0o12 | extent root | target len | 8 (+tree) |
| char/block dev | 0o02 / 0o06 | encoding §14 | 0 | 0 |
| fifo / socket | 0o01 / 0o14 | zeros | 0 | 0 |

  (Fast/slow boundary [verified]: 59-byte target inline, 60-byte target
  allocates a block.)
- **Reserved inodes 1–10** [verified]: bitmap bits always set; `s_first_ino
  = 11`. Inode 1 (bad blocks): all zero except atime/ctime/mtime = epoch
  and a valid checksum. Inodes 3–7, 9, 10: all-zero body + valid
  `checksum_lo` (their extra_isize is 0, so no checksum_hi). Inode 2 =
  root, 8 = journal (§15), 11 = lost+found: mode 0o40700 root:root, 4
  blocks (16 KiB) — block 0 a normal empty-dir block with "."/"..",
  blocks 1–3 "empty" dirent blocks (§11) [verified].
- Directory `i_links_count = 2 + subdirectory count`, **unless** that
  exceeds 65,000 (EXT4_LINK_MAX): then store `i_links_count = 1`, the
  dir_nlink convention for "uncounted" (kernel and e2fsck accept nlink 1
  on directories when the ro_compat bit is set). Regular files: hardlink
  count, hard error above 65,000 (dir_nlink does not cover files). Root
  counts itself.

## 10. Extent trees

- Node layout: 12-byte header `{eh_magic 0xF30A, eh_entries, eh_max,
  eh_depth, eh_generation=0}` + 12-byte entries. Leaf entry:
  `{ee_block, ee_len, ee_start_hi, ee_start_lo}`; index entry:
  `{ei_block, ei_leaf_lo, ei_leaf_hi, ei_unused}`. `*_hi = 0` always
  (no 64bit).
- Root in `i_block`: header + up to 4 entries (`eh_max = 4` [verified]).
  Interior/leaf blocks: `eh_max = (4096 − 12 − 4)/12 = 340` [verified],
  4-byte `ext4_extent_tail` checksum at byte 4092.
- Block checksum [verified]: `crc32c(inode_seed, block[0..4092])` stored at
  4092 (coverage = header + all 340 slots, used or not), where inode_seed
  is the same ino+generation seed as §9.
- `ee_len` encoding [verified the sharp edge]: 1..=32768 = initialized
  extent of that length (32768 = 0x8000 itself is valid!); values > 32768
  encode unwritten extents of `len − 32768` (max 32767). **We never emit
  unwritten extents** — declared holes are simply absent extents (ADR-9):
  no blocks, no i_blocks, reads as zeros.
- Deterministic bulk build (ADR-4): collect the file's extents (contiguous
  allocation runs, split at 32768 blocks and around skipped metadata
  runs). If ≤ 4 → inline root, depth 0. Otherwise build bottom-up at 100%
  fill: leaves take 340 extents each, index nodes 340 children, root ≤ 4;
  depth = minimal. First `ee_block`/`ei_block` = first *mapped* logical
  block of its subtree (holes may precede). fsck checks ordering/overlap,
  not fill factor. Capacity: depth 1 ⇒ 1360 extents (≥ 170 GiB contiguous),
  depth 2 ⇒ 462 400 — sparse-heavy multi-GiB files fit in depth ≤ 2.

## 11. Directories — linear format

- `ext4_dir_entry_2`: `{inode u32, rec_len u16, name_len u8, file_type u8,
  name}`; entries 4-byte aligned (`rec_len = align4(8 + name_len)`), the
  last real entry's rec_len stretches to the checksum tail. file_type:
  1 reg, 2 dir, 3 chr, 4 blk, 5 fifo, 6 sock, 7 symlink.
- Every dir block ends with a 12-byte **tail** `{inode 0, rec_len 12,
  name_len 0, file_type 0xDE, checksum u32}`; checksum =
  `crc32c(inode_seed, block[0..4084])` [verified].
- Block 0 starts with "." then ".." (rec_len 12 each). Entries are packed
  in **declaration order**, greedy first-fit into successive blocks (an
  entry never splits). An "empty" block (lost+found padding) is a single
  unused dirent `{inode 0, rec_len 4084, file_type 0}` + tail [verified].
- **Linear/htree threshold — the oracle's exact rule, adopted verbatim**
  (ADR-5): a directory stays linear iff the sum of its entry records
  (`Σ align4(8 + name_len)` over real entries, excluding "."/"..") is
  **< blocksize − 24** (4072). That is e2fsck 1.47.4 `rehash.c`'s
  compression test, [verified] empirically at the boundary: 254 16-byte
  entries (4064 B) → 2-block *linear*; 255 (4080 B) → htree. So linear
  directories occupy 1 block, or exactly 2 in the narrow window where
  entries exceed block 0's dot-reduced capacity but sit under the
  threshold. Anything at/over the threshold is built as htree (§12).
  (Background [verified]: `mke2fs -d` itself leaves even 120k-entry
  directories linear — htree-at-mkfs is produced by `e2fsck -fD`, whose
  output is our layout oracle. dir_index is a hard requirement here, so
  linear-only is not a fallback.)

## 12. Directories — htree

Verified against `e2fsck -fD`-built trees (1-level, 300 entries; 2-level,
120k entries) — structure, limits, checksums, and every leaf-name hash.

- **dx_root** (block 0): "." (rec_len 12), ".." (rec_len = 4084, i.e. to
  the tail), then at 0x18 `dx_root_info {reserved_zero u32, hash_version=1
  (half_md4), info_length=8, indirect_levels, unused_flags=0}`; at 0x20 the
  entry array. Slot 0 is the `dx_countlimit` alias: `{limit u16, count
  u16, block u32}` — its "hash" is implicitly 0; real entries `{hash u32,
  block u32}` follow. `limit = 507` [verified] = (4096−32)/8  1 tail
  slot. Logical block pointers are *within the directory file*.
- **dx_node** (interior, only when indirect_levels = 1): fake dirent
  `{inode 0, rec_len 4096}` (8 bytes), then countlimit + entries;
  `limit = 510` [verified] = (4096−8)/8  1.
- **dx_tail** `{dt_reserved u32, dt_checksum u32}` occupies the last 8
  bytes (the slot excluded from limit). Checksum [verified]:
  `crc32c(inode_seed, block[0..count_offset + count·8]) ⊕ then
  dt_reserved (4 B) ⊕ then 4 zero bytes` — count_offset 0x20 for root, 0x8
  for nodes; only `count` entries are covered, not `limit`.
- **Leaves** are ordinary dirent blocks (§11 incl. tail). `indirect_levels
  ≤ 1` without largedir (2-level tree ≈ 507·510 leaves ≫ any target).
- **Hash** [verified byte-for-byte against debugfs and 120k real entries]:
  half_md4 (3-round, target order a,d,c,b), signed-char `str2hashbuf`,
  seed = `s_hash_seed` (the option), consumed 32 name bytes per transform;
  `hash = buf[1] & ~1`, `minor = buf[2]`. Vectors in
  `testdata/vectors/dx_hash.json`.
- **Deterministic bulk build** (ADR-5): hash all entries; sort by
  `(hash, minor, declaration index)`; pack leaves to 100% fill in that
  order (entries inside a leaf stay in that sorted order — deterministic;
  [verified] e2fsck -D also emits hash-sorted leaves, though it packs to
  80% — `htree_slack_percentage` 20 default; our 100% fill is a recorded
  deviation, §18); dx entry i carries
  the first hash of leaf i; if a leaf's first hash equals the previous
  leaf's last hash, set the collision-continuation bit (`hash | 1`) —
  same-hash runs must never be split *between* leaves when avoidable (split
  only if a single hash value exceeds one leaf's capacity, which the
  collision bit handles). "." and ".." exist only in dx_root. Below the
  §11 threshold → linear instead.
- `i_size` covers all htree blocks; `EXT4_INDEX_FL` set; every block of the
  directory is covered by its extent tree as usual.

## 13. Extended attributes

- **In-inode area** (0xA0..0x100, 96 bytes): header = magic `0xEA020000`
  (u32) [verified], then entries; values packed downward from the end of
  the inode. Entry: `{e_name_len u8, e_name_index u8, e_value_offs u16,
  e_value_inum u32 = 0, e_value_size u32, e_hash u32, name bytes}`, entries
  4-byte aligned, list terminated by 4 zero bytes. **`e_hash = 0` for
  in-inode entries** [verified]; `e_value_offs` is relative to the **first
  entry position, i.e. after the 4-byte magic** (kernel `IFIRST`)
  [verified byte-exactly — offsets relative to the magic word itself are
  off by 4].
- **xattr block** (at most one, `i_file_acl`, i_blocks += 8 [verified]):
  32-byte header `{h_magic 0xEA020000, h_refcount = 1, h_blocks = 1,
  h_hash, h_checksum, reserved}`; entries from 0x20, values packed downward
  from block end; `e_value_offs` relative to block start.
- **Checksum** [verified]: `crc32c(fs_seed, le64(block_nr) ‖
  block[0..4096] with h_checksum zeroed)` — the only checksum in the
  format that binds a physical block address.
- **Hashes**: `e_hash` = fold of name (rol 5/27 per byte) then value (rol
  16/16 per LE u32 word, value zero-padded to 4) [verified against a real
  entry]; `h_hash` = fold (rol 16/16) of entry hashes. debugfs writes
  `h_hash = 0` and fsck accepts, but the kernel computes both — we emit
  real hashes (kernel formula).
- **Policy** (ADR-7): no block sharing ever (`h_refcount = 1` always —
  dedup would couple unrelated inodes and complicate streaming for ~zero
  benefit on rootfs images). Canonical order: entries sorted by
  `(e_name_index, name)` in *both* regions (the block format requires
  sorted; in-inode order is free [verified: debugfs writes insertion
  order] — we sort everywhere so xattr declaration order is
  output-irrelevant). Placement: attributes are assigned to the in-inode
  area in sorted order while they fit (entry + value); the first that
  doesn't fit moves *itself and all remaining* to the block (single
  deterministic split point). Oversize total → builder error.
- Name-index compression: 1 `user.`, 2 `system.posix_acl_access`,
  3 `system.posix_acl_default`, 4 `trusted.`, 6 `security.`, 7 `system.`
  (others rejected).

## 14. Special files & symlinks

- **Devices** [verified both encodings]: if major < 256 && minor < 256 →
  old encoding, `i_block[0] = (major << 8) | minor`, `i_block[1] = 0`;
  otherwise new encoding, `i_block[0] = 0`, `i_block[1] = (minor & 0xFF) |
  (major << 8) | ((minor & ~0xFF) << 12)`. Limits: major ≤ 4095, minor ≤
  1048575.
- **FIFO / socket**: i_block all zero, size 0.
- **Symlinks**: target ≤ 59 bytes → fast (bytes in i_block, zero-padded,
  no EXTENTS_FL, i_blocks 0); 60..=4095 → slow (one extent-mapped block,
  target + zero padding) [verified at the 59/60 boundary].

## 15. Journal (empty jbd2)

- Inode 8: mode 0o100600 root:root, links 1, EXTENTS_FL, allocated
  right after flex-0 metadata (deviation from mke2fs's mid-device
  placement, §18). The journal is *extent-mapped like any file* and its
  allocation splits around reserved runs (backup superblocks/GDTs, flex
  metadata) — jbd2 requires logical, not physical, contiguity; a large
  journal near the front necessarily straddles backup groups.
  `i_size = blocks × 4096`, `i_blocks = blocks × 8` (+ extent tree
  blocks if the split forces > 4 extents) [verified base shape].
- Default size (blocks) by fs size [verified at 5 points, remaining tier
  boundaries from `ext2fs_default_journal_size`]:

  | fs blocks | journal blocks |
  |---|---|
  | < 2048 | error (no journal possible — reject) |
  | < 32768 | 1024 |
  | < 262144 | 4096 |
  | < 524288 | 8192 |
  | < 4194304 | 16384 |
  | < 8388608 | 32768 |
  | < 16777216 | 65536 |
  | < 33554432 | 131072 |
  | ≥ 33554432 | 262144 |

- **Journal superblock** (journal block 0, first 1024 bytes; **all fields
  big-endian**) [verified]: `h_magic 0xC03B3998, h_blocktype 4 (SB v2),
  h_sequence 0; s_blocksize 4096, s_maxlen = blocks, s_first 1,
  s_sequence 1, s_start 0, s_errno 0; feature compat/incompat/ro_compat
  ALL ZERO; s_uuid = fs uuid; s_nr_users 1`; everything else zero,
  including `s_checksum_type`**mke2fs writes an un-checksummed v2
  journal even with metadata_csum**; the kernel upgrades it on first
  mount. We emit exactly this. Rest of block 0 and all remaining journal
  blocks: zeros (via `zeros()`).
- Superblock backup [verified]: `s_jnl_backup_type = 1`;
  `s_jnl_blocks[0..14] = journal i_block[0..14]` (the extent root),
  `[15] = i_size_high = 0`, `[16] = i_size`. `s_journal_uuid = 0`.

## 16. Checksums — unified table

`crc32c` = Castagnoli, reflected, polynomial 0x82F63B78, **no final xor**;
the seed argument is the running register. `fs_seed = crc32c(~0, s_uuid)`
(metadata_csum_seed is off, so the field at 0x270 stays 0 and the seed
always derives from the UUID). `inode_seed(ino) = crc32c(crc32c(fs_seed,
le32(ino)), le32(i_generation))` — generation is always 0 for us. All
formulas [verified] on every image in the matrix:

| structure | stored at | computation |
|---|---|---|
| superblock | 0x3FC u32 | crc32c(**~0**, sb[0..0x3FC]) — seeds itself, not fs_seed |
| group desc | 0x1E u16 | crc32c(fs_seed, le32(group) ‖ desc[0..0x1E] ‖ 0u16) & 0xFFFF |
| block bitmap | desc 0x18 u16 | crc32c(fs_seed, bitmap[0..4096]) & 0xFFFF |
| inode bitmap | desc 0x1A u16 | crc32c(fs_seed, bitmap[0..(ipg+7)/8]) & 0xFFFF |
| inode | 0x7C u16 + 0x82 u16 | crc32c(inode_seed, inode with csum fields zeroed) |
| extent block | byte 4092 u32 | crc32c(inode_seed, block[0..4092]) |
| dirent block | tail u32 | crc32c(inode_seed, block[0..4084]) |
| dx root/node | dt_checksum | crc32c(inode_seed, block[0..cnt_off+count·8]) ⊕ dt_reserved ⊕ 4 zero bytes |
| xattr block | 0x10 u32 | crc32c(fs_seed, le64(blocknr) ‖ block with 0x10..0x14 zeroed) |
| uninit bitmaps || stored 0 (INODE_UNINIT; BLOCK_UNINIT n/a for us) |
| journal || none (empty journal is feature-less) |

crc16 is never needed (GDT_CSUM and METADATA_CSUM are mutually exclusive;
we use only the latter). Rust unit tests reproduce every row from the
committed vector blobs.

## 17. Accounting & overhead

`s_overhead_clusters` = every block that is not (and can never be) file or
directory data [verified exactly on 512 MiB and 64 GiB images]:

```
overhead = 1 (sb) + G (primary GDT)
         + n_backups × (1 + G)               # sparse_super groups
         + 2 × group_count                   # both bitmaps
         + group_count × ipg/16              # inode tables
         + journal_blocks
```

Free blocks/inodes and all per-group counts are computed from the final
allocation map; e2fsck pass 5 must find zero discrepancies. lost+found's 4
blocks and root's dir block count as *used data*, not overhead [verified].
The closed form lets callers predict usable capacity for a given image
size before declaring anything.

## 18. Deviations from mke2fs (deliberate, all fsck-clean)

1. **htree built eagerly at mkfs time** for any directory at/over the
   §11 threshold — stock `mke2fs -d` leaves all directories linear
   (verified, contrary to folklore); our layout oracle is `e2fsck -fD`
   output (whose threshold we adopt exactly), and dir_index is a product
   requirement.
1a. **htree leaves packed to 100% fill**`e2fsck -fD` leaves 20% slack
   (`htree_slack_percentage`) for future kernel inserts; our images are
   effectively immutable rootfs content, so slack is pure waste (a
   first-insert leaf split at runtime is the kernel's normal path).
   Structural tests assert our invariants + fsck/kernel oracles, not
   byte-parity with `-fD` layouts.
2. **Journal placement**: immediately after flex-0 metadata, not
   mid-device (mke2fs's placement optimizes spinning-disk seeks; ours
   optimizes a dense metadata prefix for streaming).
3. **No BLOCK_UNINIT** ever; real bitmaps + checksums everywhere. (mke2fs
   marks fully-free groups uninit with zeroed csum.)
4. **Backups are fresh**: backup superblocks equal the primary except
   `s_block_group_nr`/`s_checksum`; mke2fs leaves stale free counts,
   `s_state = 0`, and stale `s_kbytes_written` in backups.
5. **Allocation order**: inode numbers and data placement follow
   declaration order; mke2fs follows its own traversal and grouping
   heuristics. (Any consistent assignment is valid.)
6. **s_kbytes_written**: deterministic (metadata + declared data, in KiB);
   mke2fs records its actual I/O volume.
7. **xattr h_hash/e_hash computed** per kernel formula in the block case;
   debugfs-authored references store 0. (Matching the kernel, not
   debugfs.)
8. **s_wtime = epoch** rather than wall clock.

Structural identities we deliberately share with mke2fs: flex packing
shape, ipg rounding, journal size tiers, overhead accounting, uninit-csum
conventions, reserved-inode layout, lost+found sizing (4 blocks), empty
featureless journal, the dir_nlink feature (a stock mke2fs default), and
e2fsck's linear/htree threshold.

## 19. Verification plan & acceptance gates

Phase-1 assets (already running):
- `tools/mkrefs.sh` — reference-image matrix (16 MiB, 305000-block odd
  geometry, 512 MiB, 8 GiB, 64 GiB; htree variants via `e2fsck -fD`),
  every image gated on `e2fsck -fn`.
- `tools/check_vectors.py` — independent reimplementation of §16 + §12
  hashes; all checks pass on all images; dumps `testdata/vectors/`.

Writer gates (phases 3+):
1. `e2fsck -fn` exit 0 on every image any test produces (helper makes this
   one call).
2. Kernel oracle in Linux CI: loop-mount, full-tree diff (bytes, modes,
   owners, timestamps, xattrs, symlink targets, hardlink identity, device
   numbers), 100k-entry directory lookups through the mount (htree proof).
3. Differential reader oracle: our reader walks mke2fs images (this
   matrix) and must agree with debugfs/dumpe2fs; the writer is then
   validated against reader + fsck + kernel — never only against our own
   reader.
4. Structural spot-checks: `dumpe2fs`/`debugfs` field diffs against §6/§7
   tables; `debugfs htree`/`ex` on generated trees; 10-GiB sparse extent
   trees.
5. Determinism goldens: same input twice ⇒ identical bytes (hash-pinned);
   plus sensitivity tests (one mtime change ⇒ different hash).
6. `CheckingSink`: exactly-once coverage of [0, image_len), finality,
   metadata-before-data, ascending data offsets.
7. proptest namespaces (nested dirs, name edge cases, hardlink webs, size
   distributions incl. 0-byte and extent-boundary files, hole maps, and
   whiteout patterns: recursive `remove` of populated subtrees, remove +
   re-add under the same name, hardlinks surviving subtree removal) ⇒
   build ⇒ fsck + reader round-trip.
8. Unit tests per module against `testdata/vectors/` blobs.
9. CI from the first Rust commit: Linux lane (full, incl. loop mounts),
   macOS lane (everything except mounts; e2fsprogs via brew).
10. **Performance gate** (phase 5, then permanent): two committed
   hyperfine benchmarks — (a) ~120k-file small-file tree
   (node_modules-like), (b) ~4 GiB tree containing multi-GiB files — must
   beat `mke2fs -F -t ext4 -d <tree>` wall-clock on the Linux CI runner
   class, with numbers reported in the README. A regression that loses to
   mke2fs fails CI; the goal in §1 is enforced, not aspirational.

**Oracle pinning**: all [verified] claims and the differential matrix are
against **e2fsprogs 1.47.4**; CI pins that exact version (brew pin on
macOS, built-from-tag on Linux — distro drift must not silently change the
oracle). The fsck gate additionally runs an **older vintage, e2fsck
1.46.x**, on every generated image — the public crate's output must be
clean under more than one fsck generation. The mount lane records its
kernel (ubuntu-24.04 runners, linux 6.8.x at time of writing) in the
workflow and README.

Notes: `e2fsck -b <backup>` is *not* a gate (it reports differences even
on pristine mke2fs images); backup correctness is covered by byte-equality
tests against §5's rule plus fsck of images whose primary sb is manually
clobbered (recovery drill, Linux CI).

## 20. Appendix A — ADRs

- **ADR-1 Physical layout / emission order.** Dense metadata prefix
  (sb, GDT, flex-0 metadata, journal, all namespace metadata), then data in
  declaration order; two ascending emission passes (metadata+zeros, then
  data). Rejected: mke2fs-faithful scattered placement (no benefit without
  spinning disks; hurts chunk retirement), fully-front-packed bitmaps/
  itables of *all* flex groups (legal under flex_bg but untested territory
  in kernel heuristics; backups are immovable anyway, so the prefix can
  never be perfect).
- **ADR-1a Backups fresh, not stale.** Byte-identical-to-primary (modulo
  group_nr/csum) beats replicating mke2fs's write-ordering artifacts.
- **ADR-2 flex_bg 16, mke2fs packing shape.** Compatibility-shaped layout,
  nothing exotic; factor is an option only if a need appears.
- **ADR-3 UNINIT flags.** INODE_UNINIT + itable_unused: yes (real runtime
  value, matches mke2fs, csum convention verified). BLOCK_UNINIT: never
  (uniformity; real bitmaps are one constant block per free group).
- **ADR-4 Extent trees: bulk bottom-up, 100% fill.** No incremental-split
  replay; fsck doesn't check fill factor; deterministic and simplest.
- **ADR-5 htree threshold & build.** Threshold = e2fsck `rehash.c`'s rule,
  adopted verbatim (entry bytes < blocksize−24 ⇒ linear, even when that
  means a 2-block linear dir) so there is no divergence window at the
  boundary; bulk build with hash-sorted leaves at 100% fill (oracle uses
  80% — deviation §18.1a); collision bit on equal-hash leaf boundaries.
  Linear fallback is not needed (dir_index is a requirement).
- **ADR-6 Hash signedness fixed: signed (s_flags 0x1).** Matches x86-64
  and Apple-silicon e2fsprogs output; platform char signedness must never
  leak (arm64-Linux mke2fs would write unsigned — we are constant).
- **ADR-7 xattrs: no sharing; canonical sort; single split point.**
  Determinism and simplicity over the ~0 gain of mbcache-style dedup.
- **ADR-8 s_overhead_clusters: computed exactly** (formula §17, verified
  against mke2fs) rather than 0/lazy — statfs correctness with no mount-
  time computation.
- **ADR-9 Holes are absent extents,** never unwritten: no block cost, no
  i_blocks inflation, natural zeros() semantics. Unwritten extents remain
  reader-supported (mke2fs images may contain them).
- **ADR-10 Journal: mke2fs size tiers, early placement (split at
  reserved runs), feature-less empty jsb** (kernel upgrades on first
  mount — verified that's mke2fs behavior too). Physical contiguity was
  considered and dropped: backups every 32768 blocks make a front-placed
  large journal non-contiguous by construction, and jbd2 only needs
  logical contiguity through the extent map.

## Appendix B — resolved research log

| question | resolution | evidence |
|---|---|---|
| Does mke2fs -d build htree? | **No** — even 120k-entry dirs are linear; `e2fsck -fD` is the htree authority | debugfs `stat`/`htree` on ref512/ref8g |
| e2fsck -fD linear/htree threshold | linear iff entry bytes < blocksize−24 (254×16 B entries → 2-block linear; 255 → htree), leaves packed with 20% slack (`htree_slack_percentage`) | boundary bisect at n=250..260 + rehash.c v1.47.4 |
| dx_root/node limits | 507 / 510 with metadata_csum | htree dumps + formula match |
| dx checksum coverage | count-covered entries + dt_reserved + zeroed csum word | brute-forced variants; exact match |
| half_md4 details | signed str2hashbuf, seed = s_hash_seed words LE, hash = buf[1]&~1, minor = buf[2] | 4 debugfs vectors + 300 + 120k leaf names in range |
| inode-bitmap csum length | (ipg+7)/8 bytes, not the block | mismatch reproduced then fixed |
| BLOCK_UNINIT semantics | zero bitmap block + zero csum; set by mke2fs on fully-free groups | refodd/ref64g descs |
| INODE_UNINIT bitmap | entire block zero, csum 0 | ref512 groups 1–3 |
| Reserved inodes 3–7,9,10 | zero body + valid csum_lo (extra_isize 0 ⇒ no csum_hi) | itable bytes + recompute |
| Inode 1 | links 0, times = mkfs time, valid csum | inode_1 vector |
| Empty file | EXTENTS_FL + header {magic, 0 entries, max 4, depth 0} | inode_empty vector |
| Fast/slow symlink boundary | 59 inline / 60 block | sym_59 / sym_60 |
| ee_len = 32768 | valid initialized max-length extent (only >32768 is unwritten) | ref64g journal extents |
| Empty journal features | all zero, no csum_v3, checksum_type 0, s_uuid = fs uuid, BE fields | jsb.bin |
| s_jnl_blocks layout | i_block[0..14] ‖ i_size_high ‖ i_size | sb vs journal inode bytes |
| Backup sb/GDT | GDT identical; sb differs in group_nr, csum, stale counts/state/kbytes | byte diff |
| ipg rounding | up to multiple of 16 (whole itable blocks) | 6963.2→6976, 7625→7632 |
| Minimum last group | none — 1-block last group is valid and fsck-clean | 32769-block build |
| overhead formula | fixed metadata + journal, excluding dir data | exact match at 512 MiB & 64 GiB |
| xattr entry hash | rol5/27 name, rol16/16 value words | real entry match |
| xattr h_hash | debugfs writes 0 (fsck accepts); kernel computes fold — we compute | probe + kernel formula |
| dev encodings | old iff major<256 && minor<256; new packs minor around major | both verified |
| e2fsck -b | reports errors even on pristine mke2fs images — recovery path, not gate | refodd -b run |