am-fs-ext4 0.3.3

Pure-Rust ext4 filesystem driver. Exposes a C ABI (fs_ext4_*) suitable for FFI from C/C++/Go/etc.
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
//! ext4 filesystem creation (mkfs).
//!
//! Writes a minimum-viable ext4 layout that mounts cleanly under both this
//! crate's read path and Linux. Targets a single block group for tiny test
//! volumes and scales to N groups for larger devices. v1 layout:
//!
//! - Block 0 (offset 0..1024)    : zero (boot sector)
//! - Block 0 (offset 1024..2048) : primary superblock
//! - Block 1                     : block group descriptor table
//! - Block 2                     : group 0 block bitmap
//! - Block 3                     : group 0 inode bitmap
//! - Blocks 4..N                 : group 0 inode table
//! - Block (4+itable_blocks)     : root directory data block
//!
//! Features enabled: FILETYPE, EXTENTS, 64BIT, METADATA_CSUM. Journal is
//! intentionally OFF for v1 — the resulting FS mounts cleanly without it.
//!
//! Inode 1 is reserved (unused), inode 2 is the root `/` directory.

use crate::block_io::BlockDevice;
use crate::checksum::{linux_crc32c, Checksummer};
use crate::dir::{self, DirEntryType};
use crate::error::{Error, Result};
use crate::features::{Compat, FsFlavor, Incompat, RoCompat};

const EXT4_MAGIC: u16 = 0xEF53;
const EXT4_VALID_FS: u16 = 0x0001;
const EXT4_ROOT_INO: u32 = 2;
const EXT4_GOOD_OLD_INODE_SIZE: u16 = 128;
const I_EXTRA_ISIZE: u16 = 32; // covers checksum_hi, ctime/mtime/atime extra, crtime
const ROOT_MODE: u16 = 0o40755; // S_IFDIR | 0755
const EXTENT_MAGIC: u16 = 0xF30A;

/// Format `dev` as an ext4 filesystem (extents, 64-bit BGDs, metadata_csum,
/// no journal). Thin wrapper over [`format_filesystem_with_flavor`] for
/// callers that don't care about the dialect.
pub fn format_filesystem(
    dev: &dyn BlockDevice,
    label: Option<&str>,
    uuid: Option<[u8; 16]>,
    size_bytes: u64,
    block_size: u32,
) -> Result<()> {
    format_filesystem_with_flavor(dev, label, uuid, size_bytes, block_size, FsFlavor::Ext4)
}

