Skip to main content

microsandbox_image/ext4/
resizer.rs

1//! Offline grow-only resizer for ext4 upper images produced by this crate's formatter.
2//!
3//! The resizer parses and strictly validates the primary superblock (it refuses anything the formatter did not write), then appends whole block groups: per-group bitmaps, backup
4//! superblock + GDT copies in new sparse_super groups, descriptors appended to the primary GDT and every backup GDT, and finally the updated primary superblock. Because the
5//! formatter reserves `RESERVED_GDT_BLOCKS` after the GDT, descriptors can extend into that reserved span without moving any existing metadata: `gdt_blocks +
6//! s_reserved_gdt_blocks` stays constant across grows.
7//!
8//! Images whose guest was stopped without unmounting carry `EXT4_FEATURE_INCOMPAT_RECOVER` plus a pending jbd2 log; those are recovered first (see the [`jbd2`](super::jbd2)
9//! module) and then grown as clean images.
10
11use std::fs::{File, OpenOptions};
12use std::io::{Read, Seek, SeekFrom, Write};
13use std::path::Path;
14
15use super::format::{
16    EXT4_BG_INODE_ZEROED, EXT4_BLOCK_SIZE, EXT4_BLOCKS_PER_GROUP, EXT4_DESC_SIZE,
17    EXT4_FEATURE_COMPAT_DIR_INDEX, EXT4_FEATURE_COMPAT_EXT_ATTR, EXT4_FEATURE_COMPAT_HAS_JOURNAL,
18    EXT4_FEATURE_INCOMPAT_64BIT, EXT4_FEATURE_INCOMPAT_EXTENTS, EXT4_FEATURE_INCOMPAT_FILETYPE,
19    EXT4_FEATURE_INCOMPAT_RECOVER, EXT4_FEATURE_RO_COMPAT_DIR_NLINK,
20    EXT4_FEATURE_RO_COMPAT_EXTRA_ISIZE, EXT4_FEATURE_RO_COMPAT_HUGE_FILE,
21    EXT4_FEATURE_RO_COMPAT_LARGE_FILE, EXT4_FEATURE_RO_COMPAT_METADATA_CSUM,
22    EXT4_FEATURE_RO_COMPAT_SPARSE_SUPER, EXT4_FIRST_INO, EXT4_INODE_SIZE, EXT4_INODES_PER_GROUP,
23    EXT4_LOG_BLOCK_SIZE, EXT4_SUPER_MAGIC, sparse_super_group,
24};
25use super::formatter::{Ext4Error, mark_sparse};
26use super::jbd2;
27use super::layout::{
28    GroupDescStats, GroupGeometry, MAX_BLOCKS, bitmap_checksum, build_block_bitmap_base,
29    build_group_descriptor, build_inode_bitmap_base, gdt_checksum, get_le16, get_le32, put_le16,
30    put_le32, superblock_checksum, write_backup_superblock_at, write_gdt_at,
31};
32use crate::crc32c;
33
34//--------------------------------------------------------------------------------------------------
35// Constants
36//--------------------------------------------------------------------------------------------------
37
38/// Byte offset of the 1024-byte superblock within the image.
39const SB_OFFSET: u64 = 1024;
40
41/// On-disk superblock size.
42const SB_SIZE: usize = 1024;
43
44//--------------------------------------------------------------------------------------------------
45// Types
46//--------------------------------------------------------------------------------------------------
47
48/// Result of a successful offline grow.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct GrowOutcome {
51    /// 4 KiB block count before the grow.
52    pub old_blocks: u64,
53
54    /// 4 KiB block count after the grow.
55    pub new_blocks: u64,
56
57    /// Block group count before the grow.
58    pub old_groups: u32,
59
60    /// Block group count after the grow.
61    pub new_groups: u32,
62}
63
64/// Superblock and primary GDT state parsed from an image and validated to match exactly what
65/// this crate's formatter writes.
66struct ParsedImage {
67    /// Raw 1024-byte primary superblock.
68    sb: Vec<u8>,
69
70    /// Raw primary GDT descriptors (num_groups x 64 bytes). Left empty when `needs_recovery` is set, since the deep GDT validation that fills it only runs on clean images.
71    gdt: Vec<u8>,
72
73    /// `EXT4_FEATURE_INCOMPAT_RECOVER` was set: the guest never unmounted, so the jbd2 log must be replayed before the image can be trusted or grown.
74    needs_recovery: bool,
75
76    num_blocks: u64,
77    num_groups: u32,
78    gdt_blocks: u32,
79    reserved_gdt_blocks: u32,
80    inode_table_blocks: u32,
81    csum_seed: u32,
82    free_blocks: u64,
83    free_inodes: u32,
84    overhead_blocks: u32,
85}
86
87//--------------------------------------------------------------------------------------------------
88// Methods
89//--------------------------------------------------------------------------------------------------
90
91impl ParsedImage {
92    fn geometry(&self) -> GroupGeometry {
93        GroupGeometry {
94            num_blocks: self.num_blocks,
95            gdt_blocks: self.gdt_blocks,
96            reserved_gdt_blocks: self.reserved_gdt_blocks,
97            inode_table_blocks: self.inode_table_blocks,
98        }
99    }
100
101    /// Largest block count this image can grow to in place: every group needs a descriptor, and
102    /// descriptors must fit within the blocks already set aside for the GDT (allocated +
103    /// reserved), since the data that follows them cannot be moved offline.
104    fn max_growable_blocks(&self) -> u64 {
105        let descs_per_block = (EXT4_BLOCK_SIZE / EXT4_DESC_SIZE as u32) as u64;
106        let capacity_groups =
107            (self.gdt_blocks as u64 + self.reserved_gdt_blocks as u64) * descs_per_block;
108        (capacity_groups * EXT4_BLOCKS_PER_GROUP as u64).min(MAX_BLOCKS)
109    }
110}
111
112//--------------------------------------------------------------------------------------------------
113// Functions
114//--------------------------------------------------------------------------------------------------
115
116/// Grow the formatter-produced ext4 image at `path` to `new_size_bytes`.
117///
118/// Shrinking and no-op sizes are refused, the size must be a 4 KiB multiple, and the new group
119/// descriptors must fit within the image's existing GDT capacity (see
120/// [`Ext4Error::ExceedsGdtCapacity`]).
121///
122/// Images left dirty by a hard guest stop (`EXT4_FEATURE_INCOMPAT_RECOVER` set) have their jbd2
123/// log replayed and the flag cleared everywhere before growing; any journal inconsistency aborts
124/// with the image untouched.
125///
126/// Crash safety: all new-group metadata, backup superblocks, and backup GDTs are written and
127/// fsynced before the primary superblock is rewritten, so a torn grow leaves the image valid at
128/// its old size.
129pub fn grow_image(path: &Path, new_size_bytes: u64) -> Result<GrowOutcome, Ext4Error> {
130    let mut file = OpenOptions::new().read(true).write(true).open(path)?;
131    let mut img = parse_and_validate(&mut file)?;
132
133    // Replay the journal before anything else: growing with a pending log would let the next kernel mount replay stale transactions over the appended GDT entries. After a
134    // successful replay the image must re-validate as a clean formatter image (the deep GDT checks were skipped on the dirty parse).
135    if img.needs_recovery {
136        replay_journal_and_clear_recover(&mut file, &img)?;
137        img = parse_and_validate(&mut file)?;
138        if img.needs_recovery {
139            return Err(unsupported("journal recovery left the RECOVER flag set"));
140        }
141    }
142
143    let block_size = EXT4_BLOCK_SIZE as u64;
144    if !new_size_bytes.is_multiple_of(block_size) {
145        return Err(Ext4Error::InvalidSize(format!(
146            "image size must be aligned to {block_size} bytes"
147        )));
148    }
149    let new_blocks = new_size_bytes / block_size;
150    if new_blocks > MAX_BLOCKS {
151        return Err(Ext4Error::TooLarge {
152            requested_blocks: new_blocks,
153            max_blocks: MAX_BLOCKS,
154        });
155    }
156    if new_blocks <= img.num_blocks {
157        return Err(Ext4Error::InvalidSize(format!(
158            "cannot grow image from {} to {} bytes: the new size must be larger than the current size",
159            img.num_blocks * block_size,
160            new_size_bytes
161        )));
162    }
163    if new_blocks > img.max_growable_blocks() {
164        return Err(Ext4Error::ExceedsGdtCapacity {
165            requested_bytes: new_size_bytes,
166            max_size_bytes: img.max_growable_blocks() * block_size,
167        });
168    }
169
170    let new_groups = new_blocks.div_ceil(EXT4_BLOCKS_PER_GROUP as u64) as u32;
171    let descs_per_block = EXT4_BLOCK_SIZE / EXT4_DESC_SIZE as u32;
172    let new_gdt_blocks = new_groups.div_ceil(descs_per_block);
173
174    // Descriptors may extend into the reserved GDT span, but `gdt_blocks + reserved` stays
175    // constant so no existing per-group metadata moves.
176    let gdt_span = img.gdt_blocks + img.reserved_gdt_blocks;
177    let new_reserved = gdt_span - new_gdt_blocks;
178
179    let new_geo = GroupGeometry {
180        num_blocks: new_blocks,
181        gdt_blocks: new_gdt_blocks,
182        reserved_gdt_blocks: new_reserved,
183        inode_table_blocks: img.inode_table_blocks,
184    };
185
186    // Same partial-final-group rule as the formatter: a new group must be able to hold its
187    // own metadata.
188    for group in img.num_groups..new_groups {
189        let blocks_in_group = new_geo.blocks_in_group(group);
190        let metadata_blocks = new_geo.group_metadata_blocks(group);
191        if blocks_in_group < metadata_blocks {
192            return Err(Ext4Error::InvalidSize(format!(
193                "block group {group} has {blocks_in_group} blocks but needs at least {metadata_blocks} metadata blocks; choose a size that leaves either no partial group or a larger final group"
194            )));
195        }
196    }
197
198    mark_sparse(&file)?;
199    file.set_len(new_size_bytes)?;
200
201    let mut gdt = img.gdt.clone();
202    let mut total_free = img.free_blocks;
203    let mut overhead = img.overhead_blocks as u64;
204
205    // If the old final group was partial, the padding bits past its old end become real free
206    // blocks: clear them in its bitmap and refresh its descriptor.
207    let old_last = img.num_groups - 1;
208    let old_geo = img.geometry();
209    let old_last_blocks = old_geo.blocks_in_group(old_last);
210    let new_last_blocks = new_geo.blocks_in_group(old_last);
211    let mut extended_last_bitmap: Option<Vec<u8>> = None;
212    if new_last_blocks > old_last_blocks {
213        let mut bitmap = read_block_at(&mut file, old_geo.group_block_bitmap_block(old_last))?;
214        for bit in old_last_blocks..new_last_blocks {
215            bitmap[(bit / 8) as usize] &= !(1 << (bit % 8));
216        }
217        let bb_csum = bitmap_checksum(img.csum_seed, &bitmap, EXT4_BLOCK_SIZE as usize);
218        let delta = new_last_blocks - old_last_blocks;
219
220        let off = old_last as usize * EXT4_DESC_SIZE as usize;
221        let desc = &mut gdt[off..off + EXT4_DESC_SIZE as usize];
222        let free_blocks =
223            (get_le16(desc, 0x0C) as u32 | ((get_le16(desc, 0x2C) as u32) << 16)) + delta;
224        put_le16(desc, 0x0C, free_blocks as u16);
225        put_le16(desc, 0x2C, (free_blocks >> 16) as u16);
226        put_le16(desc, 0x18, bb_csum as u16);
227        put_le16(desc, 0x38, (bb_csum >> 16) as u16);
228        put_le16(desc, 0x1E, 0);
229        let checksum = gdt_checksum(img.csum_seed, old_last, desc);
230        put_le16(desc, 0x1E, checksum);
231
232        total_free += delta as u64;
233        extended_last_bitmap = Some(bitmap);
234    }
235
236    // New groups: bitmaps on disk, descriptors in memory. Inode tables stay sparse zeros,
237    // matching the formatter's EXT4_BG_INODE_ZEROED groups.
238    for group in img.num_groups..new_groups {
239        let block_bitmap = build_block_bitmap_base(&new_geo, group);
240        let inode_bitmap = build_inode_bitmap_base(0);
241        write_block_at(
242            &mut file,
243            new_geo.group_block_bitmap_block(group),
244            &block_bitmap,
245        )?;
246        write_block_at(
247            &mut file,
248            new_geo.group_inode_bitmap_block(group),
249            &inode_bitmap,
250        )?;
251
252        let free_blocks = new_geo.blocks_in_group(group) - new_geo.group_metadata_blocks(group);
253        let stats = GroupDescStats {
254            free_blocks,
255            free_inodes: EXT4_INODES_PER_GROUP,
256            used_dirs: 0,
257            block_bitmap_csum: bitmap_checksum(
258                img.csum_seed,
259                &block_bitmap,
260                EXT4_BLOCK_SIZE as usize,
261            ),
262            inode_bitmap_csum: bitmap_checksum(
263                img.csum_seed,
264                &inode_bitmap,
265                (EXT4_INODES_PER_GROUP / 8) as usize,
266            ),
267        };
268        gdt.extend_from_slice(&build_group_descriptor(
269            &new_geo,
270            group,
271            &stats,
272            img.csum_seed,
273        ));
274
275        total_free += free_blocks as u64;
276        overhead += new_geo.group_metadata_blocks(group) as u64;
277    }
278
279    let added_groups = new_groups - img.num_groups;
280    let mut new_sb = img.sb.clone();
281    put_le32(&mut new_sb, 0x00, new_groups * EXT4_INODES_PER_GROUP);
282    put_le32(&mut new_sb, 0x04, new_blocks as u32);
283    put_le32(&mut new_sb, 0x150, (new_blocks >> 32) as u32);
284    put_le32(&mut new_sb, 0x0C, total_free as u32);
285    put_le32(&mut new_sb, 0x158, (total_free >> 32) as u32);
286    put_le32(
287        &mut new_sb,
288        0x10,
289        img.free_inodes + added_groups * EXT4_INODES_PER_GROUP,
290    );
291    put_le16(&mut new_sb, 0xCE, new_reserved as u16);
292    put_le32(&mut new_sb, 0x194, overhead as u32);
293    let new_sb_csum = superblock_checksum(&new_sb);
294    put_le32(&mut new_sb, 0x3FC, new_sb_csum);
295
296    // Phase 1: everything invisible while the old primary superblock is in place — new-group
297    // bitmaps (written above), descriptors appended past the old end of the primary GDT, and
298    // every backup superblock + GDT copy.
299    let old_gdt_len = img.num_groups as usize * EXT4_DESC_SIZE as usize;
300    file.seek(SeekFrom::Start(EXT4_BLOCK_SIZE as u64 + old_gdt_len as u64))?;
301    file.write_all(&gdt[old_gdt_len..])?;
302
303    for group in 1..new_groups {
304        if !sparse_super_group(group) {
305            continue;
306        }
307        let mut backup_sb = new_sb.clone();
308        put_le16(&mut backup_sb, 0x5A, group as u16);
309        let backup_sb_csum = superblock_checksum(&backup_sb);
310        put_le32(&mut backup_sb, 0x3FC, backup_sb_csum);
311        write_backup_superblock_at(&mut file, new_geo.group_start_block(group), &backup_sb)?;
312        write_gdt_at(&mut file, new_geo.group_start_block(group), &gdt)?;
313    }
314    file.sync_all()?;
315
316    // Phase 2: the only pre-publish writes visible at the old size (the old final group's
317    // bitmap padding and free count). A tear here still leaves the old superblock intact and
318    // the drift is limited to that one group's padding bits and free count.
319    if let Some(bitmap) = &extended_last_bitmap {
320        write_block_at(
321            &mut file,
322            old_geo.group_block_bitmap_block(old_last),
323            bitmap,
324        )?;
325        let off = old_last as usize * EXT4_DESC_SIZE as usize;
326        file.seek(SeekFrom::Start(EXT4_BLOCK_SIZE as u64 + off as u64))?;
327        file.write_all(&gdt[off..off + EXT4_DESC_SIZE as usize])?;
328        file.sync_all()?;
329    }
330
331    // Phase 3: publish the grow by rewriting the primary superblock last.
332    file.seek(SeekFrom::Start(SB_OFFSET))?;
333    file.write_all(&new_sb)?;
334    file.sync_all()?;
335
336    Ok(GrowOutcome {
337        old_blocks: img.num_blocks,
338        new_blocks,
339        old_groups: img.num_groups,
340        new_groups,
341    })
342}
343
344/// Parse the primary superblock and GDT, refusing anything that does not match exactly what this
345/// crate's formatter writes (geometry, feature masks, per-group layout, checksums).
346fn parse_and_validate(file: &mut File) -> Result<ParsedImage, Ext4Error> {
347    let file_len = file.metadata()?.len();
348    if file_len < SB_OFFSET + SB_SIZE as u64 {
349        return Err(unsupported("file too small to contain an ext4 superblock"));
350    }
351
352    let mut sb = vec![0u8; SB_SIZE];
353    file.seek(SeekFrom::Start(SB_OFFSET))?;
354    file.read_exact(&mut sb)?;
355
356    if get_le16(&sb, 0x38) != EXT4_SUPER_MAGIC {
357        return Err(unsupported("bad superblock magic"));
358    }
359    if superblock_checksum(&sb) != get_le32(&sb, 0x3FC) {
360        return Err(unsupported("superblock checksum mismatch"));
361    }
362
363    let compat = get_le32(&sb, 0x5C);
364    let incompat = get_le32(&sb, 0x60);
365    let ro_compat = get_le32(&sb, 0x64);
366    let expected_compat = EXT4_FEATURE_COMPAT_HAS_JOURNAL
367        | EXT4_FEATURE_COMPAT_EXT_ATTR
368        | EXT4_FEATURE_COMPAT_DIR_INDEX;
369    let expected_incompat = EXT4_FEATURE_INCOMPAT_FILETYPE
370        | EXT4_FEATURE_INCOMPAT_EXTENTS
371        | EXT4_FEATURE_INCOMPAT_64BIT;
372    let expected_ro_compat = EXT4_FEATURE_RO_COMPAT_SPARSE_SUPER
373        | EXT4_FEATURE_RO_COMPAT_LARGE_FILE
374        | EXT4_FEATURE_RO_COMPAT_HUGE_FILE
375        | EXT4_FEATURE_RO_COMPAT_DIR_NLINK
376        | EXT4_FEATURE_RO_COMPAT_EXTRA_ISIZE
377        | EXT4_FEATURE_RO_COMPAT_METADATA_CSUM;
378    // Acceptance rule: exactly the formatter's masks, with one exception — INCOMPAT_RECOVER may additionally be set, because every upper that was ever mounted carries it (the
379    // guest does not unmount on stop). RECOVER images get their journal replayed by grow_image before the deep validation below ever runs on them.
380    let needs_recovery = incompat & EXT4_FEATURE_INCOMPAT_RECOVER != 0;
381    if compat != expected_compat
382        || incompat & !EXT4_FEATURE_INCOMPAT_RECOVER != expected_incompat
383        || ro_compat != expected_ro_compat
384    {
385        return Err(unsupported(format!(
386            "feature flags do not match this crate's formatter (compat={compat:#x}, incompat={incompat:#x}, ro_compat={ro_compat:#x})"
387        )));
388    }
389
390    let checks: [(bool, &str); 13] = [
391        (get_le32(&sb, 0x4C) == 1, "unexpected revision level"),
392        (
393            get_le32(&sb, 0x18) == EXT4_LOG_BLOCK_SIZE,
394            "unexpected block size",
395        ),
396        (
397            get_le32(&sb, 0x1C) == EXT4_LOG_BLOCK_SIZE,
398            "unexpected cluster size",
399        ),
400        (
401            get_le32(&sb, 0x20) == EXT4_BLOCKS_PER_GROUP,
402            "unexpected blocks per group",
403        ),
404        (
405            get_le32(&sb, 0x24) == EXT4_BLOCKS_PER_GROUP,
406            "unexpected clusters per group",
407        ),
408        (
409            get_le32(&sb, 0x28) == EXT4_INODES_PER_GROUP,
410            "unexpected inodes per group",
411        ),
412        (
413            get_le16(&sb, 0x58) == EXT4_INODE_SIZE,
414            "unexpected inode size",
415        ),
416        (
417            get_le16(&sb, 0xFE) == EXT4_DESC_SIZE,
418            "unexpected group descriptor size",
419        ),
420        (get_le32(&sb, 0x14) == 0, "unexpected first data block"),
421        (
422            get_le32(&sb, 0x54) == EXT4_FIRST_INO,
423            "unexpected first inode",
424        ),
425        (get_le16(&sb, 0x5A) == 0, "not a primary superblock"),
426        (sb[0x175] == 1, "unexpected metadata checksum type"),
427        (
428            sb[0x174] == 0 && get_le32(&sb, 0x104) == 0,
429            "unexpected flex_bg/meta_bg layout",
430        ),
431    ];
432    for (ok, message) in checks {
433        if !ok {
434            return Err(unsupported(message));
435        }
436    }
437    // The kernel signals pending recovery via INCOMPAT_RECOVER and leaves s_state at 1 (valid) even across a hard stop, so any other value — error bits set or the valid bit
438    // cleared — means damage that journal replay cannot repair.
439    if get_le16(&sb, 0x3A) != 1 {
440        return Err(unsupported("filesystem state is not clean (s_state != 1)"));
441    }
442
443    let num_blocks = get_le32(&sb, 0x04) as u64 | ((get_le32(&sb, 0x150) as u64) << 32);
444    if num_blocks == 0 || num_blocks > MAX_BLOCKS {
445        return Err(unsupported("implausible block count"));
446    }
447    if file_len != num_blocks * EXT4_BLOCK_SIZE as u64 {
448        return Err(unsupported(
449            "file length does not match superblock block count",
450        ));
451    }
452
453    let num_groups = num_blocks.div_ceil(EXT4_BLOCKS_PER_GROUP as u64) as u32;
454    if get_le32(&sb, 0x00) != num_groups * EXT4_INODES_PER_GROUP {
455        return Err(unsupported("inode count does not match group count"));
456    }
457
458    let reserved_gdt_blocks = get_le16(&sb, 0xCE) as u32;
459    let gdt_blocks =
460        (num_groups as u64 * EXT4_DESC_SIZE as u64).div_ceil(EXT4_BLOCK_SIZE as u64) as u32;
461    let inode_table_blocks =
462        (EXT4_INODES_PER_GROUP as u64 * EXT4_INODE_SIZE as u64 / EXT4_BLOCK_SIZE as u64) as u32;
463
464    let mut uuid = [0u8; 16];
465    uuid.copy_from_slice(&sb[0x68..0x78]);
466    let csum_seed = crc32c::crc32c_raw(0xFFFF_FFFF, &uuid);
467
468    let img = ParsedImage {
469        num_blocks,
470        num_groups,
471        gdt_blocks,
472        reserved_gdt_blocks,
473        inode_table_blocks,
474        csum_seed,
475        free_blocks: get_le32(&sb, 0x0C) as u64 | ((get_le32(&sb, 0x158) as u64) << 32),
476        free_inodes: get_le32(&sb, 0x10),
477        overhead_blocks: get_le32(&sb, 0x194),
478        gdt: Vec::new(),
479        needs_recovery,
480        sb,
481    };
482
483    let geo = img.geometry();
484    if (geo.group_metadata_blocks(0) as u64) > geo.blocks_in_group(0) as u64 {
485        return Err(unsupported("group 0 metadata does not fit its group"));
486    }
487
488    // Until the journal is replayed the on-disk descriptors may be stale or torn mid-checkpoint — exactly what replay repairs — so the deep validation below only runs on a
489    // clean image; grow_image replays and re-parses before growing.
490    if img.needs_recovery {
491        return Ok(img);
492    }
493
494    // A non-empty orphan list needs inode-level processing (truncating/deleting inodes that were unlinked while open) that this resizer does not implement.
495    if get_le32(&img.sb, 0xE8) != 0 {
496        return Err(unsupported("filesystem has a pending orphan inode list"));
497    }
498
499    // Every existing descriptor must place its group's metadata exactly where the formatter's
500    // layout does and carry a valid checksum; anything else means the image is not ours.
501    let mut gdt = vec![0u8; img.num_groups as usize * EXT4_DESC_SIZE as usize];
502    file.seek(SeekFrom::Start(EXT4_BLOCK_SIZE as u64))?;
503    file.read_exact(&mut gdt)?;
504    for group in 0..img.num_groups {
505        let desc = &gdt[group as usize * EXT4_DESC_SIZE as usize..][..EXT4_DESC_SIZE as usize];
506        let bb = get_le32(desc, 0x00) as u64 | ((get_le32(desc, 0x20) as u64) << 32);
507        let ib = get_le32(desc, 0x04) as u64 | ((get_le32(desc, 0x24) as u64) << 32);
508        let it = get_le32(desc, 0x08) as u64 | ((get_le32(desc, 0x28) as u64) << 32);
509        if bb != geo.group_block_bitmap_block(group)
510            || ib != geo.group_inode_bitmap_block(group)
511            || it != geo.group_inode_table_block(group)
512        {
513            return Err(unsupported(format!(
514                "group {group} metadata is not at the expected location"
515            )));
516        }
517        if get_le16(desc, 0x12) != EXT4_BG_INODE_ZEROED {
518            return Err(unsupported(format!("group {group} has unexpected flags")));
519        }
520        let mut desc_copy = desc.to_vec();
521        put_le16(&mut desc_copy, 0x1E, 0);
522        if gdt_checksum(img.csum_seed, group, &desc_copy) != get_le16(desc, 0x1E) {
523            return Err(unsupported(format!(
524                "group {group} descriptor checksum mismatch"
525            )));
526        }
527    }
528
529    Ok(ParsedImage { gdt, ..img })
530}
531
532/// Replay the pending jbd2 log, then clear `EXT4_FEATURE_INCOMPAT_RECOVER` from the primary and every backup superblock.
533///
534/// The journal is fully validated before its first write (see [`jbd2::recover_journal`]) and the backup superblocks are validated up front too, so an inconsistent image is
535/// refused untouched. The write ordering is crash-safe: replayed blocks are fsynced, then the jbd2 superblock is reset to empty, then RECOVER is cleared — a tear at any point
536/// leaves an image that the next attempt recovers to the same end state (replaying an already-emptied journal is a no-op).
537fn replay_journal_and_clear_recover(file: &mut File, img: &ParsedImage) -> Result<(), Ext4Error> {
538    let geo = img.geometry();
539    let journal = jbd2::locate_journal(file, geo.group_inode_table_block(0), img.csum_seed)?;
540    if journal.start_block + journal.len_blocks as u64 > img.num_blocks {
541        return Err(unsupported("journal extent extends beyond the filesystem"));
542    }
543    let mut fs_uuid = [0u8; 16];
544    fs_uuid.copy_from_slice(&img.sb[0x68..0x78]);
545
546    let backup_groups: Vec<u32> = (1..img.num_groups)
547        .filter(|g| sparse_super_group(*g))
548        .collect();
549    for &group in &backup_groups {
550        read_superblock_at(
551            file,
552            geo.group_start_block(group) * EXT4_BLOCK_SIZE as u64,
553            &format!("group {group} backup"),
554        )?;
555    }
556
557    jbd2::recover_journal(file, &journal, &fs_uuid, img.num_blocks)?;
558
559    // Replay may rewrite block 0 — the primary superblock is journaled metadata like any other — so re-read it before clearing the flag.
560    let mut sb = read_superblock_at(file, SB_OFFSET, "primary")?;
561    clear_recover_flag(&mut sb);
562    file.seek(SeekFrom::Start(SB_OFFSET))?;
563    file.write_all(&sb)?;
564
565    // The kernel only ever sets RECOVER in the primary, but replay could have landed a journaled copy in a backup group; clear wherever it appears so the stored masks end up
566    // uniformly clean.
567    for &group in &backup_groups {
568        let offset = geo.group_start_block(group) * EXT4_BLOCK_SIZE as u64;
569        let mut backup = read_superblock_at(file, offset, &format!("group {group} backup"))?;
570        if get_le32(&backup, 0x60) & EXT4_FEATURE_INCOMPAT_RECOVER != 0 {
571            clear_recover_flag(&mut backup);
572            file.seek(SeekFrom::Start(offset))?;
573            file.write_all(&backup)?;
574        }
575    }
576    file.sync_all()?;
577
578    Ok(())
579}
580
581/// Read a 1024-byte superblock at `offset`, refusing bad magic or checksum.
582fn read_superblock_at(file: &mut File, offset: u64, label: &str) -> Result<Vec<u8>, Ext4Error> {
583    let mut sb = vec![0u8; SB_SIZE];
584    file.seek(SeekFrom::Start(offset))?;
585    file.read_exact(&mut sb)?;
586    if get_le16(&sb, 0x38) != EXT4_SUPER_MAGIC || superblock_checksum(&sb) != get_le32(&sb, 0x3FC) {
587        return Err(unsupported(format!(
588            "{label} superblock has a bad magic or checksum"
589        )));
590    }
591    Ok(sb)
592}
593
594fn clear_recover_flag(sb: &mut [u8]) {
595    let incompat = get_le32(sb, 0x60) & !EXT4_FEATURE_INCOMPAT_RECOVER;
596    put_le32(sb, 0x60, incompat);
597    let checksum = superblock_checksum(sb);
598    put_le32(sb, 0x3FC, checksum);
599}
600
601fn unsupported(message: impl Into<String>) -> Ext4Error {
602    Ext4Error::Unsupported(message.into())
603}
604
605fn read_block_at(file: &mut File, block: u64) -> Result<Vec<u8>, Ext4Error> {
606    let mut buf = vec![0u8; EXT4_BLOCK_SIZE as usize];
607    file.seek(SeekFrom::Start(block * EXT4_BLOCK_SIZE as u64))?;
608    file.read_exact(&mut buf)?;
609    Ok(buf)
610}
611
612fn write_block_at(file: &mut File, block: u64, data: &[u8]) -> Result<(), Ext4Error> {
613    file.seek(SeekFrom::Start(block * EXT4_BLOCK_SIZE as u64))?;
614    file.write_all(data)?;
615    Ok(())
616}
617
618//--------------------------------------------------------------------------------------------------
619// Tests
620//--------------------------------------------------------------------------------------------------
621
622#[cfg(test)]
623mod tests {
624    use super::super::format::JBD2_MAGIC;
625    use super::super::formatter::{
626        Ext4FormatOptions, format_ext4, format_ext4_for_test_with_reserved_gdt,
627    };
628    use super::super::jbd2::{JournalLocation, TestTransaction, write_test_log};
629    use super::super::layout::{RESERVED_GDT_BLOCKS, count_used_bits, get_be32, put_be32};
630    use super::*;
631    use sha2::{Digest, Sha256};
632
633    const MIB: u64 = 1024 * 1024;
634
635    fn format_image(path: &Path, size_bytes: u64) {
636        let opts = Ext4FormatOptions {
637            size_bytes,
638            journal_blocks: 4096,
639        };
640        format_ext4(path, &opts).unwrap();
641    }
642
643    fn parse(path: &Path) -> ParsedImage {
644        let mut file = File::open(path).unwrap();
645        parse_and_validate(&mut file).unwrap()
646    }
647
648    /// Re-open the image and check every invariant the resizer must preserve: superblock and
649    /// descriptor checksums (via the parser), bitmap checksums, metadata/padding bits, per-group
650    /// and total free-block accounting, and backup superblock + GDT copies.
651    fn assert_image_invariants(path: &Path) {
652        let mut file = File::open(path).unwrap();
653        let img = parse_and_validate(&mut file).unwrap();
654        let geo = img.geometry();
655
656        let mut total_free = 0u64;
657        for group in 0..img.num_groups {
658            let desc =
659                &img.gdt[group as usize * EXT4_DESC_SIZE as usize..][..EXT4_DESC_SIZE as usize];
660            let block_bitmap =
661                read_block_at(&mut file, geo.group_block_bitmap_block(group)).unwrap();
662            let inode_bitmap =
663                read_block_at(&mut file, geo.group_inode_bitmap_block(group)).unwrap();
664
665            let bb_csum = get_le16(desc, 0x18) as u32 | ((get_le16(desc, 0x38) as u32) << 16);
666            let ib_csum = get_le16(desc, 0x1A) as u32 | ((get_le16(desc, 0x3A) as u32) << 16);
667            assert_eq!(
668                bitmap_checksum(img.csum_seed, &block_bitmap, EXT4_BLOCK_SIZE as usize),
669                bb_csum,
670                "group {group} block bitmap checksum"
671            );
672            assert_eq!(
673                bitmap_checksum(
674                    img.csum_seed,
675                    &inode_bitmap,
676                    (EXT4_INODES_PER_GROUP / 8) as usize
677                ),
678                ib_csum,
679                "group {group} inode bitmap checksum"
680            );
681
682            let blocks_in_group = geo.blocks_in_group(group);
683            for bit in 0..geo.group_metadata_blocks(group) {
684                assert_ne!(
685                    block_bitmap[(bit / 8) as usize] & (1 << (bit % 8)),
686                    0,
687                    "group {group} metadata block {bit} not marked used"
688                );
689            }
690            for bit in blocks_in_group..EXT4_BLOCKS_PER_GROUP {
691                assert_ne!(
692                    block_bitmap[(bit / 8) as usize] & (1 << (bit % 8)),
693                    0,
694                    "group {group} padding bit {bit} not set"
695                );
696            }
697
698            let used = count_used_bits(&block_bitmap, blocks_in_group as usize);
699            let free = get_le16(desc, 0x0C) as u32 | ((get_le16(desc, 0x2C) as u32) << 16);
700            assert_eq!(
701                free as usize,
702                blocks_in_group as usize - used,
703                "group {group} free block count"
704            );
705            total_free += free as u64;
706        }
707        assert_eq!(total_free, img.free_blocks, "superblock free block total");
708
709        for group in 1..img.num_groups {
710            if !sparse_super_group(group) {
711                continue;
712            }
713            let start = geo.group_start_block(group) * EXT4_BLOCK_SIZE as u64;
714            let mut backup_sb = vec![0u8; SB_SIZE];
715            file.seek(SeekFrom::Start(start)).unwrap();
716            file.read_exact(&mut backup_sb).unwrap();
717            assert_eq!(get_le16(&backup_sb, 0x38), EXT4_SUPER_MAGIC);
718            assert_eq!(get_le16(&backup_sb, 0x5A), group as u16);
719            assert_eq!(
720                superblock_checksum(&backup_sb),
721                get_le32(&backup_sb, 0x3FC),
722                "backup superblock checksum in group {group}"
723            );
724            assert_eq!(
725                &backup_sb[0x00..0x18],
726                &img.sb[0x00..0x18],
727                "backup superblock counts in group {group}"
728            );
729
730            let mut backup_gdt = vec![0u8; img.gdt.len()];
731            file.seek(SeekFrom::Start(
732                (geo.group_start_block(group) + 1) * EXT4_BLOCK_SIZE as u64,
733            ))
734            .unwrap();
735            file.read_exact(&mut backup_gdt).unwrap();
736            assert_eq!(backup_gdt, img.gdt, "backup GDT in group {group}");
737        }
738    }
739
740    /// Hash every block below `blocks` except block 0 and the superblock + GDT span at the start
741    /// of each backup-super group — the only pre-existing regions a grow may rewrite.
742    fn hash_stable_prefix(path: &Path, blocks: u64, gdt_span: u32) -> [u8; 32] {
743        let mut file = File::open(path).unwrap();
744        let mut hasher = Sha256::new();
745        let mut buf = vec![0u8; EXT4_BLOCK_SIZE as usize];
746        for block in 0..blocks {
747            let group = (block / EXT4_BLOCKS_PER_GROUP as u64) as u32;
748            let offset_in_group = block % EXT4_BLOCKS_PER_GROUP as u64;
749            let has_super = group == 0 || sparse_super_group(group);
750            if has_super && offset_in_group < 1 + gdt_span as u64 {
751                continue;
752            }
753            file.seek(SeekFrom::Start(block * EXT4_BLOCK_SIZE as u64))
754                .unwrap();
755            file.read_exact(&mut buf).unwrap();
756            hasher.update(&buf);
757        }
758        hasher.finalize().into()
759    }
760
761    fn journal_location(path: &Path) -> (JournalLocation, [u8; 16]) {
762        let mut file = File::open(path).unwrap();
763        let img = parse_and_validate(&mut file).unwrap();
764        let location = jbd2::locate_journal(
765            &mut file,
766            img.geometry().group_inode_table_block(0),
767            img.csum_seed,
768        )
769        .unwrap();
770        let mut uuid = [0u8; 16];
771        uuid.copy_from_slice(&img.sb[0x68..0x78]);
772        (location, uuid)
773    }
774
775    /// Simulate the state every mounted-but-never-unmounted upper is left in: RECOVER set in the primary superblock (the kernel never sets it in backups).
776    fn set_recover_flag(path: &Path) {
777        let mut file = OpenOptions::new()
778            .read(true)
779            .write(true)
780            .open(path)
781            .unwrap();
782        let mut sb = vec![0u8; SB_SIZE];
783        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
784        file.read_exact(&mut sb).unwrap();
785        let incompat = get_le32(&sb, 0x60) | EXT4_FEATURE_INCOMPAT_RECOVER;
786        put_le32(&mut sb, 0x60, incompat);
787        let checksum = superblock_checksum(&sb);
788        put_le32(&mut sb, 0x3FC, checksum);
789        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
790        file.write_all(&sb).unwrap();
791    }
792
793    fn write_dirty_journal(path: &Path, start_seq: u32, transactions: &[TestTransaction]) {
794        let (location, uuid) = journal_location(path);
795        let mut file = OpenOptions::new()
796            .read(true)
797            .write(true)
798            .open(path)
799            .unwrap();
800        write_test_log(&mut file, &location, &uuid, start_seq, transactions).unwrap();
801        drop(file);
802        set_recover_flag(path);
803    }
804
805    fn read_jbd2_superblock(path: &Path) -> Vec<u8> {
806        let (location, _) = journal_location(path);
807        let mut file = File::open(path).unwrap();
808        let mut jsb = vec![0u8; 1024];
809        file.seek(SeekFrom::Start(
810            location.start_block * EXT4_BLOCK_SIZE as u64,
811        ))
812        .unwrap();
813        file.read_exact(&mut jsb).unwrap();
814        jsb
815    }
816
817    fn assert_recover_cleared_everywhere(path: &Path) {
818        let mut file = File::open(path).unwrap();
819        let img = parse_and_validate(&mut file).unwrap();
820        assert_eq!(
821            get_le32(&img.sb, 0x60) & EXT4_FEATURE_INCOMPAT_RECOVER,
822            0,
823            "primary superblock still has RECOVER"
824        );
825        let geo = img.geometry();
826        for group in 1..img.num_groups {
827            if !sparse_super_group(group) {
828                continue;
829            }
830            let mut backup = vec![0u8; SB_SIZE];
831            file.seek(SeekFrom::Start(
832                geo.group_start_block(group) * EXT4_BLOCK_SIZE as u64,
833            ))
834            .unwrap();
835            file.read_exact(&mut backup).unwrap();
836            assert_eq!(
837                get_le32(&backup, 0x60) & EXT4_FEATURE_INCOMPAT_RECOVER,
838                0,
839                "backup superblock in group {group} still has RECOVER"
840            );
841        }
842    }
843
844    fn hash_file(path: &Path) -> [u8; 32] {
845        let mut file = File::open(path).unwrap();
846        let mut hasher = Sha256::new();
847        let mut buf = vec![0u8; 1 << 20];
848        loop {
849            let n = file.read(&mut buf).unwrap();
850            if n == 0 {
851                break;
852            }
853            hasher.update(&buf[..n]);
854        }
855        hasher.finalize().into()
856    }
857
858    fn pattern_block(byte: u8) -> Vec<u8> {
859        vec![byte; EXT4_BLOCK_SIZE as usize]
860    }
861
862    #[test]
863    fn test_freshly_formatted_image_passes_validation() {
864        let dir = tempfile::tempdir().unwrap();
865        let path = dir.path().join("fresh.ext4");
866        format_image(&path, 256 * MIB);
867
868        let img = parse(&path);
869        assert_eq!(img.num_blocks, 65536);
870        assert_eq!(img.num_groups, 2);
871        assert_eq!(img.gdt_blocks, 1);
872        assert_eq!(img.reserved_gdt_blocks, RESERVED_GDT_BLOCKS);
873        assert_image_invariants(&path);
874    }
875
876    #[test]
877    fn test_grow_doubles_aligned_image() {
878        let dir = tempfile::tempdir().unwrap();
879        let path = dir.path().join("grow.ext4");
880        format_image(&path, 256 * MIB);
881
882        let img = parse(&path);
883        let span = img.gdt_blocks + img.reserved_gdt_blocks;
884        let before = hash_stable_prefix(&path, img.num_blocks, span);
885
886        let outcome = grow_image(&path, 512 * MIB).unwrap();
887        assert_eq!(
888            outcome,
889            GrowOutcome {
890                old_blocks: 65536,
891                new_blocks: 131072,
892                old_groups: 2,
893                new_groups: 4,
894            }
895        );
896        assert_eq!(std::fs::metadata(&path).unwrap().len(), 512 * MIB);
897
898        // Group 3 is a sparse_super backup group, so the grow must have created its backup
899        // superblock + GDT; assert_image_invariants verifies both.
900        assert_image_invariants(&path);
901
902        let after = hash_stable_prefix(&path, img.num_blocks, span);
903        assert_eq!(before, after, "pre-existing data blocks were modified");
904    }
905
906    #[test]
907    fn test_grow_crosses_sparse_super_backup_groups() {
908        let dir = tempfile::tempdir().unwrap();
909        let path = dir.path().join("backups.ext4");
910        format_image(&path, 256 * MIB);
911
912        let outcome = grow_image(&path, 1024 * MIB).unwrap();
913        assert_eq!(outcome.new_groups, 8);
914
915        let img = parse(&path);
916        assert_eq!(img.num_groups, 8);
917        assert_eq!(img.free_inodes, get_le32(&img.sb, 0x10));
918        assert_image_invariants(&path);
919    }
920
921    #[test]
922    fn test_grow_consumes_reserved_gdt_blocks() {
923        let dir = tempfile::tempdir().unwrap();
924        let path = dir.path().join("consume.ext4");
925        format_image(&path, 256 * MIB);
926
927        // 68 groups need two GDT blocks, so the second descriptor block comes out of the
928        // reserved span while gdt_blocks + reserved stays 257.
929        let outcome = grow_image(&path, 68 * 128 * MIB).unwrap();
930        assert_eq!(outcome.new_groups, 68);
931
932        let img = parse(&path);
933        assert_eq!(img.gdt_blocks, 2);
934        assert_eq!(img.reserved_gdt_blocks, RESERVED_GDT_BLOCKS - 1);
935        assert_image_invariants(&path);
936    }
937
938    #[test]
939    fn test_grow_twice_reuses_headroom() {
940        let dir = tempfile::tempdir().unwrap();
941        let path = dir.path().join("twice.ext4");
942        format_image(&path, 256 * MIB);
943
944        grow_image(&path, 512 * MIB).unwrap();
945        assert_image_invariants(&path);
946
947        let outcome = grow_image(&path, 1024 * MIB).unwrap();
948        assert_eq!(outcome.old_groups, 4);
949        assert_eq!(outcome.new_groups, 8);
950        assert_image_invariants(&path);
951    }
952
953    #[test]
954    fn test_grow_extends_partial_final_group() {
955        let dir = tempfile::tempdir().unwrap();
956        let path = dir.path().join("partial-old.ext4");
957        format_image(&path, 200 * MIB);
958
959        let outcome = grow_image(&path, 256 * MIB).unwrap();
960        assert_eq!(outcome.old_groups, 2);
961        assert_eq!(outcome.new_groups, 2);
962        assert_eq!(outcome.new_blocks - outcome.old_blocks, 56 * MIB / 4096);
963        assert_image_invariants(&path);
964    }
965
966    #[test]
967    fn test_grow_creates_partial_final_group() {
968        let dir = tempfile::tempdir().unwrap();
969        let path = dir.path().join("partial-new.ext4");
970        format_image(&path, 256 * MIB);
971
972        let outcome = grow_image(&path, 448 * MIB).unwrap();
973        assert_eq!(outcome.new_groups, 4);
974        assert_image_invariants(&path);
975    }
976
977    #[test]
978    fn test_grow_rejects_shrink_and_noop() {
979        let dir = tempfile::tempdir().unwrap();
980        let path = dir.path().join("shrink.ext4");
981        format_image(&path, 256 * MIB);
982
983        let result = grow_image(&path, 128 * MIB);
984        assert!(matches!(result, Err(Ext4Error::InvalidSize(_))));
985
986        let result = grow_image(&path, 256 * MIB);
987        assert!(matches!(result, Err(Ext4Error::InvalidSize(_))));
988    }
989
990    #[test]
991    fn test_grow_rejects_unaligned_size() {
992        let dir = tempfile::tempdir().unwrap();
993        let path = dir.path().join("unaligned.ext4");
994        format_image(&path, 256 * MIB);
995
996        let result = grow_image(&path, 512 * MIB + 1);
997        assert!(matches!(result, Err(Ext4Error::InvalidSize(_))));
998    }
999
1000    #[test]
1001    fn test_grow_rejects_size_beyond_32_bit_block_addresses() {
1002        let dir = tempfile::tempdir().unwrap();
1003        let path = dir.path().join("huge.ext4");
1004        format_image(&path, 256 * MIB);
1005
1006        let result = grow_image(&path, (MAX_BLOCKS + 1) * EXT4_BLOCK_SIZE as u64);
1007        assert!(matches!(result, Err(Ext4Error::TooLarge { .. })));
1008    }
1009
1010    #[test]
1011    fn test_grow_over_capacity_reports_max_growable_size() {
1012        let dir = tempfile::tempdir().unwrap();
1013        let path = dir.path().join("pre-headroom.ext4");
1014        let opts = Ext4FormatOptions {
1015            size_bytes: 256 * MIB,
1016            journal_blocks: 4096,
1017        };
1018        format_ext4_for_test_with_reserved_gdt(&path, &opts, 0).unwrap();
1019
1020        // One GDT block and no reserved headroom caps the image at 64 groups (8 GiB).
1021        let max_size_bytes = 64 * 128 * MIB;
1022        let result = grow_image(&path, 16 * 1024 * MIB);
1023        match result {
1024            Err(Ext4Error::ExceedsGdtCapacity {
1025                requested_bytes,
1026                max_size_bytes: reported_max,
1027            }) => {
1028                assert_eq!(requested_bytes, 16 * 1024 * MIB);
1029                assert_eq!(reported_max, max_size_bytes);
1030            }
1031            other => panic!("expected ExceedsGdtCapacity, got {other:?}"),
1032        }
1033
1034        // Growing to exactly the capacity limit uses the remaining slack in the allocated
1035        // GDT block and succeeds.
1036        let outcome = grow_image(&path, max_size_bytes).unwrap();
1037        assert_eq!(outcome.new_groups, 64);
1038        assert_image_invariants(&path);
1039    }
1040
1041    #[test]
1042    fn test_grow_rejects_corrupted_superblock() {
1043        let dir = tempfile::tempdir().unwrap();
1044        let path = dir.path().join("corrupt.ext4");
1045        format_image(&path, 256 * MIB);
1046
1047        let mut file = OpenOptions::new()
1048            .read(true)
1049            .write(true)
1050            .open(&path)
1051            .unwrap();
1052        file.seek(SeekFrom::Start(SB_OFFSET + 0x20)).unwrap();
1053        file.write_all(&[0xFF]).unwrap();
1054        drop(file);
1055
1056        let result = grow_image(&path, 512 * MIB);
1057        assert!(matches!(result, Err(Ext4Error::Unsupported(_))));
1058    }
1059
1060    #[test]
1061    fn test_grow_rejects_foreign_feature_flags() {
1062        let dir = tempfile::tempdir().unwrap();
1063        let path = dir.path().join("foreign.ext4");
1064        format_image(&path, 256 * MIB);
1065
1066        // Set an extra ro_compat flag and re-checksum so only the feature check can reject it.
1067        let mut file = OpenOptions::new()
1068            .read(true)
1069            .write(true)
1070            .open(&path)
1071            .unwrap();
1072        let mut sb = vec![0u8; SB_SIZE];
1073        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
1074        file.read_exact(&mut sb).unwrap();
1075        let ro_compat = get_le32(&sb, 0x64);
1076        put_le32(&mut sb, 0x64, ro_compat | 0x8000);
1077        let checksum = superblock_checksum(&sb);
1078        put_le32(&mut sb, 0x3FC, checksum);
1079        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
1080        file.write_all(&sb).unwrap();
1081        drop(file);
1082
1083        let result = grow_image(&path, 512 * MIB);
1084        match result {
1085            Err(Ext4Error::Unsupported(message)) => {
1086                assert!(message.contains("feature flags"), "message: {message}")
1087            }
1088            other => panic!("expected Unsupported, got {other:?}"),
1089        }
1090    }
1091
1092    #[test]
1093    fn test_grow_replays_pending_journal() {
1094        let dir = tempfile::tempdir().unwrap();
1095        let path = dir.path().join("replay.ext4");
1096        format_image(&path, 256 * MIB);
1097
1098        let (location, _) = journal_location(&path);
1099        // Block 2 is the first reserved-GDT block, directly adjacent to the superblock + GDT; it stays reserved after this grow so the replayed content must survive verbatim.
1100        let reserved_gdt_target = 2u64;
1101        let data_target = location.start_block + location.len_blocks as u64 + 16;
1102        let reserved_data = pattern_block(0xA5);
1103        let file_data = pattern_block(0x5A);
1104        write_dirty_journal(
1105            &path,
1106            2,
1107            &[TestTransaction {
1108                writes: vec![
1109                    (reserved_gdt_target, reserved_data.clone()),
1110                    (data_target, file_data.clone()),
1111                ],
1112                revokes: vec![],
1113                corrupt_commit: false,
1114            }],
1115        );
1116
1117        let outcome = grow_image(&path, 512 * MIB).unwrap();
1118        assert_eq!(outcome.new_groups, 4);
1119
1120        let mut file = File::open(&path).unwrap();
1121        assert_eq!(
1122            read_block_at(&mut file, reserved_gdt_target).unwrap(),
1123            reserved_data,
1124            "journaled superblock-adjacent write was not replayed"
1125        );
1126        assert_eq!(
1127            read_block_at(&mut file, data_target).unwrap(),
1128            file_data,
1129            "journaled data-block write was not replayed"
1130        );
1131        drop(file);
1132
1133        assert_recover_cleared_everywhere(&path);
1134        let jsb = read_jbd2_superblock(&path);
1135        assert_eq!(get_be32(&jsb, 0x1C), 0, "journal s_start not reset");
1136        // Sequence 2 replayed, end-of-log at sequence 3, and the kernel-mirroring reset restarts one past that.
1137        assert_eq!(
1138            get_be32(&jsb, 0x18),
1139            4,
1140            "journal s_sequence not advanced past the replayed transaction"
1141        );
1142        assert_image_invariants(&path);
1143    }
1144
1145    #[test]
1146    fn test_replay_restores_escaped_blocks() {
1147        let dir = tempfile::tempdir().unwrap();
1148        let path = dir.path().join("escape.ext4");
1149        format_image(&path, 256 * MIB);
1150
1151        let (location, _) = journal_location(&path);
1152        let target = location.start_block + location.len_blocks as u64 + 16;
1153        let mut data = pattern_block(0x11);
1154        put_be32(&mut data, 0, JBD2_MAGIC);
1155        write_dirty_journal(
1156            &path,
1157            2,
1158            &[TestTransaction {
1159                writes: vec![(target, data.clone())],
1160                revokes: vec![],
1161                corrupt_commit: false,
1162            }],
1163        );
1164
1165        grow_image(&path, 512 * MIB).unwrap();
1166
1167        let mut file = File::open(&path).unwrap();
1168        let replayed = read_block_at(&mut file, target).unwrap();
1169        assert_eq!(
1170            get_be32(&replayed, 0),
1171            JBD2_MAGIC,
1172            "escape magic not restored"
1173        );
1174        assert_eq!(replayed, data);
1175    }
1176
1177    #[test]
1178    fn test_replay_honors_revocations() {
1179        let dir = tempfile::tempdir().unwrap();
1180        let path = dir.path().join("revoke.ext4");
1181        format_image(&path, 256 * MIB);
1182
1183        let (location, _) = journal_location(&path);
1184        let data_start = location.start_block + location.len_blocks as u64 + 16;
1185        let revoked_target = data_start;
1186        let kept_target = data_start + 1;
1187        let late_target = data_start + 2;
1188        // The revocation lives in a LATER transaction than the write it suppresses: replay of transaction 2 must skip revoked_target because transaction 3 revoked it.
1189        write_dirty_journal(
1190            &path,
1191            2,
1192            &[
1193                TestTransaction {
1194                    writes: vec![
1195                        (revoked_target, pattern_block(0xDE)),
1196                        (kept_target, pattern_block(0x22)),
1197                    ],
1198                    revokes: vec![],
1199                    corrupt_commit: false,
1200                },
1201                TestTransaction {
1202                    writes: vec![(late_target, pattern_block(0x33))],
1203                    revokes: vec![revoked_target],
1204                    corrupt_commit: false,
1205                },
1206            ],
1207        );
1208
1209        grow_image(&path, 512 * MIB).unwrap();
1210
1211        let mut file = File::open(&path).unwrap();
1212        assert_eq!(
1213            read_block_at(&mut file, revoked_target).unwrap(),
1214            vec![0u8; EXT4_BLOCK_SIZE as usize],
1215            "revoked block was replayed"
1216        );
1217        assert_eq!(
1218            read_block_at(&mut file, kept_target).unwrap(),
1219            pattern_block(0x22)
1220        );
1221        assert_eq!(
1222            read_block_at(&mut file, late_target).unwrap(),
1223            pattern_block(0x33)
1224        );
1225    }
1226
1227    #[test]
1228    fn test_replay_stops_at_corrupt_commit() {
1229        let dir = tempfile::tempdir().unwrap();
1230        let path = dir.path().join("badcommit.ext4");
1231        format_image(&path, 256 * MIB);
1232
1233        let (location, _) = journal_location(&path);
1234        let applied_target = location.start_block + location.len_blocks as u64 + 16;
1235        let dropped_target = applied_target + 1;
1236        write_dirty_journal(
1237            &path,
1238            2,
1239            &[
1240                TestTransaction {
1241                    writes: vec![(applied_target, pattern_block(0x44))],
1242                    revokes: vec![],
1243                    corrupt_commit: false,
1244                },
1245                TestTransaction {
1246                    writes: vec![(dropped_target, pattern_block(0x55))],
1247                    revokes: vec![],
1248                    corrupt_commit: true,
1249                },
1250            ],
1251        );
1252
1253        grow_image(&path, 512 * MIB).unwrap();
1254
1255        let mut file = File::open(&path).unwrap();
1256        assert_eq!(
1257            read_block_at(&mut file, applied_target).unwrap(),
1258            pattern_block(0x44),
1259            "committed transaction was not replayed"
1260        );
1261        assert_eq!(
1262            read_block_at(&mut file, dropped_target).unwrap(),
1263            vec![0u8; EXT4_BLOCK_SIZE as usize],
1264            "uncommitted transaction was replayed"
1265        );
1266        drop(file);
1267
1268        // end-of-log at the corrupt commit: sequence 2 replayed, sequence 3 discarded, so the reset journal restarts at 4.
1269        let jsb = read_jbd2_superblock(&path);
1270        assert_eq!(get_be32(&jsb, 0x1C), 0);
1271        assert_eq!(get_be32(&jsb, 0x18), 4);
1272        assert_recover_cleared_everywhere(&path);
1273    }
1274
1275    #[test]
1276    fn test_grow_clears_recover_flag_with_empty_journal() {
1277        let dir = tempfile::tempdir().unwrap();
1278        let path = dir.path().join("recover-clean.ext4");
1279        format_image(&path, 256 * MIB);
1280        set_recover_flag(&path);
1281
1282        let outcome = grow_image(&path, 512 * MIB).unwrap();
1283        assert_eq!(outcome.new_groups, 4);
1284
1285        assert_recover_cleared_everywhere(&path);
1286        // An empty log (s_start == 0) needs no recovery, so the journal superblock is left exactly as formatted.
1287        let jsb = read_jbd2_superblock(&path);
1288        assert_eq!(get_be32(&jsb, 0x1C), 0);
1289        assert_eq!(get_be32(&jsb, 0x18), 1);
1290        assert_image_invariants(&path);
1291    }
1292
1293    #[test]
1294    fn test_replay_rejects_unknown_journal_features() {
1295        let dir = tempfile::tempdir().unwrap();
1296        let path = dir.path().join("badjournal.ext4");
1297        format_image(&path, 256 * MIB);
1298
1299        // ASYNC_COMMIT (0x4) is a real jbd2 feature, but not one the formatter writes, so recovery must refuse it rather than misparse commit blocks.
1300        let (location, _) = journal_location(&path);
1301        let mut file = OpenOptions::new()
1302            .read(true)
1303            .write(true)
1304            .open(&path)
1305            .unwrap();
1306        let mut jsb = vec![0u8; 1024];
1307        file.seek(SeekFrom::Start(
1308            location.start_block * EXT4_BLOCK_SIZE as u64,
1309        ))
1310        .unwrap();
1311        file.read_exact(&mut jsb).unwrap();
1312        let incompat = get_be32(&jsb, 0x28);
1313        put_be32(&mut jsb, 0x28, incompat | 0x04);
1314        jsb[0xFC..0x100].fill(0);
1315        let checksum = crc32c::crc32c_raw(0xFFFF_FFFF, &jsb);
1316        put_be32(&mut jsb, 0xFC, checksum);
1317        file.seek(SeekFrom::Start(
1318            location.start_block * EXT4_BLOCK_SIZE as u64,
1319        ))
1320        .unwrap();
1321        file.write_all(&jsb).unwrap();
1322        drop(file);
1323        set_recover_flag(&path);
1324
1325        let before = hash_file(&path);
1326        let result = grow_image(&path, 512 * MIB);
1327        match result {
1328            Err(Ext4Error::Unsupported(message)) => {
1329                assert!(message.contains("journal feature"), "message: {message}")
1330            }
1331            other => panic!("expected Unsupported, got {other:?}"),
1332        }
1333        assert_eq!(
1334            hash_file(&path),
1335            before,
1336            "failed recovery modified the image"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_replay_rejects_target_beyond_filesystem() {
1342        let dir = tempfile::tempdir().unwrap();
1343        let path = dir.path().join("oob.ext4");
1344        format_image(&path, 256 * MIB);
1345
1346        // 256 MiB = 65536 blocks, so this target is past the end of the filesystem.
1347        write_dirty_journal(
1348            &path,
1349            2,
1350            &[TestTransaction {
1351                writes: vec![(70_000, pattern_block(0x66))],
1352                revokes: vec![],
1353                corrupt_commit: false,
1354            }],
1355        );
1356
1357        let before = hash_file(&path);
1358        let result = grow_image(&path, 512 * MIB);
1359        match result {
1360            Err(Ext4Error::Unsupported(message)) => {
1361                assert!(
1362                    message.contains("beyond the filesystem"),
1363                    "message: {message}"
1364                )
1365            }
1366            other => panic!("expected Unsupported, got {other:?}"),
1367        }
1368        assert_eq!(
1369            hash_file(&path),
1370            before,
1371            "failed recovery modified the image"
1372        );
1373    }
1374
1375    /// Full `e2fsck -fn` validation of a formatted and grown image. Gated behind `--ignored`
1376    /// because e2fsprogs is only guaranteed on Linux CI; skips cleanly when the binary is absent.
1377    #[test]
1378    #[ignore]
1379    fn test_e2fsck_validates_formatted_and_grown_image() {
1380        let dir = tempfile::tempdir().unwrap();
1381        let path = dir.path().join("fsck.ext4");
1382        format_image(&path, 256 * MIB);
1383
1384        let run_e2fsck = |label: &str| {
1385            let output = match std::process::Command::new("e2fsck")
1386                .arg("-fn")
1387                .arg(&path)
1388                .output()
1389            {
1390                Ok(output) => output,
1391                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1392                    eprintln!("e2fsck not found; skipping");
1393                    return false;
1394                }
1395                Err(error) => panic!("failed to run e2fsck: {error}"),
1396            };
1397            assert!(
1398                output.status.success(),
1399                "e2fsck failed after {label}:\nstdout: {}\nstderr: {}",
1400                String::from_utf8_lossy(&output.stdout),
1401                String::from_utf8_lossy(&output.stderr)
1402            );
1403            true
1404        };
1405
1406        if !run_e2fsck("format") {
1407            return;
1408        }
1409        grow_image(&path, 512 * MIB).unwrap();
1410        run_e2fsck("grow to 512 MiB");
1411        grow_image(&path, 1024 * MIB).unwrap();
1412        run_e2fsck("grow to 1 GiB");
1413    }
1414
1415    /// Same e2fsck gate for the recovery path: a dirty image (pending journal with escaped and revoked blocks) must replay, grow, and still be fully clean to `e2fsck -fn`.
1416    #[test]
1417    #[ignore]
1418    fn test_e2fsck_validates_replayed_and_grown_image() {
1419        let dir = tempfile::tempdir().unwrap();
1420        let path = dir.path().join("fsck-replay.ext4");
1421        format_image(&path, 256 * MIB);
1422
1423        let (location, _) = journal_location(&path);
1424        let data_start = location.start_block + location.len_blocks as u64 + 16;
1425        let mut escaped = pattern_block(0x11);
1426        put_be32(&mut escaped, 0, JBD2_MAGIC);
1427        write_dirty_journal(
1428            &path,
1429            2,
1430            &[
1431                TestTransaction {
1432                    writes: vec![(2, pattern_block(0xA5)), (data_start, escaped)],
1433                    revokes: vec![],
1434                    corrupt_commit: false,
1435                },
1436                TestTransaction {
1437                    writes: vec![(data_start + 1, pattern_block(0x22))],
1438                    revokes: vec![data_start],
1439                    corrupt_commit: false,
1440                },
1441            ],
1442        );
1443
1444        grow_image(&path, 512 * MIB).unwrap();
1445
1446        let output = match std::process::Command::new("e2fsck")
1447            .arg("-fn")
1448            .arg(&path)
1449            .output()
1450        {
1451            Ok(output) => output,
1452            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1453                eprintln!("e2fsck not found; skipping");
1454                return;
1455            }
1456            Err(error) => panic!("failed to run e2fsck: {error}"),
1457        };
1458        assert!(
1459            output.status.success(),
1460            "e2fsck failed after replay + grow:\nstdout: {}\nstderr: {}",
1461            String::from_utf8_lossy(&output.stdout),
1462            String::from_utf8_lossy(&output.stderr)
1463        );
1464    }
1465}