/// Format `dev` as a filesystem of the requested dialect. The device must be
/// at least large enough to hold the metadata + one root directory block
/// (≈ 200 KiB at 4 KiB blocks with the default geometry).
///
/// `flavor` selects the on-disk feature set:
/// - [`FsFlavor::Ext4`] — extents, 64-bit BGDs, metadata_csum (current default).
/// - [`FsFlavor::Ext2`] — legacy direct/indirect block pointers, 32-byte BGDs,
///   128-byte inodes, no journal, no metadata_csum. Mounts under both this
///   crate and the kernel's single ext4 driver running in ext2-compat mode.
/// - [`FsFlavor::Ext3`] — not yet supported by this formatter (Phase B).
///   Returns `Error::InvalidArgument`. Read+write of pre-existing ext3
///   volumes IS supported via the mount path.
///
/// Arguments:
/// - `label`     — volume name (truncated to 16 bytes; UTF-8 stored verbatim).
/// - `uuid`      — 128-bit volume UUID; if `None`, a random one is generated.
/// - `size_bytes`— total device size to format (must be ≤ device's reported size).
/// - `block_size`— filesystem block size in bytes; must be a power of two,
///   1024..=65536. Typical: 4096.
pub fn format_filesystem_with_flavor(
    dev: &dyn BlockDevice,
    label: Option<&str>,
    uuid: Option<[u8; 16]>,
    size_bytes: u64,
    block_size: u32,
    flavor: FsFlavor,
) -> Result<()> {
    // Per-flavor on-disk choices. ext3 sits between ext2 (no extents, no
    // metadata_csum, 32-byte BGDs) and ext4 (everything on) — same layout
    // as ext2 plus a journal inode + JBD2 log area at the head of the
    // data region.
    let inode_size: u16 = match flavor {
        FsFlavor::Ext2 | FsFlavor::Ext3 => 128,
        FsFlavor::Ext4 => 256,
    };
    let desc_size: u16 = match flavor {
        FsFlavor::Ext2 | FsFlavor::Ext3 => 32, // legacy 32-byte BGDs (no INCOMPAT_64BIT)
        FsFlavor::Ext4 => 64,
    };
    let csum_enabled = matches!(flavor, FsFlavor::Ext4);
    let dir_csum_tail: usize = if csum_enabled { 12 } else { 0 };
    // Journal sizing for ext3. JBD2's documented minimum is 1024 blocks;
    // we pick exactly that for fixture friendliness — at 1 KiB block_size
    // it's a 1 MiB journal that fits in our smallest-realistic test image.
    // A real-world ext3 mkfs would scale this up to ~32 MiB or more.
    let ext3_journal_blocks: u32 = if matches!(flavor, FsFlavor::Ext3) {
        1024
    } else {
        0
    };
    const EXT3_JOURNAL_INODE: u32 = 8;
    if !block_size.is_power_of_two() || !(1024..=65536).contains(&block_size) {
        return Err(Error::InvalidArgument("mkfs: block_size out of range"));
    }
    if size_bytes < block_size as u64 * 64 {
        return Err(Error::InvalidArgument("mkfs: device too small"));
    }
    if !dev.is_writable() {
        return Err(Error::ReadOnly);
    }

    let log_block_size = (block_size.trailing_zeros() as i32 - 10) as u32;

    // Geometry. blocks_per_group is the canonical 8 * block_size (so 32768
    // for 4 KiB blocks). Inodes-per-group is sized so the inode table stays
    // a small fraction of the group; for v1 simplicity we use 8192 — gives
    // 64 KiB / 256 B = 256 inodes per inode-table block, 32 inode-table
    // blocks per group at 4 KiB.
    let blocks_per_group: u32 = 8 * block_size; // 32768 at 4 KiB
    let blocks_count: u64 = size_bytes / block_size as u64;
    if blocks_count < 64 {
        return Err(Error::InvalidArgument("mkfs: too few blocks"));
    }

    // For v1 we only fully initialise group 0; the rest are marked
    // BLOCK_UNINIT + INODE_UNINIT. Restrict to a single group to match the
    // brief's "v1 minimum-viable" scope and to keep block-layout math
    // tractable for the test fixture (32 MiB single-group image).
    if blocks_count > blocks_per_group as u64 {
        return Err(Error::InvalidArgument(
            "mkfs: multi-group volumes not yet supported (v1 single-group only)",
        ));
    }
    let group_count: u64 = 1;

    let inodes_per_group: u32 = 8192;
    let inode_table_blocks: u32 =
        (inodes_per_group as u64 * inode_size as u64).div_ceil(block_size as u64) as u32;

    // first_data_block is 1 for 1 KiB blocks, 0 otherwise — mirrors ext4 formatter.
    let first_data_block: u32 = if block_size == 1024 { 1 } else { 0 };

    // Layout within group 0:
    //   superblock           : block first_data_block (offset 1024 inside it for 4 KiB)
    //   bgd table            : block first_data_block + 1
    //   block bitmap         : block first_data_block + 2
    //   inode bitmap         : block first_data_block + 3
    //   inode table          : blocks first_data_block + 4 .. + 4 + itable_blocks
    //   root dir data block  : block first_data_block + 4 + itable_blocks
    let bgt_block: u64 = first_data_block as u64 + 1;
    let blk_bitmap: u64 = first_data_block as u64 + 2;
    let ino_bitmap: u64 = first_data_block as u64 + 3;
    let inode_table_start: u64 = first_data_block as u64 + 4;
    let root_dir_block: u64 = inode_table_start + inode_table_blocks as u64;
    // ext3 journal data lives immediately after the root dir block. Layout
    // gap-free so the journal inode's i_block tree maps to a single
    // contiguous physical run — the writer (`indirect_mut::plan_contiguous`)
    // is then a one-shot call.
    let journal_data_start: u64 = root_dir_block + 1;
    let journal_data_end: u64 = journal_data_start + ext3_journal_blocks as u64;

    // Sanity: every metadata block + journal (if any) must fit in the device.
    if journal_data_end >= blocks_count {
        return Err(Error::InvalidArgument(
            "mkfs: device too small for layout (journal won't fit)",
        ));
    }

    // e2fsprogs convention: inodes 1..s_first_ino-1 are all "reserved-used"
    // in the bitmap regardless of whether the FS actually populates them.
    // build_superblock pins s_first_ino = 11, so inodes 1..=10 are all
    // marked used. ext3's journal at inode 8 falls inside that range, so
    // no flavor-specific bookkeeping is needed for free_inodes.
    const RESERVED_INODES: u32 = 10;
    let used_blocks: u64 = if matches!(flavor, FsFlavor::Ext3) {
        journal_data_end // blocks 0..journal_data_end
    } else {
        root_dir_block + 1
    };
    let free_blocks: u64 = blocks_count - used_blocks;
    let free_inodes: u32 = inodes_per_group - RESERVED_INODES;

    let uuid = uuid.unwrap_or_else(generate_uuid);

    // ----- Superblock -------------------------------------------------------
    // Build the 1024-byte primary superblock then patch its checksum.
    let journal_inum_for_sb: u32 = if matches!(flavor, FsFlavor::Ext3) {
        EXT3_JOURNAL_INODE
    } else {
        0
    };
    let mut sb = build_superblock(
        blocks_count,
        free_blocks,
        free_inodes,
        first_data_block,
        log_block_size,
        blocks_per_group,
        inodes_per_group,
        &uuid,
        label.unwrap_or(""),
        flavor,
        inode_size,
        desc_size,
        journal_inum_for_sb,
    );
    // Superblock CRC32C: seed = ~0, covers bytes [0..0x3FC]. Only patched
    // when the volume advertises METADATA_CSUM — ext2/3 leave the slot zero.
    if csum_enabled {
        let sb_csum = linux_crc32c(!0, &sb[..0x3FC]);
        sb[0x3FC..0x400].copy_from_slice(&sb_csum.to_le_bytes());
    }

    // Mount-time checksummer, used for BGD + inode + dir-block CRCs.
    let csum_seed = linux_crc32c(!0, &uuid);
    let csum = Checksummer {
        seed: csum_seed,
        enabled: csum_enabled,
    };

    // ----- Block group descriptor (group 0 only) ---------------------------
    let mut bgd = vec![0u8; desc_size as usize];
    write_bgd_group0(
        &mut bgd,
        blk_bitmap,
        ino_bitmap,
        inode_table_start,
        free_blocks as u32,
        free_inodes,
        /* used_dirs */ 1, // root dir lives in group 0
        desc_size,
    );
    // (BGD CRC is patched LATER, once the bitmap csums have been written
    // into 0x18/0x1A/0x38/0x3A — otherwise fsck.ext4 -fnv reports
    // "Group 0 inode/block bitmap does not match checksum" because the
    // BGD it CRCs over carries stale bitmap-csum slots.)

    // ----- Block bitmap (group 0) ------------------------------------------
    // Bits 0..used_blocks are used (for ext3 this includes the journal
    // data run; for ext2/ext4 it stops at root_dir_block).
    let mut block_bitmap = vec![0u8; block_size as usize];
    for b in 0..used_blocks {
        let byte = (b / 8) as usize;
        let bit = (b % 8) as u8;
        if byte < block_bitmap.len() {
            block_bitmap[byte] |= 1 << bit;
        }
    }
    // Tail-pad: blocks past `blocks_count` (within the group's bitmap window)
    // are flagged "used" so the allocator never tries them. blocks_per_group
    // bits cover the bitmap's logical span.
    for b in blocks_count..blocks_per_group as u64 {
        let byte = (b / 8) as usize;
        if byte >= block_bitmap.len() {
            break;
        }
        let bit = (b % 8) as u8;
        block_bitmap[byte] |= 1 << bit;
    }

    // ----- Inode bitmap (group 0) ------------------------------------------
    // ext4 inode numbers are 1-based; bit i = inode (i+1). e2fsprogs marks
    // every reserved inode (1..s_first_ino) as used so fsck.ext4 -fnv
    // doesn't report "Inode bitmap differences: +(3--10)" — fix that
    // by setting bits 0..RESERVED_INODES (inodes 1..=10). Then tail-pad
    // bits past inodes_per_group up to the bitmap block boundary so the
    // bitmap checksum matches what e2fsck recomputes (which assumes all
    // out-of-range bits are 1 — "Padding at end of inode bitmap is not set").
    let mut inode_bitmap = vec![0u8; block_size as usize];
    for i in 0..RESERVED_INODES {
        inode_bitmap[(i / 8) as usize] |= 1 << (i % 8);
    }
    let bitmap_bits = block_size * 8;
    for i in inodes_per_group..bitmap_bits {
        inode_bitmap[(i / 8) as usize] |= 1 << (i % 8);
    }

    // ----- Inode table (group 0) — only inode 2 has content ----------------
    let mut inode_table = vec![0u8; inode_table_blocks as usize * block_size as usize];
    // Inode 2 lives at byte offset (2-1) * inode_size.
    let root_inode_off = (EXT4_ROOT_INO as usize - 1) * inode_size as usize;
    write_root_inode(
        &mut inode_table[root_inode_off..root_inode_off + inode_size as usize],
        root_dir_block,
        block_size,
        flavor,
        inode_size,
    );
    // Inode CRC: only when metadata_csum is on (the lo/hi fields live past
    // byte 0x7C / 0x82 which are only present in 256-byte inodes anyway).
    if csum_enabled {
        let slot = &mut inode_table[root_inode_off..root_inode_off + inode_size as usize];
        if let Some((lo, hi)) = csum.compute_inode_checksum(EXT4_ROOT_INO, 0, slot) {
            slot[0x7C..0x7E].copy_from_slice(&lo.to_le_bytes());
            slot[0x82..0x84].copy_from_slice(&hi.to_le_bytes());
        }
    }

    // ----- Journal inode (ext3 only): inode 8 -------------------------------
    // Built from the same `indirect_mut::plan_contiguous` primitive every
    // ext2/3 file write uses. The journal lives at a contiguous physical
    // run of `ext3_journal_blocks` starting at `journal_data_start`, so
    // the planner produces a single (`i_block`, indirect_block_writes)
    // pair we splice in directly.
    let mut journal_indirect_writes: Vec<(u64, Vec<u8>)> = Vec::new();
    if matches!(flavor, FsFlavor::Ext3) {
        let jino_off = (EXT3_JOURNAL_INODE as usize - 1) * inode_size as usize;
        // Allocate any indirect-tree blocks immediately AFTER the journal
        // data run so they don't collide with metadata/data the bitmap
        // already reserved. For 1024-block journals at 1 KiB blocks the
        // tree only needs 1 single-indirect block (12 direct + 256 in single
        // tier covers 268; for 1024 we'll spill into double — count the
        // exact need via `count_indirect_blocks`).
        let n_indirect =
            crate::indirect_mut::count_indirect_blocks(ext3_journal_blocks, block_size);
        let journal_indirect_start: u64 = journal_data_end;
        let mut next_indirect = journal_indirect_start;

        // Track the indirect-tree blocks so we can mark them allocated in
        // the bitmap a few lines below.
        let plan = crate::indirect_mut::plan_contiguous(
            ext3_journal_blocks,
            journal_data_start,
            block_size,
            || {
                let v = next_indirect;
                next_indirect += 1;
                Ok(v)
            },
        )?;
        write_journal_inode(
            &mut inode_table[jino_off..jino_off + inode_size as usize],
            ext3_journal_blocks as u64 * block_size as u64,
            (ext3_journal_blocks as u64 + n_indirect) * block_size as u64 / 512,
            &plan.i_block,
        );
        journal_indirect_writes = plan.block_writes;

        // Mark indirect-tree blocks as allocated in the block bitmap so
        // they don't get reused. Data blocks were already marked above as
        // part of `used_blocks`.
        for ib in &plan.indirect_blocks_allocated {
            let byte = (*ib / 8) as usize;
            let bit = (*ib % 8) as u8;
            if byte < block_bitmap.len() {
                block_bitmap[byte] |= 1 << bit;
            }
        }
    }

    // ----- Root directory data block ---------------------------------------
    // Two entries (`.`, `..`). On metadata_csum volumes the last 12 bytes
    // are reserved for the `ext4_dir_entry_tail` csum slot; ext2/3 reclaim
    // those bytes and the final `..` entry's `rec_len` extends to end-of-
    // block instead.
    let mut root_dir = vec![0u8; block_size as usize];
    let usable = block_size as usize - dir_csum_tail;
    // Bootstrap: one big tombstone entry that fills the usable region. The
    // dir helper splits this on each add.
    root_dir[0..4].copy_from_slice(&0u32.to_le_bytes()); // inode = 0 (tombstone)
    root_dir[4..6].copy_from_slice(&(usable as u16).to_le_bytes());
    // name_len + file_type already zero.

    dir::add_entry_to_block(
        &mut root_dir,
        EXT4_ROOT_INO,
        b".",
        DirEntryType::Directory,
        true,
        dir_csum_tail,
    )?;
    dir::add_entry_to_block(
        &mut root_dir,
        EXT4_ROOT_INO,
        b"..",
        DirEntryType::Directory,
        true,
        dir_csum_tail,
    )?;

    // Csum tail + CRC32C — only on metadata_csum volumes.
    if csum_enabled {
        let end = root_dir.len();
        root_dir[end - 12..end - 8].copy_from_slice(&0u32.to_le_bytes()); // inode
        root_dir[end - 8..end - 6].copy_from_slice(&12u16.to_le_bytes()); // rec_len
        root_dir[end - 6] = 0; // name_len
        root_dir[end - 5] = 0xDE; // file_type marker
        root_dir[end - 4..end].copy_from_slice(&0u32.to_le_bytes()); // csum slot

        let mut c = linux_crc32c(csum.seed, &EXT4_ROOT_INO.to_le_bytes());
        c = linux_crc32c(c, &0u32.to_le_bytes()); // generation = 0
        c = linux_crc32c(c, &root_dir[..root_dir.len() - 12]);
        root_dir[end - 4..end].copy_from_slice(&c.to_le_bytes());
    }

    // ----- BGD bitmap-csums + final BGD csum -------------------------------
    // Per Linux `fs/ext4/bitmap.c::ext4_{block,inode}_bitmap_csum_set`:
    //   bb_csum = crc32c(seed, bitmap[0..blocks_per_group / 8])
    //   ib_csum = crc32c(seed, bitmap[0..inodes_per_group / 8])
    // Stored split into 16-bit lo (BGD 0x18 / 0x1A) + 16-bit hi
    // (BGD 0x38 / 0x3A; 64-byte-desc only). e2fsck recomputes both and
    // refuses the volume if the slots don't match. Skipped when csums
    // are off (ext2) so the slots stay zero, matching e2fsprogs.
    if csum_enabled {
        let bb_sz = (blocks_per_group as usize) / 8;
        let ib_sz = (inodes_per_group as usize) / 8;
        let bb_csum = csum.crc(&block_bitmap[..bb_sz.min(block_bitmap.len())]);
        let ib_csum = csum.crc(&inode_bitmap[..ib_sz.min(inode_bitmap.len())]);
        bgd[0x18..0x1A].copy_from_slice(&((bb_csum & 0xFFFF) as u16).to_le_bytes());
        bgd[0x1A..0x1C].copy_from_slice(&((ib_csum & 0xFFFF) as u16).to_le_bytes());
        if desc_size >= 64 {
            bgd[0x38..0x3A].copy_from_slice(&((bb_csum >> 16) as u16).to_le_bytes());
            bgd[0x3A..0x3C].copy_from_slice(&((ib_csum >> 16) as u16).to_le_bytes());
        }

        // BGD CRC slot lives at 0x1E..0x20. Computed over the full
        // descriptor with the slot itself zeroed, prefixed by the LE
        // group number (0 for group 0). Folded down to its low 16 bits.
        let mut tmp = bgd.clone();
        tmp[0x1E] = 0;
        tmp[0x1F] = 0;
        let bgd_csum_full = csum.crc_with_prefix(0u32, &tmp);
        let bgd_csum16 = (bgd_csum_full & 0xFFFF) as u16;
        bgd[0x1E..0x20].copy_from_slice(&bgd_csum16.to_le_bytes());
    }

    // ----- Write everything out --------------------------------------------
    // Header zone: zero out block 0 (boot sector) and the SB block. For
    // block_size >= 2048 the SB shares block 0; for 1 KiB blocks the SB
    // occupies its own block 1 — the `.max(2048)` ensures we wipe both
    // bootloader region and SB block in either case so we don't carry over
    // stale image bytes (which sabotaged early mount experiments).
    let header_zero_len = (block_size as usize).max(2048);
    let zeros = vec![0u8; header_zero_len];
    dev.write_at(0, &zeros)?;
    // Primary superblock always lives at byte offset 1024 regardless of
    // block_size — for 4 KiB blocks that's mid-block-0; for 1 KiB blocks
    // it's the entirety of block 1.
    dev.write_at(crate::superblock::SUPERBLOCK_OFFSET, &sb)?;
    dev.flush()?;

    // BGT block (zeroed, then group 0's descriptor copied in at offset 0).
    let mut bgt_block_buf = vec![0u8; block_size as usize];
    bgt_block_buf[..desc_size as usize].copy_from_slice(&bgd);
    dev.write_at(bgt_block * block_size as u64, &bgt_block_buf)?;

    dev.write_at(blk_bitmap * block_size as u64, &block_bitmap)?;
    dev.write_at(ino_bitmap * block_size as u64, &inode_bitmap)?;
    dev.write_at(inode_table_start * block_size as u64, &inode_table)?;
    dev.write_at(root_dir_block * block_size as u64, &root_dir)?;

    // ext3 journal: write the JBD2 superblock at the head of the journal
    // data run, leave the rest as zeros (clean state — `s_start = 0` in
    // the JSB matches "no pending transactions"), then persist the
    // indirect-tree blocks the journal inode points at.
    if matches!(flavor, FsFlavor::Ext3) {
        let jsb_block = build_jbd2_superblock(block_size, ext3_journal_blocks, &uuid);
        dev.write_at(journal_data_start * block_size as u64, &jsb_block)?;
        for (blk, buf) in &journal_indirect_writes {
            dev.write_at(blk * block_size as u64, buf)?;
        }
    }

    dev.flush()?;
    let _ = group_count; // single-group v1; future multi-group will use this
    Ok(())
}

/// Build a clean JBD2 v2 superblock for a fresh ext3 journal. Layout per
/// `jbd2.rs` module docs (big-endian throughout, magic 0xc03b3998 + V2
/// block_type = 4). All fields zero except the spec-required ones:
/// `block_size`, `max_len`, `first = 1` (block 0 is the JSB itself),
/// `sequence = 1` (replay starts here on first mount), `start = 0`
/// (journal is clean — nothing to replay), per-volume `uuid`.
fn build_jbd2_superblock(block_size: u32, max_len: u32, uuid: &[u8; 16]) -> Vec<u8> {
    let mut buf = vec![0u8; block_size as usize];
    // Header: magic + block_type + h_sequence (big-endian).
    buf[0x00..0x04].copy_from_slice(&crate::jbd2::JBD2_MAGIC_NUMBER.to_be_bytes());
    buf[0x04..0x08].copy_from_slice(&crate::jbd2::JBD2_SUPERBLOCK_V2.to_be_bytes());
    buf[0x08..0x0C].copy_from_slice(&1u32.to_be_bytes()); // h_sequence
                                                          // s_blocksize, s_maxlen, s_first, s_sequence, s_start, s_errno.
    buf[0x0C..0x10].copy_from_slice(&block_size.to_be_bytes());
    buf[0x10..0x14].copy_from_slice(&max_len.to_be_bytes());
    buf[0x14..0x18].copy_from_slice(&1u32.to_be_bytes()); // s_first = 1
    buf[0x18..0x1C].copy_from_slice(&1u32.to_be_bytes()); // s_sequence = 1
    buf[0x1C..0x20].copy_from_slice(&0u32.to_be_bytes()); // s_start = 0 (clean)
                                                          // s_errno = 0 (already zero from vec init)
                                                          // V2 fields — leave feature bits all-zero (no compression / 64bit /
                                                          // csum-v2 etc.). UUID at 0x30 + s_nr_users = 1 at 0x40.
    buf[0x30..0x40].copy_from_slice(uuid);
    buf[0x40..0x44].copy_from_slice(&1u32.to_be_bytes()); // s_nr_users = 1
    buf
}

/// Write the journal-inode image (typically inode 8) for ext3. The
/// journal inode is mode-less (`i_mode = 0`), `i_links_count = 1`, and
/// `i_block` is filled by the caller from `indirect_mut::plan_contiguous`
/// — the journal data lives at a contiguous physical run, so the same
/// indirect-tree primitive every regular ext2/3 file uses works here.
///
/// `i_size` covers ONLY the journal data blocks (not the indirect-tree
/// metadata blocks, which add to `i_blocks` but not `i_size`).
fn write_journal_inode(slot: &mut [u8], size_bytes: u64, blocks_512: u64, i_block: &[u8; 60]) {
    // i_mode = 0 — journal has no POSIX type. The kernel checks ino number,
    // not mode, when locating it. Linux mkfs writes 0 here.
    slot[0x00..0x02].copy_from_slice(&0u16.to_le_bytes());
    // i_size_lo
    slot[0x04..0x08].copy_from_slice(&((size_bytes & 0xFFFF_FFFF) as u32).to_le_bytes());
    // i_links_count = 1 (the journal-inode-table reference itself).
    slot[0x1A..0x1C].copy_from_slice(&1u16.to_le_bytes());
    // i_blocks_lo: 512-byte sectors = data + indirect-tree blocks.
    slot[0x1C..0x20].copy_from_slice(&((blocks_512 & 0xFFFF_FFFF) as u32).to_le_bytes());
    // i_flags = 0 (no EXTENTS_FL — ext3 journal is always indirect).
    slot[0x20..0x24].copy_from_slice(&0u32.to_le_bytes());
    // i_block: the indirect-tree root we built externally.
    slot[0x28..0x28 + 60].copy_from_slice(i_block);
    // i_size_hi at 0x6C — only meaningful past the 4 GiB cap; our 1024-block
    // journal at any block_size stays well under it.
}

/// 16 random bytes from `/dev/urandom`, falling back to a time-seeded LCG if
/// the device is unavailable. Sets the v4 UUID layout bits.
fn generate_uuid() -> [u8; 16] {
    let mut out = [0u8; 16];
    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
        use std::io::Read;
        if f.read_exact(&mut out).is_ok() {
            // RFC 4122 v4: top nibble of byte 6 = 0x4, top two bits of byte 8 = 0b10.
            out[6] = (out[6] & 0x0F) | 0x40;
            out[8] = (out[8] & 0x3F) | 0x80;
            return out;
        }
    }
    // Fallback: deterministic mix of nanos + pid. Not cryptographic — but
    // /dev/urandom is universally available on Darwin and Linux so this
    // path effectively only fires inside aggressively sandboxed tests.
    let mut state = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0xDEADBEEF)
        ^ (std::process::id() as u64).wrapping_mul(0x9E3779B97F4A7C15);
    for b in out.iter_mut() {
        state = state
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        *b = (state >> 56) as u8;
    }
    out[6] = (out[6] & 0x0F) | 0x40;
    out[8] = (out[8] & 0x3F) | 0x80;
    out
}

#[allow(clippy::too_many_arguments)]
fn build_superblock(
    blocks_count: u64,
    free_blocks: u64,
    free_inodes: u32,
    first_data_block: u32,
    log_block_size: u32,
    blocks_per_group: u32,
    inodes_per_group: u32,
    uuid: &[u8; 16],
    label: &str,
    flavor: FsFlavor,
    inode_size: u16,
    desc_size: u16,
    journal_inum: u32,
) -> Vec<u8> {
    // Single-group v1: total inodes = inodes_per_group.
    let inodes_count: u32 = inodes_per_group;
    let mut sb = vec![0u8; 1024];

    let blocks_lo = (blocks_count & 0xFFFF_FFFF) as u32;
    let blocks_hi = (blocks_count >> 32) as u32;
    let free_lo = (free_blocks & 0xFFFF_FFFF) as u32;
    let free_hi = (free_blocks >> 32) as u32;

    sb[0x00..0x04].copy_from_slice(&inodes_count.to_le_bytes());
    sb[0x04..0x08].copy_from_slice(&blocks_lo.to_le_bytes());
    // 0x08..0x0C s_r_blocks_count_lo (reserved blocks) — 0 is fine.
    sb[0x0C..0x10].copy_from_slice(&free_lo.to_le_bytes());
    sb[0x10..0x14].copy_from_slice(&free_inodes.to_le_bytes());
    sb[0x14..0x18].copy_from_slice(&first_data_block.to_le_bytes());
    sb[0x18..0x1C].copy_from_slice(&log_block_size.to_le_bytes());
    // 0x1C..0x20 s_log_cluster_size — must mirror s_log_block_size when bigalloc off.
    sb[0x1C..0x20].copy_from_slice(&log_block_size.to_le_bytes());
    sb[0x20..0x24].copy_from_slice(&blocks_per_group.to_le_bytes());
    // 0x24..0x28 s_clusters_per_group (mirrors blocks_per_group, no bigalloc).
    sb[0x24..0x28].copy_from_slice(&blocks_per_group.to_le_bytes());
    sb[0x28..0x2C].copy_from_slice(&inodes_per_group.to_le_bytes());

    // s_mtime, s_wtime stay 0 — ext4 spec allows; no Y2038 trap here.
    // s_mnt_count = 0, s_max_mnt_count = 0xFFFF (no fsck nag).
    sb[0x34..0x36].copy_from_slice(&0u16.to_le_bytes()); // mnt_count
    sb[0x36..0x38].copy_from_slice(&0xFFFFu16.to_le_bytes()); // max_mnt_count

    sb[0x38..0x3A].copy_from_slice(&EXT4_MAGIC.to_le_bytes());
    sb[0x3A..0x3C].copy_from_slice(&EXT4_VALID_FS.to_le_bytes()); // state
    sb[0x3C..0x3E].copy_from_slice(&1u16.to_le_bytes()); // errors = continue
    sb[0x3E..0x40].copy_from_slice(&0u16.to_le_bytes()); // minor_rev_level

    // 0x40..0x44 s_lastcheck = 0
    // 0x44..0x48 s_checkinterval = 0
    sb[0x48..0x4C].copy_from_slice(&0u32.to_le_bytes()); // creator_os = 0 (Linux)

    sb[0x4C..0x50].copy_from_slice(&1u32.to_le_bytes()); // rev_level = DYNAMIC
    sb[0x50..0x52].copy_from_slice(&0u16.to_le_bytes()); // def_resuid
    sb[0x52..0x54].copy_from_slice(&0u16.to_le_bytes()); // def_resgid

    // Dynamic-rev fields.
    sb[0x54..0x58].copy_from_slice(&11u32.to_le_bytes()); // first_ino (>= 11 reserved-end)
    sb[0x58..0x5A].copy_from_slice(&inode_size.to_le_bytes());
    sb[0x5A..0x5C].copy_from_slice(&0u16.to_le_bytes()); // block_group_nr (this SB's group = 0)

    // Per-flavor feature bits. Ext2 advertises FILETYPE only (so directory
    // entries carry a file_type byte); ext4 layers on EXTENTS + 64BIT for
    // wide block addressing and METADATA_CSUM for the on-disk CRCs the
    // checksum_type=crc32c byte selects below.
    let (feat_compat, feat_incompat, feat_ro_compat): (u32, u32, u32) = match flavor {
        FsFlavor::Ext2 => (0u32, Incompat::FILETYPE.bits(), 0u32),
        FsFlavor::Ext3 => (
            // HAS_JOURNAL signals that `s_journal_inum` (set below) names a
            // valid journal inode. Mounters that don't grok HAS_JOURNAL will
            // (correctly) refuse the volume — historically that's how ext2-
            // only drivers stayed safe in the face of a dirty ext3 journal.
            Compat::HAS_JOURNAL.bits(),
            Incompat::FILETYPE.bits(),
            0u32,
        ),
        FsFlavor::Ext4 => (
            0u32,
            Incompat::FILETYPE.bits() | Incompat::EXTENTS.bits() | Incompat::BIT64.bits(),
            RoCompat::METADATA_CSUM.bits(),
        ),
    };
    sb[0x5C..0x60].copy_from_slice(&feat_compat.to_le_bytes());
    sb[0x60..0x64].copy_from_slice(&feat_incompat.to_le_bytes());
    sb[0x64..0x68].copy_from_slice(&feat_ro_compat.to_le_bytes());

    sb[0x68..0x78].copy_from_slice(uuid);

    // Volume label — 16 bytes, NUL-padded.
    let lbl = label.as_bytes();
    let n = lbl.len().min(16);
    sb[0x78..0x78 + n].copy_from_slice(&lbl[..n]);

    // s_last_mounted (64 bytes at 0x88) stays zero.
    // Algorithm bits / prealloc / reserved (0xC8..0xD8) zero.

    // s_journal_inum at 0xE0..0xE4 — set to inode 8 for ext3 (HAS_JOURNAL),
    // zero for everything else. (The 0xD8 region holds algorithm bits +
    // prealloc counters, NOT the journal inode pointer.)
    sb[0xE0..0xE4].copy_from_slice(&journal_inum.to_le_bytes());
    // 0xDC..0xE0 s_journal_dev  — 0.
    // 0xE0..0xE4 s_last_orphan  — 0.
    // 0xE4..0xF4 s_hash_seed[4] — pick a stable nonzero seed. (Only matters
    // if HTree is in play; we don't set DIR_INDEX, but ext4 formatter still seeds
    // these so tools don't whine.)
    sb[0xE4..0xE8].copy_from_slice(&0xC1A2B3C4u32.to_le_bytes());
    sb[0xE8..0xEC].copy_from_slice(&0xD5E6F7A8u32.to_le_bytes());
    sb[0xEC..0xF0].copy_from_slice(&0xB9CADBECu32.to_le_bytes());
    sb[0xF0..0xF4].copy_from_slice(&0xFD0E1F2Au32.to_le_bytes());

    sb[0xFC] = 1; // s_def_hash_version = HALF_MD4
                  // 0xFD reserved_char_pad
                  // 0xFE..0x100 s_desc_size
                  // s_desc_size: only meaningful when INCOMPAT_64BIT is set (i.e. ext4).
                  // Spec says leave 0 for legacy 32-byte BGDs; the reader treats 0 as 32.
    let on_disk_desc_size: u16 = if matches!(flavor, FsFlavor::Ext4) {
        desc_size
    } else {
        0
    };
    sb[0xFE..0x100].copy_from_slice(&on_disk_desc_size.to_le_bytes());

    // 0x100..0x104 s_default_mount_opts = 0
    // 0x104..0x108 s_first_meta_bg     = 0 (no META_BG)
    // 0x108..0x10C s_mkfs_time         = 0
    // 0x10C..0x14C s_jnl_blocks[17]    = 0

    // s_blocks_count_hi at 0x150 (64BIT).
    sb[0x150..0x154].copy_from_slice(&blocks_hi.to_le_bytes());
    // s_r_blocks_count_hi 0x154 = 0.
    sb[0x158..0x15C].copy_from_slice(&free_hi.to_le_bytes()); // free_blocks_count_hi

    // s_min_extra_isize / s_want_extra_isize (0x15C / 0x15E): only meaningful
    // when on-disk inodes carry the post-128 extra section (ext4 with
    // 256-byte inodes). Ext2's 128-byte inodes leave both fields zero.
    if inode_size >= 160 {
        sb[0x15C..0x15E].copy_from_slice(&I_EXTRA_ISIZE.to_le_bytes());
        sb[0x15E..0x160].copy_from_slice(&I_EXTRA_ISIZE.to_le_bytes());
    }

    // s_flags 0x160..0x164: bit 0 = signed dirhash (matches ext4 formatter default).
    sb[0x160..0x164].copy_from_slice(&0x1u32.to_le_bytes());

    // 0x164..0x166 s_raid_stride / 0x166..0x168 mmp_update_interval / etc — zero.

    // s_kbytes_written at 0x148..0x150 = 0 (no writes yet).

    // s_inode_size at 0x58 already set; s_min_extra_isize done above.

    // s_log_groups_per_flex (0x174) — 0 means flex_bg disabled.

    // s_checksum_type at 0x175 = 1 (crc32c) when METADATA_CSUM is on. Ext2
    // leaves it 0 (no algorithm) since no on-disk checksums are written.
    if matches!(flavor, FsFlavor::Ext4) {
        sb[0x175] = 1;
    }

    // s_encryption_level (0x176) reserved-pad (0x177) zero.

    // s_kbytes_written (0x148): 0.

    // s_snapshot_inum / list / id_xattr — zero.

    // s_creator_os already 0.

    // Leave s_checksum (0x3FC..0x400) as zero — caller patches it.
    sb
}

#[allow(clippy::too_many_arguments)]
fn write_bgd_group0(
    out: &mut [u8],
    block_bitmap_block: u64,
    inode_bitmap_block: u64,
    inode_table_block: u64,
    free_blocks: u32,
    free_inodes: u32,
    used_dirs: u32,
    desc_size: u16,
) {
    // Lo halves: present in both 32-byte and 64-byte BGD layouts.
    out[0x00..0x04].copy_from_slice(&(block_bitmap_block as u32).to_le_bytes());
    out[0x04..0x08].copy_from_slice(&(inode_bitmap_block as u32).to_le_bytes());
    out[0x08..0x0C].copy_from_slice(&(inode_table_block as u32).to_le_bytes());
    out[0x0C..0x0E].copy_from_slice(&(free_blocks as u16).to_le_bytes());
    out[0x0E..0x10].copy_from_slice(&(free_inodes as u16).to_le_bytes());
    out[0x10..0x12].copy_from_slice(&(used_dirs as u16).to_le_bytes());
    out[0x12..0x14].copy_from_slice(&0u16.to_le_bytes()); // bg_flags = 0 (initialised)
                                                          // 0x14..0x18 exclude_bitmap reserved.
                                                          // 0x18..0x1A block_bitmap_csum_lo, 0x1A..0x1C inode_bitmap_csum_lo — leave 0;
                                                          // only validated on metadata_csum volumes.
    out[0x1C..0x1E].copy_from_slice(&0u16.to_le_bytes()); // itable_unused_lo
                                                          // 0x1E..0x20 checksum — patched after struct is complete.

    // 64-bit hi halves only present in the 64-byte BGD layout.
    if desc_size >= 64 {
        out[0x20..0x24].copy_from_slice(&((block_bitmap_block >> 32) as u32).to_le_bytes());
        out[0x24..0x28].copy_from_slice(&((inode_bitmap_block >> 32) as u32).to_le_bytes());
        out[0x28..0x2C].copy_from_slice(&((inode_table_block >> 32) as u32).to_le_bytes());
        out[0x2C..0x2E].copy_from_slice(&0u16.to_le_bytes()); // free_blocks_hi
        out[0x2E..0x30].copy_from_slice(&0u16.to_le_bytes()); // free_inodes_hi
        out[0x30..0x32].copy_from_slice(&0u16.to_le_bytes()); // used_dirs_hi
        out[0x32..0x34].copy_from_slice(&0u16.to_le_bytes()); // itable_unused_hi
                                                              // 0x34..0x38 reserved
        out[0x38..0x3A].copy_from_slice(&0u16.to_le_bytes()); // bb_csum_hi
        out[0x3A..0x3C].copy_from_slice(&0u16.to_le_bytes()); // ib_csum_hi
                                                              // 0x3C..0x40 reserved
    }
}

/// Write the root directory inode (ino 2). Layout depends on `flavor`:
/// ext4 uses an extent header pointing at `root_dir_block`; ext2/3 use the
/// legacy direct/indirect scheme with `i_block[0] = root_dir_block`.
/// Caller patches the CRC slots afterwards on metadata_csum volumes.
fn write_root_inode(
    slot: &mut [u8],
    root_dir_block: u64,
    block_size: u32,
    flavor: FsFlavor,
    inode_size: u16,
) {
    // i_mode
    slot[0x00..0x02].copy_from_slice(&ROOT_MODE.to_le_bytes());
    // i_uid_lo, i_size_lo. Size = one directory data block.
    slot[0x04..0x08].copy_from_slice(&(block_size).to_le_bytes());
    // i_atime, i_ctime, i_mtime — left zero; mkfs convention but not required.
    // i_links_count = 2 (`.` and `..`)
    slot[0x1A..0x1C].copy_from_slice(&2u16.to_le_bytes());
    // i_blocks_lo: 512-byte units. One 4 KiB block = 8 sectors.
    let i_blocks = block_size / 512;
    slot[0x1C..0x20].copy_from_slice(&i_blocks.to_le_bytes());

    if flavor.uses_extents() {
        // i_flags: EXT4_EXTENTS_FL
        slot[0x20..0x24].copy_from_slice(&crate::inode::InodeFlags::EXTENTS.bits().to_le_bytes());

        // i_block[60]: extent header + one extent.
        // Header: magic, entries=1, max=4, depth=0, generation=0
        slot[0x28..0x2A].copy_from_slice(&EXTENT_MAGIC.to_le_bytes());
        slot[0x2A..0x2C].copy_from_slice(&1u16.to_le_bytes()); // entries
        slot[0x2C..0x2E].copy_from_slice(&4u16.to_le_bytes()); // max
        slot[0x2E..0x30].copy_from_slice(&0u16.to_le_bytes()); // depth
        slot[0x30..0x34].copy_from_slice(&0u32.to_le_bytes()); // generation
                                                               // First extent at 0x34..0x40:
                                                               //   ee_block (logical=0), ee_len=1, ee_start_hi, ee_start_lo
        slot[0x34..0x38].copy_from_slice(&0u32.to_le_bytes()); // ee_block
        slot[0x38..0x3A].copy_from_slice(&1u16.to_le_bytes()); // ee_len
        slot[0x3A..0x3C].copy_from_slice(&((root_dir_block >> 32) as u16).to_le_bytes()); // ee_start_hi
        slot[0x3C..0x40].copy_from_slice(&(root_dir_block as u32).to_le_bytes());
    // ee_start_lo
    // Remaining 0x40..0x64 in i_block region stays zero (padding).
    } else {
        // ext2/3: i_flags clear, i_block[0] = direct pointer to the root dir
        // data block. ext2/3 cap addresses at 32 bits, so the high half of
        // `root_dir_block` is asserted zero by the geometry the caller picked
        // (single-group fixtures fit comfortably in u32).
        debug_assert!(
            root_dir_block <= u32::MAX as u64,
            "ext2 i_block pointer overflow"
        );
        slot[0x20..0x24].copy_from_slice(&0u32.to_le_bytes()); // i_flags = 0
        slot[0x28..0x2C].copy_from_slice(&(root_dir_block as u32).to_le_bytes());
        // i_block[1..15] stays zero — no further direct/indirect pointers
        // since the directory fits in one block.
    }

    slot[0x64..0x68].copy_from_slice(&0u32.to_le_bytes()); // i_generation
                                                           // i_file_acl_lo (0x68), i_size_hi (0x6C), obso_faddr (0x70) — zero.
                                                           // i_blocks_hi (0x74), i_file_acl_hi (0x76), i_uid_hi (0x78), i_gid_hi (0x7A)
                                                           // — zero. i_checksum_lo at 0x7C — caller patches when csum is on.

    // Extra section (only present when on-disk inode size >= 160 bytes).
    // Ext2's 128-byte inodes skip this entirely.
    if inode_size >= 160 && slot.len() > 0x80 {
        slot[0x80..0x82].copy_from_slice(&I_EXTRA_ISIZE.to_le_bytes());
        // i_checksum_hi (0x82..0x84) patched by caller on csum volumes.
        // 0x84..0x98 *_extra timestamps + crtime — zero.
    }
}