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. Both the current
5//! resize-inode layout and the exact pre-0.6.9 microsandbox layout are supported. Because both formatters reserve `RESERVED_GDT_BLOCKS` after the GDT, descriptors can extend into
6//! that reserved span without moving any existing metadata: `gdt_blocks + 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::SeekFrom;
13#[cfg(test)]
14use std::io::{Read, Seek, Write};
15use std::path::Path;
16
17use super::format::{EXT4_BG_BLOCK_UNINIT, EXT4_BG_INODE_UNINIT};
18use super::format::{
19    EXT4_BG_INODE_ZEROED, EXT4_BLOCK_SIZE, EXT4_BLOCKS_PER_GROUP, EXT4_DESC_SIZE, EXT4_EH_MAGIC,
20    EXT4_EXTENTS_FL, EXT4_FEATURE_COMPAT_DIR_INDEX, EXT4_FEATURE_COMPAT_EXT_ATTR,
21    EXT4_FEATURE_COMPAT_HAS_JOURNAL, EXT4_FEATURE_COMPAT_RESIZE_INODE, EXT4_FEATURE_INCOMPAT_64BIT,
22    EXT4_FEATURE_INCOMPAT_EXTENTS, EXT4_FEATURE_INCOMPAT_FILETYPE, EXT4_FEATURE_INCOMPAT_RECOVER,
23    EXT4_FEATURE_RO_COMPAT_DIR_NLINK, EXT4_FEATURE_RO_COMPAT_EXTRA_ISIZE,
24    EXT4_FEATURE_RO_COMPAT_HUGE_FILE, EXT4_FEATURE_RO_COMPAT_LARGE_FILE,
25    EXT4_FEATURE_RO_COMPAT_METADATA_CSUM, EXT4_FEATURE_RO_COMPAT_SPARSE_SUPER, EXT4_FIRST_INO,
26    EXT4_INODE_SIZE, EXT4_INODES_PER_GROUP, EXT4_JOURNAL_INO, EXT4_LOG_BLOCK_SIZE, EXT4_RESIZE_INO,
27    EXT4_ROOT_INO, EXT4_SB_ERROR_COUNT_OFFSET, EXT4_SB_OVERHEAD_BLOCKS_OFFSET, EXT4_SUPER_MAGIC,
28    S_IFDIR, sparse_super_group,
29};
30use super::formatter::Ext4Error;
31use super::jbd2;
32use super::layout::{
33    GroupDescStats, GroupGeometry, MAX_BLOCKS, bitmap_checksum, build_block_bitmap_base,
34    build_group_descriptor, build_inode_bitmap_base, count_used_bits, dir_block_checksum,
35    gdt_checksum, get_le16, get_le32, inode_checksum, put_le16, put_le32, superblock_checksum,
36    write_backup_superblock_at, write_gdt_at,
37};
38use super::resize_inode::{validate_resize_inode, write_resize_inode};
39use super::storage::Ext4Storage;
40use crate::crc32c;
41
42//--------------------------------------------------------------------------------------------------
43// Constants
44//--------------------------------------------------------------------------------------------------
45
46/// Byte offset of the 1024-byte superblock within the image.
47const SB_OFFSET: u64 = 1024;
48
49/// On-disk superblock size.
50const SB_SIZE: usize = 1024;
51
52/// Feature mask written by microsandbox through v0.6.8. These images reserve
53/// GDT headroom but intentionally have no resize inode.
54const LEGACY_FEATURE_COMPAT: u32 =
55    EXT4_FEATURE_COMPAT_HAS_JOURNAL | EXT4_FEATURE_COMPAT_EXT_ATTR | EXT4_FEATURE_COMPAT_DIR_INDEX;
56
57/// Feature mask written by the current formatter.
58const MODERN_FEATURE_COMPAT: u32 = LEGACY_FEATURE_COMPAT | EXT4_FEATURE_COMPAT_RESIZE_INODE;
59
60/// Maximum extent-tree depth accepted by ext4. Normal guest activity can grow a directory beyond
61/// the inline extent root, so legacy recognition must be able to follow its left-most path.
62const EXT4_MAX_EXTENT_DEPTH: u16 = 5;
63
64//--------------------------------------------------------------------------------------------------
65// Types
66//--------------------------------------------------------------------------------------------------
67
68/// Result of a successful offline grow.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct GrowOutcome {
71    /// 4 KiB block count before the grow.
72    pub old_blocks: u64,
73
74    /// 4 KiB block count after the grow.
75    pub new_blocks: u64,
76
77    /// Block group count before the grow.
78    pub old_groups: u32,
79
80    /// Block group count after the grow.
81    pub new_groups: u32,
82}
83
84/// Resize metadata layout identified from both feature flags and structural
85/// invariants. A dirty modern image has no parsed block until journal replay
86/// completes and the clean image is validated again.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum ResizeMetadata {
89    Legacy,
90    Modern { block: Option<u64> },
91}
92
93impl ResizeMetadata {
94    fn overhead_blocks_offset(self) -> usize {
95        match self {
96            // The pre-0.6.9 formatter used 0x194 for its used-block/overhead counter. Preserve
97            // that released layout exactly instead of silently converting superblock semantics.
98            Self::Legacy => EXT4_SB_ERROR_COUNT_OFFSET,
99            Self::Modern { .. } => EXT4_SB_OVERHEAD_BLOCKS_OFFSET,
100        }
101    }
102}
103
104/// Superblock and primary GDT state parsed from an image and validated to match exactly what
105/// this crate's formatter writes.
106struct ParsedImage {
107    /// Raw 1024-byte primary superblock.
108    sb: Vec<u8>,
109
110    /// 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.
111    gdt: Vec<u8>,
112
113    /// `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.
114    needs_recovery: bool,
115
116    num_blocks: u64,
117    num_groups: u32,
118    gdt_blocks: u32,
119    reserved_gdt_blocks: u32,
120    inode_table_blocks: u32,
121    csum_seed: u32,
122    free_blocks: u64,
123    free_inodes: u32,
124    overhead_blocks: u32,
125    resize_metadata: ResizeMetadata,
126}
127
128//--------------------------------------------------------------------------------------------------
129// Methods
130//--------------------------------------------------------------------------------------------------
131
132impl ParsedImage {
133    fn geometry(&self) -> GroupGeometry {
134        GroupGeometry {
135            num_blocks: self.num_blocks,
136            gdt_blocks: self.gdt_blocks,
137            reserved_gdt_blocks: self.reserved_gdt_blocks,
138            inode_table_blocks: self.inode_table_blocks,
139        }
140    }
141
142    /// Largest block count this image can grow to in place: every group needs a descriptor, and
143    /// descriptors must fit within the blocks already set aside for the GDT (allocated +
144    /// reserved), since the data that follows them cannot be moved offline.
145    fn max_growable_blocks(&self) -> u64 {
146        let descs_per_block = (EXT4_BLOCK_SIZE / EXT4_DESC_SIZE as u32) as u64;
147        let capacity_groups =
148            (self.gdt_blocks as u64 + self.reserved_gdt_blocks as u64) * descs_per_block;
149        (capacity_groups * EXT4_BLOCKS_PER_GROUP as u64).min(MAX_BLOCKS)
150    }
151
152    fn resize_inode_block(&self) -> Result<Option<u64>, Ext4Error> {
153        match self.resize_metadata {
154            ResizeMetadata::Legacy => Ok(None),
155            ResizeMetadata::Modern { block: Some(block) } => Ok(Some(block)),
156            ResizeMetadata::Modern { block: None } => Err(unsupported(
157                "modern resize metadata was not validated after journal recovery",
158            )),
159        }
160    }
161}
162
163//--------------------------------------------------------------------------------------------------
164// Functions
165//--------------------------------------------------------------------------------------------------
166
167/// Grow the formatter-produced ext4 image at `path` to `new_size_bytes`.
168///
169/// Shrinking and no-op sizes are refused, the size must be a 4 KiB multiple, and the new group
170/// descriptors must fit within the image's existing GDT capacity (see
171/// [`Ext4Error::ExceedsGdtCapacity`]).
172///
173/// Images left dirty by a hard guest stop (`EXT4_FEATURE_INCOMPAT_RECOVER` set) have their jbd2
174/// log replayed and the flag cleared everywhere before growing; any journal inconsistency aborts
175/// with the image untouched.
176///
177/// Publish ordering: all new-group metadata, backup superblocks, backup GDTs, and resize-inode
178/// metadata are written and fsynced before the primary superblock advertises the larger geometry.
179/// This is not a transactional rollback boundary: if the in-place grow returns an error or the
180/// process is interrupted, callers must discard and recreate the artifact rather than use it.
181pub fn grow_image(path: &Path, new_size_bytes: u64) -> Result<GrowOutcome, Ext4Error> {
182    let mut file = OpenOptions::new().read(true).write(true).open(path)?;
183    grow_storage(&mut file, new_size_bytes, false)
184}
185
186/// Grow a caller-owned staging disk; errors must never publish the staging artifact.
187pub(crate) fn grow_storage(
188    mut file: &mut impl Ext4Storage,
189    new_size_bytes: u64,
190    allow_completed_target: bool,
191) -> Result<GrowOutcome, Ext4Error> {
192    let mut img = parse_and_validate(&mut file)?;
193
194    // 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
195    // successful replay the image must re-validate as a clean formatter image (the deep GDT checks were skipped on the dirty parse).
196    if img.needs_recovery {
197        replay_journal_and_clear_recover(&mut file, &img)?;
198        img = parse_and_validate(&mut file)?;
199        if img.needs_recovery {
200            return Err(unsupported("journal recovery left the RECOVER flag set"));
201        }
202    }
203
204    let block_size = EXT4_BLOCK_SIZE as u64;
205    if !new_size_bytes.is_multiple_of(block_size) {
206        return Err(Ext4Error::InvalidSize(format!(
207            "image size must be aligned to {block_size} bytes"
208        )));
209    }
210    let new_blocks = new_size_bytes / block_size;
211    if new_blocks > MAX_BLOCKS {
212        return Err(Ext4Error::TooLarge {
213            requested_blocks: new_blocks,
214            max_blocks: MAX_BLOCKS,
215        });
216    }
217    if allow_completed_target && new_blocks == img.num_blocks {
218        // Recovery may replay a committed online-grow superblock from the journal. Only
219        // acknowledge an already-complete target after normal geometry/checksum validation.
220        return Ok(GrowOutcome {
221            old_blocks: img.num_blocks,
222            new_blocks,
223            old_groups: img.num_groups,
224            new_groups: img.num_groups,
225        });
226    }
227    if new_blocks <= img.num_blocks {
228        return Err(Ext4Error::InvalidSize(format!(
229            "cannot grow image from {} to {} bytes: the new size must be larger than the current size",
230            img.num_blocks * block_size,
231            new_size_bytes
232        )));
233    }
234    if new_blocks > img.max_growable_blocks() {
235        return Err(Ext4Error::ExceedsGdtCapacity {
236            requested_bytes: new_size_bytes,
237            max_size_bytes: img.max_growable_blocks() * block_size,
238        });
239    }
240
241    let new_groups = new_blocks.div_ceil(EXT4_BLOCKS_PER_GROUP as u64) as u32;
242    let descs_per_block = EXT4_BLOCK_SIZE / EXT4_DESC_SIZE as u32;
243    let new_gdt_blocks = new_groups.div_ceil(descs_per_block);
244
245    // Descriptors may extend into the reserved GDT span, but `gdt_blocks + reserved` stays
246    // constant so no existing per-group metadata moves.
247    let gdt_span = img.gdt_blocks + img.reserved_gdt_blocks;
248    let new_reserved = gdt_span - new_gdt_blocks;
249
250    let new_geo = GroupGeometry {
251        num_blocks: new_blocks,
252        gdt_blocks: new_gdt_blocks,
253        reserved_gdt_blocks: new_reserved,
254        inode_table_blocks: img.inode_table_blocks,
255    };
256
257    // Same partial-final-group rule as the formatter: a new group must be able to hold its
258    // own metadata.
259    for group in img.num_groups..new_groups {
260        let blocks_in_group = new_geo.blocks_in_group(group);
261        let metadata_blocks = new_geo.group_metadata_blocks(group);
262        if blocks_in_group < metadata_blocks {
263            return Err(Ext4Error::InvalidSize(format!(
264                "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"
265            )));
266        }
267    }
268
269    file.grow(new_size_bytes)?;
270
271    let mut gdt = img.gdt.clone();
272    let mut total_free = img.free_blocks;
273    let mut overhead = img.overhead_blocks as u64;
274
275    // If the old final group was partial, the padding bits past its old end become real free
276    // blocks: clear them in its bitmap and refresh its descriptor.
277    let old_last = img.num_groups - 1;
278    let old_geo = img.geometry();
279    let old_last_blocks = old_geo.blocks_in_group(old_last);
280    let new_last_blocks = new_geo.blocks_in_group(old_last);
281    let mut extended_last_bitmap: Option<Vec<u8>> = None;
282    if new_last_blocks > old_last_blocks {
283        let off = old_last as usize * EXT4_DESC_SIZE as usize;
284        let flags = get_le16(&gdt[off..], 0x12);
285        // Online ext4 growth may leave a lazy block bitmap. Its bytes are not meaningful
286        // until initialized, so derive the metadata-only bitmap rather than reading stale data.
287        let mut bitmap = if flags & EXT4_BG_BLOCK_UNINIT != 0 {
288            let expected_free = old_last_blocks - old_geo.group_metadata_blocks(old_last);
289            let recorded_free = u32::from(get_le16(&gdt[off..], 0x0C))
290                | (u32::from(get_le16(&gdt[off..], 0x2C)) << 16);
291            if recorded_free != expected_free {
292                return Err(unsupported("uninitialized group has allocated data"));
293            }
294            build_block_bitmap_base(&old_geo, old_last)
295        } else {
296            read_block_at(&mut file, old_geo.group_block_bitmap_block(old_last))?
297        };
298        for bit in old_last_blocks..new_last_blocks {
299            bitmap[(bit / 8) as usize] &= !(1 << (bit % 8));
300        }
301        let bb_csum = bitmap_checksum(img.csum_seed, &bitmap, EXT4_BLOCK_SIZE as usize);
302        let delta = new_last_blocks - old_last_blocks;
303
304        let off = old_last as usize * EXT4_DESC_SIZE as usize;
305        let desc = &mut gdt[off..off + EXT4_DESC_SIZE as usize];
306        put_le16(desc, 0x12, flags & !EXT4_BG_BLOCK_UNINIT);
307        let free_blocks =
308            (get_le16(desc, 0x0C) as u32 | ((get_le16(desc, 0x2C) as u32) << 16)) + delta;
309        put_le16(desc, 0x0C, free_blocks as u16);
310        put_le16(desc, 0x2C, (free_blocks >> 16) as u16);
311        put_le16(desc, 0x18, bb_csum as u16);
312        put_le16(desc, 0x38, (bb_csum >> 16) as u16);
313        put_le16(desc, 0x1E, 0);
314        let checksum = gdt_checksum(img.csum_seed, old_last, desc);
315        put_le16(desc, 0x1E, checksum);
316
317        total_free += delta as u64;
318        extended_last_bitmap = Some(bitmap);
319    }
320
321    // New groups: bitmaps on disk, descriptors in memory. Inode tables stay sparse zeros,
322    // matching the formatter's EXT4_BG_INODE_ZEROED groups.
323    for group in img.num_groups..new_groups {
324        let block_bitmap = build_block_bitmap_base(&new_geo, group);
325        let inode_bitmap = build_inode_bitmap_base(0);
326        write_block_at(
327            &mut file,
328            new_geo.group_block_bitmap_block(group),
329            &block_bitmap,
330        )?;
331        write_block_at(
332            &mut file,
333            new_geo.group_inode_bitmap_block(group),
334            &inode_bitmap,
335        )?;
336
337        let free_blocks = new_geo.blocks_in_group(group) - new_geo.group_metadata_blocks(group);
338        let stats = GroupDescStats {
339            free_blocks,
340            free_inodes: EXT4_INODES_PER_GROUP,
341            used_dirs: 0,
342            block_bitmap_csum: bitmap_checksum(
343                img.csum_seed,
344                &block_bitmap,
345                EXT4_BLOCK_SIZE as usize,
346            ),
347            inode_bitmap_csum: bitmap_checksum(
348                img.csum_seed,
349                &inode_bitmap,
350                (EXT4_INODES_PER_GROUP / 8) as usize,
351            ),
352        };
353        gdt.extend_from_slice(&build_group_descriptor(
354            &new_geo,
355            group,
356            &stats,
357            img.csum_seed,
358        ));
359
360        total_free += free_blocks as u64;
361        overhead += new_geo.group_metadata_blocks(group) as u64;
362    }
363
364    let added_groups = new_groups - img.num_groups;
365    let mut new_sb = img.sb.clone();
366    put_le32(&mut new_sb, 0x00, new_groups * EXT4_INODES_PER_GROUP);
367    put_le32(&mut new_sb, 0x04, new_blocks as u32);
368    put_le32(&mut new_sb, 0x150, (new_blocks >> 32) as u32);
369    put_le32(&mut new_sb, 0x0C, total_free as u32);
370    put_le32(&mut new_sb, 0x158, (total_free >> 32) as u32);
371    put_le32(
372        &mut new_sb,
373        0x10,
374        img.free_inodes + added_groups * EXT4_INODES_PER_GROUP,
375    );
376    put_le16(&mut new_sb, 0xCE, new_reserved as u16);
377    put_le32(
378        &mut new_sb,
379        img.resize_metadata.overhead_blocks_offset(),
380        overhead as u32,
381    );
382    let new_sb_csum = superblock_checksum(&new_sb);
383    put_le32(&mut new_sb, 0x3FC, new_sb_csum);
384
385    // Phase 1: everything invisible while the old primary superblock is in place — new-group
386    // bitmaps (written above), descriptors appended past the old end of the primary GDT, and
387    // every backup superblock + GDT copy.
388    let old_gdt_len = img.num_groups as usize * EXT4_DESC_SIZE as usize;
389    file.seek(SeekFrom::Start(EXT4_BLOCK_SIZE as u64 + old_gdt_len as u64))?;
390    file.write_all(&gdt[old_gdt_len..])?;
391
392    for group in 1..new_groups {
393        if !sparse_super_group(group) {
394            continue;
395        }
396        let mut backup_sb = new_sb.clone();
397        put_le16(&mut backup_sb, 0x5A, group as u16);
398        let backup_sb_csum = superblock_checksum(&backup_sb);
399        put_le32(&mut backup_sb, 0x3FC, backup_sb_csum);
400        write_backup_superblock_at(&mut file, new_geo.group_start_block(group), &backup_sb)?;
401        write_gdt_at(&mut file, new_geo.group_start_block(group), &gdt)?;
402    }
403    // Modern images account for reserved GDT blocks through inode 7. Legacy microsandbox images
404    // predate that ownership graph and must retain their original layout; fabricating inode 7
405    // here would require allocating and publishing another data block transactionally.
406    if let Some(resize_inode_block) = img.resize_inode_block()? {
407        write_resize_inode(
408            &mut file,
409            &new_geo,
410            new_geo.group_inode_table_block(0),
411            resize_inode_block,
412            img.csum_seed,
413            new_groups,
414        )?;
415    }
416    file.sync_all()?;
417
418    // Phase 2: the only pre-publish writes visible at the old size (the old final group's
419    // bitmap padding and free count). A tear here still leaves the old superblock intact and
420    // the drift is limited to that one group's padding bits and free count.
421    if let Some(bitmap) = &extended_last_bitmap {
422        write_block_at(
423            &mut file,
424            old_geo.group_block_bitmap_block(old_last),
425            bitmap,
426        )?;
427        let off = old_last as usize * EXT4_DESC_SIZE as usize;
428        file.seek(SeekFrom::Start(EXT4_BLOCK_SIZE as u64 + off as u64))?;
429        file.write_all(&gdt[off..off + EXT4_DESC_SIZE as usize])?;
430        file.sync_all()?;
431    }
432
433    // Phase 3: publish the grow by rewriting the primary superblock last.
434    file.seek(SeekFrom::Start(SB_OFFSET))?;
435    file.write_all(&new_sb)?;
436    file.sync_all()?;
437
438    Ok(GrowOutcome {
439        old_blocks: img.num_blocks,
440        new_blocks,
441        old_groups: img.num_groups,
442        new_groups,
443    })
444}
445
446/// Validate a newly materialized rootfs without mounting it or trusting host filesystem tools.
447pub(super) fn validate_rootfs_image(path: &Path) -> Result<(), Ext4Error> {
448    let mut file = File::open(path)?;
449    let img = parse_and_validate(&mut file)?;
450    if img.needs_recovery {
451        return Err(unsupported(
452            "new rootfs unexpectedly requires journal recovery",
453        ));
454    }
455    if matches!(img.resize_metadata, ResizeMetadata::Legacy) {
456        return Err(unsupported(
457            "new rootfs unexpectedly uses the legacy resize-metadata layout",
458        ));
459    }
460    let geometry = img.geometry();
461    let mut total_free_blocks = 0u64;
462    let mut total_free_inodes = 0u64;
463
464    for group in 0..img.num_groups {
465        let descriptor =
466            &img.gdt[group as usize * EXT4_DESC_SIZE as usize..][..EXT4_DESC_SIZE as usize];
467        let block_bitmap = read_block_at(&mut file, geometry.group_block_bitmap_block(group))?;
468        let inode_bitmap = read_block_at(&mut file, geometry.group_inode_bitmap_block(group))?;
469        validate_group_bitmaps(&img, group, descriptor, &block_bitmap, &inode_bitmap)?;
470
471        let used_blocks = count_used_bits(&block_bitmap, geometry.blocks_in_group(group) as usize);
472        total_free_blocks += u64::from(geometry.blocks_in_group(group)) - used_blocks as u64;
473        let used_inodes = count_used_bits(&inode_bitmap, EXT4_INODES_PER_GROUP as usize);
474        total_free_inodes += u64::from(EXT4_INODES_PER_GROUP) - used_inodes as u64;
475
476        for local_inode in 0..EXT4_INODES_PER_GROUP {
477            if inode_bitmap[(local_inode / 8) as usize] & (1 << (local_inode % 8)) == 0 {
478                continue;
479            }
480            let inode_number = group * EXT4_INODES_PER_GROUP + local_inode + 1;
481            if inode_number < EXT4_FIRST_INO
482                && inode_number != EXT4_ROOT_INO
483                && inode_number != EXT4_JOURNAL_INO
484            {
485                continue;
486            }
487            validate_allocated_inode(
488                &mut file,
489                &img,
490                group,
491                local_inode,
492                inode_number,
493                &block_bitmap,
494            )?;
495        }
496    }
497
498    if total_free_blocks != img.free_blocks || total_free_inodes != u64::from(img.free_inodes) {
499        return Err(unsupported(
500            "superblock free-space counters do not match bitmaps",
501        ));
502    }
503    validate_resize_inode(
504        &mut file,
505        &geometry,
506        geometry.group_inode_table_block(0),
507        img.csum_seed,
508        img.num_groups,
509        img.num_blocks,
510    )?;
511    validate_backup_metadata(&mut file, &img)?;
512    Ok(())
513}
514
515/// Parse the primary superblock and GDT, refusing anything that does not match exactly what this
516/// crate's formatter writes (geometry, feature masks, per-group layout, checksums).
517fn parse_and_validate(file: &mut impl Ext4Storage) -> Result<ParsedImage, Ext4Error> {
518    let file_len = file.length()?;
519    if file_len < SB_OFFSET + SB_SIZE as u64 {
520        return Err(unsupported("file too small to contain an ext4 superblock"));
521    }
522
523    let mut sb = vec![0u8; SB_SIZE];
524    file.seek(SeekFrom::Start(SB_OFFSET))?;
525    file.read_exact(&mut sb)?;
526
527    if get_le16(&sb, 0x38) != EXT4_SUPER_MAGIC {
528        return Err(unsupported("bad superblock magic"));
529    }
530    if superblock_checksum(&sb) != get_le32(&sb, 0x3FC) {
531        return Err(unsupported("superblock checksum mismatch"));
532    }
533
534    let compat = get_le32(&sb, 0x5C);
535    let incompat = get_le32(&sb, 0x60);
536    let ro_compat = get_le32(&sb, 0x64);
537    let expected_incompat = EXT4_FEATURE_INCOMPAT_FILETYPE
538        | EXT4_FEATURE_INCOMPAT_EXTENTS
539        | EXT4_FEATURE_INCOMPAT_64BIT;
540    let expected_ro_compat = EXT4_FEATURE_RO_COMPAT_SPARSE_SUPER
541        | EXT4_FEATURE_RO_COMPAT_LARGE_FILE
542        | EXT4_FEATURE_RO_COMPAT_HUGE_FILE
543        | EXT4_FEATURE_RO_COMPAT_DIR_NLINK
544        | EXT4_FEATURE_RO_COMPAT_EXTRA_ISIZE
545        | EXT4_FEATURE_RO_COMPAT_METADATA_CSUM;
546    // Acceptance rule: exactly one of microsandbox's two formatter masks, with one exception —
547    // INCOMPAT_RECOVER may additionally be set because every mounted upper carries it. Structural
548    // validation below distinguishes a real legacy image from a damaged modern image whose
549    // RESIZE_INODE bit was merely cleared.
550    let resize_metadata = match compat {
551        LEGACY_FEATURE_COMPAT => ResizeMetadata::Legacy,
552        MODERN_FEATURE_COMPAT => ResizeMetadata::Modern { block: None },
553        _ => {
554            return Err(unsupported(format!(
555                "feature flags do not match this crate's formatter (compat={compat:#x}, incompat={incompat:#x}, ro_compat={ro_compat:#x})"
556            )));
557        }
558    };
559    let needs_recovery = incompat & EXT4_FEATURE_INCOMPAT_RECOVER != 0;
560    if incompat & !EXT4_FEATURE_INCOMPAT_RECOVER != expected_incompat
561        || ro_compat != expected_ro_compat
562    {
563        return Err(unsupported(format!(
564            "feature flags do not match this crate's formatter (compat={compat:#x}, incompat={incompat:#x}, ro_compat={ro_compat:#x})"
565        )));
566    }
567
568    let checks: [(bool, &str); 14] = [
569        (get_le32(&sb, 0x4C) == 1, "unexpected revision level"),
570        (
571            get_le32(&sb, 0x18) == EXT4_LOG_BLOCK_SIZE,
572            "unexpected block size",
573        ),
574        (
575            get_le32(&sb, 0x1C) == EXT4_LOG_BLOCK_SIZE,
576            "unexpected cluster size",
577        ),
578        (
579            get_le32(&sb, 0x20) == EXT4_BLOCKS_PER_GROUP,
580            "unexpected blocks per group",
581        ),
582        (
583            get_le32(&sb, 0x24) == EXT4_BLOCKS_PER_GROUP,
584            "unexpected clusters per group",
585        ),
586        (
587            get_le32(&sb, 0x28) == EXT4_INODES_PER_GROUP,
588            "unexpected inodes per group",
589        ),
590        (
591            get_le16(&sb, 0x58) == EXT4_INODE_SIZE,
592            "unexpected inode size",
593        ),
594        (
595            get_le16(&sb, 0xFE) == EXT4_DESC_SIZE,
596            "unexpected group descriptor size",
597        ),
598        (get_le32(&sb, 0x14) == 0, "unexpected first data block"),
599        (
600            get_le32(&sb, 0x54) == EXT4_FIRST_INO,
601            "unexpected first inode",
602        ),
603        (get_le16(&sb, 0x5A) == 0, "not a primary superblock"),
604        (sb[0x175] == 1, "unexpected metadata checksum type"),
605        (
606            sb[0x174] == 0 && get_le32(&sb, 0x104) == 0,
607            "unexpected flex_bg/meta_bg layout",
608        ),
609        (
610            matches!(resize_metadata, ResizeMetadata::Legacy)
611                || get_le32(&sb, EXT4_SB_ERROR_COUNT_OFFSET) == 0,
612            "superblock error count is nonzero",
613        ),
614    ];
615    for (ok, message) in checks {
616        if !ok {
617            return Err(unsupported(message));
618        }
619    }
620    // 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
621    // cleared — means damage that journal replay cannot repair.
622    if get_le16(&sb, 0x3A) != 1 {
623        return Err(unsupported("filesystem state is not clean (s_state != 1)"));
624    }
625
626    let num_blocks = get_le32(&sb, 0x04) as u64 | ((get_le32(&sb, 0x150) as u64) << 32);
627    if num_blocks == 0 || num_blocks > MAX_BLOCKS {
628        return Err(unsupported("implausible block count"));
629    }
630    if file_len != num_blocks * EXT4_BLOCK_SIZE as u64 {
631        return Err(unsupported(
632            "file length does not match superblock block count",
633        ));
634    }
635
636    let num_groups = num_blocks.div_ceil(EXT4_BLOCKS_PER_GROUP as u64) as u32;
637    if get_le32(&sb, 0x00) != num_groups * EXT4_INODES_PER_GROUP {
638        return Err(unsupported("inode count does not match group count"));
639    }
640
641    let reserved_gdt_blocks = get_le16(&sb, 0xCE) as u32;
642    let gdt_blocks =
643        (num_groups as u64 * EXT4_DESC_SIZE as u64).div_ceil(EXT4_BLOCK_SIZE as u64) as u32;
644    let inode_table_blocks =
645        (EXT4_INODES_PER_GROUP as u64 * EXT4_INODE_SIZE as u64 / EXT4_BLOCK_SIZE as u64) as u32;
646
647    let mut uuid = [0u8; 16];
648    uuid.copy_from_slice(&sb[0x68..0x78]);
649    let csum_seed = crc32c::crc32c_raw(0xFFFF_FFFF, &uuid);
650
651    let img = ParsedImage {
652        num_blocks,
653        num_groups,
654        gdt_blocks,
655        reserved_gdt_blocks,
656        inode_table_blocks,
657        csum_seed,
658        free_blocks: get_le32(&sb, 0x0C) as u64 | ((get_le32(&sb, 0x158) as u64) << 32),
659        free_inodes: get_le32(&sb, 0x10),
660        overhead_blocks: get_le32(&sb, resize_metadata.overhead_blocks_offset()),
661        resize_metadata,
662        gdt: Vec::new(),
663        needs_recovery,
664        sb,
665    };
666
667    let geo = img.geometry();
668    if (geo.group_metadata_blocks(0) as u64) > geo.blocks_in_group(0) as u64 {
669        return Err(unsupported("group 0 metadata does not fit its group"));
670    }
671
672    // 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
673    // clean image; grow_image replays and re-parses before growing.
674    if img.needs_recovery {
675        return Ok(img);
676    }
677
678    // A non-empty orphan list needs inode-level processing (truncating/deleting inodes that were unlinked while open) that this resizer does not implement.
679    if get_le32(&img.sb, 0xE8) != 0 {
680        return Err(unsupported("filesystem has a pending orphan inode list"));
681    }
682
683    // Every existing descriptor must place its group's metadata exactly where the formatter's
684    // layout does and carry a valid checksum; anything else means the image is not ours.
685    let mut gdt = vec![0u8; img.num_groups as usize * EXT4_DESC_SIZE as usize];
686    file.seek(SeekFrom::Start(EXT4_BLOCK_SIZE as u64))?;
687    file.read_exact(&mut gdt)?;
688    for group in 0..img.num_groups {
689        let desc = &gdt[group as usize * EXT4_DESC_SIZE as usize..][..EXT4_DESC_SIZE as usize];
690        let bb = get_le32(desc, 0x00) as u64 | ((get_le32(desc, 0x20) as u64) << 32);
691        let ib = get_le32(desc, 0x04) as u64 | ((get_le32(desc, 0x24) as u64) << 32);
692        let it = get_le32(desc, 0x08) as u64 | ((get_le32(desc, 0x28) as u64) << 32);
693        if bb != geo.group_block_bitmap_block(group)
694            || ib != geo.group_inode_bitmap_block(group)
695            || it != geo.group_inode_table_block(group)
696        {
697            return Err(unsupported(format!(
698                "group {group} metadata is not at the expected location"
699            )));
700        }
701        let flags = get_le16(desc, 0x12);
702        let known_flags = EXT4_BG_INODE_ZEROED | EXT4_BG_INODE_UNINIT | EXT4_BG_BLOCK_UNINIT;
703        if flags & !known_flags != 0
704            || (matches!(img.resize_metadata, ResizeMetadata::Legacy)
705                && flags != EXT4_BG_INODE_ZEROED)
706        {
707            return Err(unsupported(format!("group {group} has unexpected flags")));
708        }
709        let mut desc_copy = desc.to_vec();
710        put_le16(&mut desc_copy, 0x1E, 0);
711        if gdt_checksum(img.csum_seed, group, &desc_copy) != get_le16(desc, 0x1E) {
712            return Err(unsupported(format!(
713                "group {group} descriptor checksum mismatch"
714            )));
715        }
716    }
717
718    let resize_metadata = match img.resize_metadata {
719        ResizeMetadata::Legacy => {
720            validate_legacy_resize_metadata(file, &img)?;
721            ResizeMetadata::Legacy
722        }
723        ResizeMetadata::Modern { .. } => {
724            let block = validate_resize_inode(
725                file,
726                &geo,
727                geo.group_inode_table_block(0),
728                img.csum_seed,
729                img.num_groups,
730                img.num_blocks,
731            )?;
732            ResizeMetadata::Modern { block: Some(block) }
733        }
734    };
735
736    Ok(ParsedImage {
737        gdt,
738        resize_metadata,
739        ..img
740    })
741}
742
743/// Validate the exact layout emitted through v0.6.8.
744///
745/// Feature flags alone are insufficient: clearing RESIZE_INODE on a modern
746/// image must not route it through the legacy grow path. Old images have a
747/// zero inode 7, place the root directory immediately after the inode table,
748/// and leave every still-reserved primary GDT block sparse-zeroed. These
749/// invariants remain true after any number of grows by the legacy resizer.
750fn validate_legacy_resize_metadata(
751    file: &mut impl Ext4Storage,
752    img: &ParsedImage,
753) -> Result<(), Ext4Error> {
754    let geometry = img.geometry();
755    let resize_inode = read_inode(file, &geometry, EXT4_RESIZE_INO)?;
756    if resize_inode.iter().any(|byte| *byte != 0) {
757        return Err(unsupported("legacy image has a non-empty resize inode"));
758    }
759
760    let root_inode = read_inode(file, &geometry, EXT4_ROOT_INO)?;
761    let stored_checksum =
762        u32::from(get_le16(&root_inode, 0x7C)) | (u32::from(get_le16(&root_inode, 0x82)) << 16);
763    if inode_checksum(
764        img.csum_seed,
765        EXT4_ROOT_INO,
766        get_le32(&root_inode, 0x64),
767        &root_inode,
768    ) != stored_checksum
769    {
770        return Err(unsupported("legacy image root inode checksum mismatch"));
771    }
772    if get_le16(&root_inode, 0) & 0xF000 != S_IFDIR
773        || get_le32(&root_inode, 0x20) & EXT4_EXTENTS_FL == 0
774    {
775        return Err(unsupported(
776            "legacy image root inode is not an extent-backed directory",
777        ));
778    }
779    let xattr_block =
780        u64::from(get_le32(&root_inode, 0x68)) | (u64::from(get_le16(&root_inode, 0x76)) << 32);
781    if xattr_block != 0 {
782        validate_external_xattrs(file, img, xattr_block, EXT4_ROOT_INO)?;
783    }
784    if get_le32(&root_inode, 0xA0) == 0xEA02_0000 {
785        validate_xattr_entries(&root_inode[0xA4..], 0xA4, root_inode.len(), EXT4_ROOT_INO)?;
786    }
787
788    // Only logical block zero is a creation-time fingerprint. The guest may add and fragment
789    // later root-directory blocks, turning the inline leaf into a multi-level extent tree.
790    let root_block = first_extent_physical_block(file, img, EXT4_ROOT_INO, &root_inode)?;
791    let expected_root_block =
792        geometry.group_inode_table_block(0) + u64::from(img.inode_table_blocks);
793    if root_block != expected_root_block {
794        return Err(unsupported(
795            "legacy image root directory is not immediately after the inode table",
796        ));
797    }
798
799    // A modern resize inode stores backup pointers in these blocks. Requiring zeros prevents a
800    // modern image with a cleared feature bit from passing as legacy. GDT blocks consumed by a
801    // previous legacy grow are excluded because `gdt_blocks` has already advanced past them.
802    for offset in 0..img.reserved_gdt_blocks {
803        let block = 1 + u64::from(img.gdt_blocks + offset);
804        if read_block_at(file, block)?.iter().any(|byte| *byte != 0) {
805            return Err(unsupported(format!(
806                "legacy image reserved GDT block {block} is not zeroed"
807            )));
808        }
809    }
810
811    Ok(())
812}
813
814/// Resolve logical block zero through a checksummed extent tree.
815///
816/// Legacy identification only depends on the first root-directory block. Following the left-most
817/// index path preserves that stable fingerprint while accepting extent fanout created by normal
818/// guest writes.
819fn first_extent_physical_block(
820    file: &mut impl Ext4Storage,
821    img: &ParsedImage,
822    inode_number: u32,
823    inode: &[u8],
824) -> Result<u64, Ext4Error> {
825    let generation = get_le32(inode, 0x64);
826    let mut node = inode[0x28..0x64].to_vec();
827    let mut entry_capacity = 4usize;
828    let mut parent_depth: Option<u16> = None;
829
830    loop {
831        if get_le16(&node, 0) != EXT4_EH_MAGIC {
832            return Err(unsupported(format!(
833                "inode {inode_number} has bad extent magic"
834            )));
835        }
836        let entries = usize::from(get_le16(&node, 2));
837        let max = usize::from(get_le16(&node, 4));
838        let depth = get_le16(&node, 6);
839        if entries == 0
840            || entries > max
841            || max > entry_capacity
842            || depth > EXT4_MAX_EXTENT_DEPTH
843            || parent_depth.is_some_and(|parent| depth.checked_add(1) != Some(parent))
844        {
845            return Err(unsupported(format!(
846                "inode {inode_number} has invalid extent header"
847            )));
848        }
849
850        let first = &node[12..24];
851        if get_le32(first, 0) != 0 {
852            return Err(unsupported(format!(
853                "inode {inode_number} extent tree does not start at logical block zero"
854            )));
855        }
856        if depth == 0 {
857            let raw_len = get_le16(first, 4);
858            if raw_len == 0 || raw_len > 0x8000 {
859                return Err(unsupported(format!(
860                    "inode {inode_number} has an invalid first extent"
861                )));
862            }
863            let block_count = if raw_len == 0x8000 {
864                32768
865            } else {
866                u64::from(raw_len)
867            };
868            let physical = u64::from(get_le32(first, 8)) | (u64::from(get_le16(first, 6)) << 32);
869            if physical
870                .checked_add(block_count)
871                .is_none_or(|end| end > img.num_blocks)
872            {
873                return Err(unsupported(format!(
874                    "inode {inode_number} first extent is out of bounds"
875                )));
876            }
877            return Ok(physical);
878        }
879
880        let child_block = u64::from(get_le32(first, 4)) | (u64::from(get_le16(first, 8)) << 32);
881        if child_block >= img.num_blocks {
882            return Err(unsupported(format!(
883                "inode {inode_number} extent index is out of bounds"
884            )));
885        }
886        let child = read_block_at(file, child_block)?;
887        let tail = child.len() - 4;
888        if get_le32(&child, tail)
889            != dir_block_checksum(img.csum_seed, inode_number, generation, &child[..tail])
890        {
891            return Err(unsupported(format!(
892                "inode {inode_number} extent index checksum mismatch"
893            )));
894        }
895        parent_depth = Some(depth);
896        entry_capacity = (tail - 12) / 12;
897        node = child;
898    }
899}
900
901fn read_inode(
902    file: &mut impl Ext4Storage,
903    geometry: &GroupGeometry,
904    inode_number: u32,
905) -> Result<Vec<u8>, Ext4Error> {
906    let group = (inode_number - 1) / EXT4_INODES_PER_GROUP;
907    let local_inode = (inode_number - 1) % EXT4_INODES_PER_GROUP;
908    let offset = geometry.group_inode_table_block(group) * u64::from(EXT4_BLOCK_SIZE)
909        + u64::from(local_inode) * u64::from(EXT4_INODE_SIZE);
910    let mut inode = vec![0u8; EXT4_INODE_SIZE as usize];
911    file.seek(SeekFrom::Start(offset))?;
912    file.read_exact(&mut inode)?;
913    Ok(inode)
914}
915
916/// Replay the pending jbd2 log, then clear `EXT4_FEATURE_INCOMPAT_RECOVER` from the primary and every backup superblock.
917///
918/// 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
919/// 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
920/// leaves an image that the next attempt recovers to the same end state (replaying an already-emptied journal is a no-op).
921fn replay_journal_and_clear_recover(
922    file: &mut impl Ext4Storage,
923    img: &ParsedImage,
924) -> Result<(), Ext4Error> {
925    let geo = img.geometry();
926    let journal = jbd2::locate_journal(file, geo.group_inode_table_block(0), img.csum_seed)?;
927    if journal.start_block + journal.len_blocks as u64 > img.num_blocks {
928        return Err(unsupported("journal extent extends beyond the filesystem"));
929    }
930    let mut fs_uuid = [0u8; 16];
931    fs_uuid.copy_from_slice(&img.sb[0x68..0x78]);
932
933    let backup_groups: Vec<u32> = (1..img.num_groups)
934        .filter(|g| sparse_super_group(*g))
935        .collect();
936    for &group in &backup_groups {
937        read_superblock_at(
938            file,
939            geo.group_start_block(group) * EXT4_BLOCK_SIZE as u64,
940            &format!("group {group} backup"),
941        )?;
942    }
943
944    jbd2::recover_journal(file, &journal, &fs_uuid, img.num_blocks)?;
945
946    // Replay may rewrite block 0 — the primary superblock is journaled metadata like any other — so re-read it before clearing the flag.
947    let mut sb = read_superblock_at(file, SB_OFFSET, "primary")?;
948    clear_recover_flag(&mut sb);
949    file.seek(SeekFrom::Start(SB_OFFSET))?;
950    file.write_all(&sb)?;
951
952    // 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
953    // uniformly clean.
954    for &group in &backup_groups {
955        let offset = geo.group_start_block(group) * EXT4_BLOCK_SIZE as u64;
956        let mut backup = read_superblock_at(file, offset, &format!("group {group} backup"))?;
957        if get_le32(&backup, 0x60) & EXT4_FEATURE_INCOMPAT_RECOVER != 0 {
958            clear_recover_flag(&mut backup);
959            file.seek(SeekFrom::Start(offset))?;
960            file.write_all(&backup)?;
961        }
962    }
963    file.sync_all()?;
964
965    Ok(())
966}
967
968/// Read a 1024-byte superblock at `offset`, refusing bad magic or checksum.
969fn read_superblock_at(
970    file: &mut impl Ext4Storage,
971    offset: u64,
972    label: &str,
973) -> Result<Vec<u8>, Ext4Error> {
974    let mut sb = vec![0u8; SB_SIZE];
975    file.seek(SeekFrom::Start(offset))?;
976    file.read_exact(&mut sb)?;
977    if get_le16(&sb, 0x38) != EXT4_SUPER_MAGIC || superblock_checksum(&sb) != get_le32(&sb, 0x3FC) {
978        return Err(unsupported(format!(
979            "{label} superblock has a bad magic or checksum"
980        )));
981    }
982    Ok(sb)
983}
984
985fn clear_recover_flag(sb: &mut [u8]) {
986    let incompat = get_le32(sb, 0x60) & !EXT4_FEATURE_INCOMPAT_RECOVER;
987    put_le32(sb, 0x60, incompat);
988    let checksum = superblock_checksum(sb);
989    put_le32(sb, 0x3FC, checksum);
990}
991
992fn unsupported(message: impl Into<String>) -> Ext4Error {
993    Ext4Error::Unsupported(message.into())
994}
995
996fn validate_group_bitmaps(
997    img: &ParsedImage,
998    group: u32,
999    descriptor: &[u8],
1000    block_bitmap: &[u8],
1001    inode_bitmap: &[u8],
1002) -> Result<(), Ext4Error> {
1003    let geometry = img.geometry();
1004    let expected_block_checksum =
1005        get_le16(descriptor, 0x18) as u32 | (u32::from(get_le16(descriptor, 0x38)) << 16);
1006    let expected_inode_checksum =
1007        get_le16(descriptor, 0x1A) as u32 | (u32::from(get_le16(descriptor, 0x3A)) << 16);
1008    if bitmap_checksum(img.csum_seed, block_bitmap, EXT4_BLOCK_SIZE as usize)
1009        != expected_block_checksum
1010        || bitmap_checksum(
1011            img.csum_seed,
1012            inode_bitmap,
1013            (EXT4_INODES_PER_GROUP / 8) as usize,
1014        ) != expected_inode_checksum
1015    {
1016        return Err(unsupported(format!(
1017            "group {group} bitmap checksum mismatch"
1018        )));
1019    }
1020
1021    let blocks_in_group = geometry.blocks_in_group(group);
1022    for bit in 0..geometry.group_metadata_blocks(group) {
1023        if block_bitmap[(bit / 8) as usize] & (1 << (bit % 8)) == 0 {
1024            return Err(unsupported(format!(
1025                "group {group} metadata block {bit} is marked free"
1026            )));
1027        }
1028    }
1029    for bit in blocks_in_group..EXT4_BLOCKS_PER_GROUP {
1030        if block_bitmap[(bit / 8) as usize] & (1 << (bit % 8)) == 0 {
1031            return Err(unsupported(format!(
1032                "group {group} block-bitmap padding is marked free"
1033            )));
1034        }
1035    }
1036    for bit in EXT4_INODES_PER_GROUP..(EXT4_BLOCK_SIZE * 8) {
1037        if inode_bitmap[(bit / 8) as usize] & (1 << (bit % 8)) == 0 {
1038            return Err(unsupported(format!(
1039                "group {group} inode-bitmap padding is marked free"
1040            )));
1041        }
1042    }
1043
1044    let free_blocks =
1045        u32::from(get_le16(descriptor, 0x0C)) | (u32::from(get_le16(descriptor, 0x2C)) << 16);
1046    let free_inodes =
1047        u32::from(get_le16(descriptor, 0x0E)) | (u32::from(get_le16(descriptor, 0x2E)) << 16);
1048    if free_blocks as usize
1049        != blocks_in_group as usize - count_used_bits(block_bitmap, blocks_in_group as usize)
1050        || free_inodes as usize
1051            != EXT4_INODES_PER_GROUP as usize
1052                - count_used_bits(inode_bitmap, EXT4_INODES_PER_GROUP as usize)
1053    {
1054        return Err(unsupported(format!(
1055            "group {group} free-space counters do not match bitmaps"
1056        )));
1057    }
1058    Ok(())
1059}
1060
1061fn validate_allocated_inode(
1062    file: &mut impl Ext4Storage,
1063    img: &ParsedImage,
1064    group: u32,
1065    local_inode: u32,
1066    inode_number: u32,
1067    _block_bitmap: &[u8],
1068) -> Result<(), Ext4Error> {
1069    let inode_offset = img.geometry().group_inode_table_block(group) * EXT4_BLOCK_SIZE as u64
1070        + u64::from(local_inode) * u64::from(EXT4_INODE_SIZE);
1071    let mut inode = vec![0u8; EXT4_INODE_SIZE as usize];
1072    file.seek(SeekFrom::Start(inode_offset))?;
1073    file.read_exact(&mut inode)?;
1074    let stored_checksum =
1075        u32::from(get_le16(&inode, 0x7C)) | (u32::from(get_le16(&inode, 0x82)) << 16);
1076    if inode_checksum(img.csum_seed, inode_number, get_le32(&inode, 0x64), &inode)
1077        != stored_checksum
1078    {
1079        return Err(unsupported(format!(
1080            "inode {inode_number} checksum mismatch"
1081        )));
1082    }
1083    if get_le16(&inode, 0) == 0 {
1084        return Err(unsupported(format!(
1085            "allocated inode {inode_number} has no mode"
1086        )));
1087    }
1088
1089    if get_le32(&inode, 0x20) & EXT4_EXTENTS_FL != 0 {
1090        validate_inode_extent_tree(file, img, inode_number, &inode)?;
1091    }
1092    let xattr_block = u64::from(get_le32(&inode, 0x68)) | (u64::from(get_le16(&inode, 0x76)) << 32);
1093    if xattr_block != 0 {
1094        validate_external_xattrs(file, img, xattr_block, inode_number)?;
1095    }
1096    if get_le32(&inode, 0xA0) == 0xEA02_0000 {
1097        validate_xattr_entries(&inode[0xA4..], 0xA4, inode.len(), inode_number)?;
1098    }
1099    Ok(())
1100}
1101
1102fn validate_inode_extent_tree(
1103    file: &mut impl Ext4Storage,
1104    img: &ParsedImage,
1105    inode_number: u32,
1106    inode: &[u8],
1107) -> Result<(), Ext4Error> {
1108    let root = &inode[0x28..0x64];
1109    if get_le16(root, 0) != EXT4_EH_MAGIC {
1110        return Err(unsupported(format!(
1111            "inode {inode_number} has bad extent magic"
1112        )));
1113    }
1114    let entries = usize::from(get_le16(root, 2));
1115    let max = usize::from(get_le16(root, 4));
1116    let depth = get_le16(root, 6);
1117    if entries > max || max > 4 || depth > 1 {
1118        return Err(unsupported(format!(
1119            "inode {inode_number} has invalid extent header"
1120        )));
1121    }
1122    if depth == 0 {
1123        validate_extent_entries(img, inode_number, &root[12..], entries)
1124    } else {
1125        if entries != 1 {
1126            return Err(unsupported(format!(
1127                "inode {inode_number} has an unsupported extent index fanout"
1128            )));
1129        }
1130        let leaf_block = u64::from(get_le32(root, 16)) | (u64::from(get_le16(root, 20)) << 32);
1131        if leaf_block >= img.num_blocks {
1132            return Err(unsupported(format!(
1133                "inode {inode_number} extent leaf is out of bounds"
1134            )));
1135        }
1136        let leaf = read_block_at(file, leaf_block)?;
1137        let tail = EXT4_BLOCK_SIZE as usize - 4;
1138        if get_le32(&leaf, tail)
1139            != dir_block_checksum(
1140                img.csum_seed,
1141                inode_number,
1142                get_le32(inode, 0x64),
1143                &leaf[..tail],
1144            )
1145            || get_le16(&leaf, 0) != EXT4_EH_MAGIC
1146            || get_le16(&leaf, 6) != 0
1147        {
1148            return Err(unsupported(format!(
1149                "inode {inode_number} has an invalid extent leaf"
1150            )));
1151        }
1152        let leaf_entries = usize::from(get_le16(&leaf, 2));
1153        let leaf_max = usize::from(get_le16(&leaf, 4));
1154        if leaf_entries > leaf_max || 12 + leaf_entries * 12 > tail {
1155            return Err(unsupported(format!(
1156                "inode {inode_number} extent leaf overflows"
1157            )));
1158        }
1159        validate_extent_entries(img, inode_number, &leaf[12..], leaf_entries)
1160    }
1161}
1162
1163fn validate_extent_entries(
1164    img: &ParsedImage,
1165    inode_number: u32,
1166    entries: &[u8],
1167    count: usize,
1168) -> Result<(), Ext4Error> {
1169    let mut logical_end = 0u64;
1170    for index in 0..count {
1171        let entry = &entries[index * 12..][..12];
1172        let logical = u64::from(get_le32(entry, 0));
1173        let raw_len = get_le16(entry, 4);
1174        if raw_len == 0 || raw_len > 0x8000 || logical < logical_end {
1175            return Err(unsupported(format!(
1176                "inode {inode_number} has invalid extent ordering"
1177            )));
1178        }
1179        let block_count = if raw_len == 0x8000 {
1180            32768
1181        } else {
1182            u64::from(raw_len)
1183        };
1184        let physical = u64::from(get_le32(entry, 8)) | (u64::from(get_le16(entry, 6)) << 32);
1185        if physical
1186            .checked_add(block_count)
1187            .is_none_or(|end| end > img.num_blocks)
1188        {
1189            return Err(unsupported(format!(
1190                "inode {inode_number} extent is out of bounds"
1191            )));
1192        }
1193        logical_end = logical + block_count;
1194    }
1195    Ok(())
1196}
1197
1198fn validate_external_xattrs(
1199    file: &mut impl Ext4Storage,
1200    img: &ParsedImage,
1201    block_number: u64,
1202    inode_number: u32,
1203) -> Result<(), Ext4Error> {
1204    if block_number >= img.num_blocks {
1205        return Err(unsupported(format!(
1206            "inode {inode_number} xattr block is out of bounds"
1207        )));
1208    }
1209    let block = read_block_at(file, block_number)?;
1210    if get_le32(&block, 0) != 0xEA02_0000 || get_le32(&block, 8) != 1 {
1211        return Err(unsupported(format!(
1212            "inode {inode_number} has a bad xattr header"
1213        )));
1214    }
1215    let mut checksum_input = block.clone();
1216    put_le32(&mut checksum_input, 16, 0);
1217    let mut checksum = crc32c::crc32c_raw(img.csum_seed, &block_number.to_le_bytes());
1218    checksum = crc32c::crc32c_raw(checksum, &checksum_input);
1219    if checksum != get_le32(&block, 16) {
1220        return Err(unsupported(format!(
1221            "inode {inode_number} xattr checksum mismatch"
1222        )));
1223    }
1224    validate_xattr_entries(&block[32..], 0, block.len(), inode_number)
1225}
1226
1227fn validate_xattr_entries(
1228    entries: &[u8],
1229    base_offset: usize,
1230    end_offset: usize,
1231    inode_number: u32,
1232) -> Result<(), Ext4Error> {
1233    let mut cursor = 0usize;
1234    while cursor + 4 <= entries.len() && get_le32(entries, cursor) != 0 {
1235        if cursor + 16 > entries.len() {
1236            return Err(unsupported(format!(
1237                "inode {inode_number} has truncated xattrs"
1238            )));
1239        }
1240        let name_len = entries[cursor] as usize;
1241        let name_index = entries[cursor + 1];
1242        let value_offset = usize::from(get_le16(entries, cursor + 2));
1243        let value_size = get_le32(entries, cursor + 8) as usize;
1244        let entry_len = (16 + name_len + 3) & !3;
1245        if !matches!(name_index, 1 | 2 | 3 | 4 | 6)
1246            || cursor + entry_len > entries.len()
1247            || base_offset + value_offset + value_size > end_offset
1248        {
1249            return Err(unsupported(format!(
1250                "inode {inode_number} has malformed xattrs"
1251            )));
1252        }
1253        cursor += entry_len;
1254    }
1255    if cursor + 4 > entries.len() {
1256        return Err(unsupported(format!(
1257            "inode {inode_number} xattrs lack a terminator"
1258        )));
1259    }
1260    Ok(())
1261}
1262
1263fn validate_backup_metadata(
1264    file: &mut impl Ext4Storage,
1265    img: &ParsedImage,
1266) -> Result<(), Ext4Error> {
1267    let geometry = img.geometry();
1268    for group in 1..img.num_groups {
1269        if !sparse_super_group(group) {
1270            continue;
1271        }
1272        let start = geometry.group_start_block(group) * EXT4_BLOCK_SIZE as u64;
1273        let backup = read_superblock_at(file, start, &format!("group {group} backup"))?;
1274        if get_le16(&backup, 0x5A) != group as u16 || backup[0..0x18] != img.sb[0..0x18] {
1275            return Err(unsupported(format!(
1276                "group {group} backup superblock differs"
1277            )));
1278        }
1279        let mut backup_gdt = vec![0u8; img.gdt.len()];
1280        file.seek(SeekFrom::Start(start + EXT4_BLOCK_SIZE as u64))?;
1281        file.read_exact(&mut backup_gdt)?;
1282        if backup_gdt != img.gdt {
1283            return Err(unsupported(format!("group {group} backup GDT differs")));
1284        }
1285    }
1286    Ok(())
1287}
1288
1289fn read_block_at(file: &mut impl Ext4Storage, block: u64) -> Result<Vec<u8>, Ext4Error> {
1290    let mut buf = vec![0u8; EXT4_BLOCK_SIZE as usize];
1291    file.seek(SeekFrom::Start(block * EXT4_BLOCK_SIZE as u64))?;
1292    file.read_exact(&mut buf)?;
1293    Ok(buf)
1294}
1295
1296fn write_block_at(file: &mut impl Ext4Storage, block: u64, data: &[u8]) -> Result<(), Ext4Error> {
1297    file.seek(SeekFrom::Start(block * EXT4_BLOCK_SIZE as u64))?;
1298    file.write_all(data)?;
1299    Ok(())
1300}
1301
1302//--------------------------------------------------------------------------------------------------
1303// Tests
1304//--------------------------------------------------------------------------------------------------
1305
1306#[cfg(test)]
1307mod tests {
1308    use super::super::format::JBD2_MAGIC;
1309    use super::super::formatter::{
1310        Ext4FormatOptions, format_ext4, format_ext4_for_test_with_reserved_gdt,
1311        format_ext4_legacy_for_test,
1312    };
1313    use super::super::jbd2::{JournalLocation, TestTransaction, write_test_log};
1314    use super::super::layout::{RESERVED_GDT_BLOCKS, count_used_bits, get_be32, put_be32};
1315    use super::*;
1316    use sha2::{Digest, Sha256};
1317
1318    const MIB: u64 = 1024 * 1024;
1319
1320    fn format_image(path: &Path, size_bytes: u64) {
1321        let opts = Ext4FormatOptions {
1322            size_bytes,
1323            journal_blocks: 4096,
1324        };
1325        format_ext4(path, &opts).unwrap();
1326    }
1327
1328    fn format_legacy_image(path: &Path, size_bytes: u64) {
1329        let opts = Ext4FormatOptions {
1330            size_bytes,
1331            journal_blocks: 4096,
1332        };
1333        format_ext4_legacy_for_test(path, &opts).unwrap();
1334    }
1335
1336    #[test]
1337    fn grow_initializes_a_lazy_partial_group_bitmap_before_extending_it() {
1338        let dir = tempfile::tempdir().unwrap();
1339        let path = dir.path().join("lazy.ext4");
1340        format_image(&path, 300 * MIB);
1341        let mut file = OpenOptions::new()
1342            .read(true)
1343            .write(true)
1344            .open(&path)
1345            .unwrap();
1346        let image = parse_and_validate(&mut file).unwrap();
1347        let last = image.num_groups - 1;
1348        let offset = last as usize * EXT4_DESC_SIZE as usize;
1349        let mut descriptor = image.gdt[offset..offset + EXT4_DESC_SIZE as usize].to_vec();
1350        put_le16(
1351            &mut descriptor,
1352            0x12,
1353            EXT4_BG_INODE_ZEROED | EXT4_BG_BLOCK_UNINIT,
1354        );
1355        put_le16(&mut descriptor, 0x1E, 0);
1356        let checksum = gdt_checksum(image.csum_seed, last, &descriptor);
1357        put_le16(&mut descriptor, 0x1E, checksum);
1358        file.seek(SeekFrom::Start(4096 + offset as u64)).unwrap();
1359        file.write_all(&descriptor).unwrap();
1360        // Bytes of an uninitialized bitmap are deliberately meaningless.
1361        write_block_at(
1362            &mut file,
1363            image.geometry().group_block_bitmap_block(last),
1364            &[0xa5; 4096],
1365        )
1366        .unwrap();
1367        file.sync_all().unwrap();
1368        drop(file);
1369        grow_image(&path, 384 * MIB).unwrap();
1370        let mut file = File::open(&path).unwrap();
1371        let grown = parse_and_validate(&mut file).unwrap();
1372        let desc = &grown.gdt[offset..offset + EXT4_DESC_SIZE as usize];
1373        assert_eq!(get_le16(desc, 0x12), EXT4_BG_INODE_ZEROED);
1374        let bitmap =
1375            read_block_at(&mut file, grown.geometry().group_block_bitmap_block(last)).unwrap();
1376        assert_eq!(bitmap, build_block_bitmap_base(&grown.geometry(), last));
1377    }
1378
1379    /// Reproduce a root directory that has outgrown the inode's inline extent leaf.
1380    ///
1381    /// The kernel normally creates this shape after enough directory churn. Building the same
1382    /// valid on-disk shape directly keeps the regression deterministic and exercises the exact
1383    /// compatibility fingerprint: logical block zero stays at its legacy location while its
1384    /// extent record moves into a checksummed depth-one leaf.
1385    fn grow_legacy_root_extent_tree(path: &Path) -> u64 {
1386        let mut file = OpenOptions::new()
1387            .read(true)
1388            .write(true)
1389            .open(path)
1390            .unwrap();
1391        let img = parse_and_validate(&mut file).unwrap();
1392        assert_eq!(img.resize_metadata, ResizeMetadata::Legacy);
1393        let geo = img.geometry();
1394
1395        let mut root_inode = read_inode(&mut file, &geo, EXT4_ROOT_INO).unwrap();
1396        assert_eq!(get_le16(&root_inode, 0x28), EXT4_EH_MAGIC);
1397        assert_eq!(get_le16(&root_inode, 0x2A), 1);
1398        assert_eq!(get_le16(&root_inode, 0x2E), 0);
1399        let original_extent = root_inode[0x34..0x40].to_vec();
1400
1401        // Keep the synthetic extent metadata away from formatter-owned data and mark it allocated
1402        // exactly as ext4 would. The released test layout keeps this location in block group zero.
1403        let location =
1404            jbd2::locate_journal(&mut file, geo.group_inode_table_block(0), img.csum_seed).unwrap();
1405        let extent_block = location.start_block + u64::from(location.len_blocks) + 16;
1406        let group = (extent_block / u64::from(EXT4_BLOCKS_PER_GROUP)) as u32;
1407        assert_eq!(group, 0, "test extent metadata must remain in group zero");
1408        let local_block = (extent_block % u64::from(EXT4_BLOCKS_PER_GROUP)) as u32;
1409
1410        let bitmap_block = geo.group_block_bitmap_block(group);
1411        let mut block_bitmap = read_block_at(&mut file, bitmap_block).unwrap();
1412        let byte = &mut block_bitmap[(local_block / 8) as usize];
1413        let mask = 1 << (local_block % 8);
1414        assert_eq!(*byte & mask, 0, "chosen extent block is already allocated");
1415        *byte |= mask;
1416
1417        // External extent nodes reserve their final four bytes for the metadata checksum.
1418        let mut extent_leaf = vec![0u8; EXT4_BLOCK_SIZE as usize];
1419        let checksum_offset = extent_leaf.len() - 4;
1420        put_le16(&mut extent_leaf, 0x00, EXT4_EH_MAGIC);
1421        put_le16(&mut extent_leaf, 0x02, 1);
1422        put_le16(&mut extent_leaf, 0x04, ((checksum_offset - 12) / 12) as u16);
1423        extent_leaf[0x0C..0x18].copy_from_slice(&original_extent);
1424        let generation = get_le32(&root_inode, 0x64);
1425        let checksum = dir_block_checksum(
1426            img.csum_seed,
1427            EXT4_ROOT_INO,
1428            generation,
1429            &extent_leaf[..checksum_offset],
1430        );
1431        put_le32(&mut extent_leaf, checksum_offset, checksum);
1432
1433        // Replace the inline leaf with a one-entry index pointing at the new external leaf.
1434        root_inode[0x28..0x64].fill(0);
1435        put_le16(&mut root_inode, 0x28, EXT4_EH_MAGIC);
1436        put_le16(&mut root_inode, 0x2A, 1);
1437        put_le16(&mut root_inode, 0x2C, 4);
1438        put_le16(&mut root_inode, 0x2E, 1);
1439        put_le32(&mut root_inode, 0x38, extent_block as u32);
1440        put_le16(&mut root_inode, 0x3C, (extent_block >> 32) as u16);
1441        let inode_sectors = get_le32(&root_inode, 0x1C) + u32::from(EXT4_BLOCK_SIZE / 512);
1442        put_le32(&mut root_inode, 0x1C, inode_sectors);
1443        let root_checksum = inode_checksum(img.csum_seed, EXT4_ROOT_INO, generation, &root_inode);
1444        put_le16(&mut root_inode, 0x7C, root_checksum as u16);
1445        put_le16(&mut root_inode, 0x82, (root_checksum >> 16) as u16);
1446
1447        // Allocation metadata is redundant by design: update the bitmap, group descriptor,
1448        // primary superblock, and every sparse-super backup as one coherent test fixture.
1449        let mut gdt = img.gdt.clone();
1450        let desc_offset = group as usize * EXT4_DESC_SIZE as usize;
1451        let desc = &mut gdt[desc_offset..desc_offset + EXT4_DESC_SIZE as usize];
1452        let free_blocks = get_le16(desc, 0x0C) as u32 | (u32::from(get_le16(desc, 0x2C)) << 16);
1453        put_le16(desc, 0x0C, (free_blocks - 1) as u16);
1454        put_le16(desc, 0x2C, ((free_blocks - 1) >> 16) as u16);
1455        let bitmap_checksum =
1456            bitmap_checksum(img.csum_seed, &block_bitmap, EXT4_BLOCK_SIZE as usize);
1457        put_le16(desc, 0x18, bitmap_checksum as u16);
1458        put_le16(desc, 0x38, (bitmap_checksum >> 16) as u16);
1459        put_le16(desc, 0x1E, 0);
1460        let desc_checksum = gdt_checksum(img.csum_seed, group, desc);
1461        put_le16(desc, 0x1E, desc_checksum);
1462
1463        let mut sb = img.sb.clone();
1464        let total_free = img.free_blocks - 1;
1465        put_le32(&mut sb, 0x0C, total_free as u32);
1466        put_le32(&mut sb, 0x158, (total_free >> 32) as u32);
1467        let legacy_overhead = get_le32(&sb, EXT4_SB_ERROR_COUNT_OFFSET) + 1;
1468        put_le32(&mut sb, EXT4_SB_ERROR_COUNT_OFFSET, legacy_overhead);
1469        let sb_checksum = superblock_checksum(&sb);
1470        put_le32(&mut sb, 0x3FC, sb_checksum);
1471
1472        write_block_at(&mut file, bitmap_block, &block_bitmap).unwrap();
1473        write_block_at(&mut file, extent_block, &extent_leaf).unwrap();
1474        let root_offset = geo.group_inode_table_block(0) * u64::from(EXT4_BLOCK_SIZE)
1475            + u64::from(EXT4_ROOT_INO - 1) * u64::from(EXT4_INODE_SIZE);
1476        file.seek(SeekFrom::Start(root_offset)).unwrap();
1477        file.write_all(&root_inode).unwrap();
1478        write_gdt_at(&mut file, 0, &gdt).unwrap();
1479
1480        for backup_group in 1..img.num_groups {
1481            if !sparse_super_group(backup_group) {
1482                continue;
1483            }
1484            let mut backup_sb = sb.clone();
1485            put_le16(&mut backup_sb, 0x5A, backup_group as u16);
1486            let backup_checksum = superblock_checksum(&backup_sb);
1487            put_le32(&mut backup_sb, 0x3FC, backup_checksum);
1488            let group_start = geo.group_start_block(backup_group);
1489            write_backup_superblock_at(&mut file, group_start, &backup_sb).unwrap();
1490            write_gdt_at(&mut file, group_start, &gdt).unwrap();
1491        }
1492        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
1493        file.write_all(&sb).unwrap();
1494        file.sync_all().unwrap();
1495        extent_block
1496    }
1497
1498    fn parse(path: &Path) -> ParsedImage {
1499        let mut file = File::open(path).unwrap();
1500        parse_and_validate(&mut file).unwrap()
1501    }
1502
1503    /// Re-open the image and check every invariant the resizer must preserve: superblock and
1504    /// descriptor checksums (via the parser), bitmap checksums, metadata/padding bits, per-group
1505    /// and total free-block accounting, and backup superblock + GDT copies.
1506    fn assert_image_invariants(path: &Path) {
1507        let mut file = File::open(path).unwrap();
1508        let img = parse_and_validate(&mut file).unwrap();
1509        let geo = img.geometry();
1510
1511        let mut total_free = 0u64;
1512        for group in 0..img.num_groups {
1513            let desc =
1514                &img.gdt[group as usize * EXT4_DESC_SIZE as usize..][..EXT4_DESC_SIZE as usize];
1515            let block_bitmap =
1516                read_block_at(&mut file, geo.group_block_bitmap_block(group)).unwrap();
1517            let inode_bitmap =
1518                read_block_at(&mut file, geo.group_inode_bitmap_block(group)).unwrap();
1519
1520            let bb_csum = get_le16(desc, 0x18) as u32 | ((get_le16(desc, 0x38) as u32) << 16);
1521            let ib_csum = get_le16(desc, 0x1A) as u32 | ((get_le16(desc, 0x3A) as u32) << 16);
1522            assert_eq!(
1523                bitmap_checksum(img.csum_seed, &block_bitmap, EXT4_BLOCK_SIZE as usize),
1524                bb_csum,
1525                "group {group} block bitmap checksum"
1526            );
1527            assert_eq!(
1528                bitmap_checksum(
1529                    img.csum_seed,
1530                    &inode_bitmap,
1531                    (EXT4_INODES_PER_GROUP / 8) as usize
1532                ),
1533                ib_csum,
1534                "group {group} inode bitmap checksum"
1535            );
1536
1537            let blocks_in_group = geo.blocks_in_group(group);
1538            for bit in 0..geo.group_metadata_blocks(group) {
1539                assert_ne!(
1540                    block_bitmap[(bit / 8) as usize] & (1 << (bit % 8)),
1541                    0,
1542                    "group {group} metadata block {bit} not marked used"
1543                );
1544            }
1545            for bit in blocks_in_group..EXT4_BLOCKS_PER_GROUP {
1546                assert_ne!(
1547                    block_bitmap[(bit / 8) as usize] & (1 << (bit % 8)),
1548                    0,
1549                    "group {group} padding bit {bit} not set"
1550                );
1551            }
1552
1553            let used = count_used_bits(&block_bitmap, blocks_in_group as usize);
1554            let free = get_le16(desc, 0x0C) as u32 | ((get_le16(desc, 0x2C) as u32) << 16);
1555            assert_eq!(
1556                free as usize,
1557                blocks_in_group as usize - used,
1558                "group {group} free block count"
1559            );
1560            total_free += free as u64;
1561        }
1562        assert_eq!(total_free, img.free_blocks, "superblock free block total");
1563
1564        for group in 1..img.num_groups {
1565            if !sparse_super_group(group) {
1566                continue;
1567            }
1568            let start = geo.group_start_block(group) * EXT4_BLOCK_SIZE as u64;
1569            let mut backup_sb = vec![0u8; SB_SIZE];
1570            file.seek(SeekFrom::Start(start)).unwrap();
1571            file.read_exact(&mut backup_sb).unwrap();
1572            assert_eq!(get_le16(&backup_sb, 0x38), EXT4_SUPER_MAGIC);
1573            assert_eq!(get_le16(&backup_sb, 0x5A), group as u16);
1574            assert_eq!(
1575                superblock_checksum(&backup_sb),
1576                get_le32(&backup_sb, 0x3FC),
1577                "backup superblock checksum in group {group}"
1578            );
1579            assert_eq!(
1580                &backup_sb[0x00..0x18],
1581                &img.sb[0x00..0x18],
1582                "backup superblock counts in group {group}"
1583            );
1584
1585            let mut backup_gdt = vec![0u8; img.gdt.len()];
1586            file.seek(SeekFrom::Start(
1587                (geo.group_start_block(group) + 1) * EXT4_BLOCK_SIZE as u64,
1588            ))
1589            .unwrap();
1590            file.read_exact(&mut backup_gdt).unwrap();
1591            assert_eq!(backup_gdt, img.gdt, "backup GDT in group {group}");
1592        }
1593    }
1594
1595    /// Hash every block below `blocks` except block 0 and the superblock + GDT span at the start
1596    /// of each backup-super group — the only pre-existing regions a grow may rewrite.
1597    fn hash_stable_prefix(path: &Path, blocks: u64, gdt_span: u32) -> [u8; 32] {
1598        let mut file = File::open(path).unwrap();
1599        let img = parse_and_validate(&mut file).unwrap();
1600        let resize_inode_table_block = img.geometry().group_inode_table_block(0);
1601        let mut hasher = Sha256::new();
1602        let mut buf = vec![0u8; EXT4_BLOCK_SIZE as usize];
1603        for block in 0..blocks {
1604            let group = (block / EXT4_BLOCKS_PER_GROUP as u64) as u32;
1605            let offset_in_group = block % EXT4_BLOCKS_PER_GROUP as u64;
1606            let has_super = group == 0 || sparse_super_group(group);
1607            if has_super && offset_in_group < 1 + gdt_span as u64 {
1608                continue;
1609            }
1610            // A grow legitimately refreshes inode 7 and its double-indirect block so that the
1611            // reserved-GDT ownership graph includes newly created sparse-super backups.
1612            if let Some(resize_inode_block) = img.resize_inode_block().unwrap()
1613                && (block == resize_inode_table_block || block == resize_inode_block)
1614            {
1615                continue;
1616            }
1617            file.seek(SeekFrom::Start(block * EXT4_BLOCK_SIZE as u64))
1618                .unwrap();
1619            file.read_exact(&mut buf).unwrap();
1620            hasher.update(&buf);
1621        }
1622        hasher.finalize().into()
1623    }
1624
1625    fn journal_location(path: &Path) -> (JournalLocation, [u8; 16]) {
1626        let mut file = File::open(path).unwrap();
1627        let img = parse_and_validate(&mut file).unwrap();
1628        let location = jbd2::locate_journal(
1629            &mut file,
1630            img.geometry().group_inode_table_block(0),
1631            img.csum_seed,
1632        )
1633        .unwrap();
1634        let mut uuid = [0u8; 16];
1635        uuid.copy_from_slice(&img.sb[0x68..0x78]);
1636        (location, uuid)
1637    }
1638
1639    fn parse_dirty_superblock(path: &Path) -> Vec<u8> {
1640        let mut file = File::open(path).unwrap();
1641        read_superblock_at(&mut file, SB_OFFSET, "test primary").unwrap()
1642    }
1643
1644    fn rewrite_primary_superblock(path: &Path, update: impl FnOnce(&mut [u8])) {
1645        let mut file = OpenOptions::new()
1646            .read(true)
1647            .write(true)
1648            .open(path)
1649            .unwrap();
1650        let mut sb = vec![0u8; SB_SIZE];
1651        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
1652        file.read_exact(&mut sb).unwrap();
1653        update(&mut sb);
1654        let checksum = superblock_checksum(&sb);
1655        put_le32(&mut sb, 0x3FC, checksum);
1656        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
1657        file.write_all(&sb).unwrap();
1658    }
1659
1660    /// 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).
1661    fn set_recover_flag(path: &Path) {
1662        rewrite_primary_superblock(path, |sb| {
1663            let incompat = get_le32(sb, 0x60) | EXT4_FEATURE_INCOMPAT_RECOVER;
1664            put_le32(sb, 0x60, incompat);
1665        });
1666    }
1667
1668    fn write_dirty_journal(path: &Path, start_seq: u32, transactions: &[TestTransaction]) {
1669        let (location, uuid) = journal_location(path);
1670        let mut file = OpenOptions::new()
1671            .read(true)
1672            .write(true)
1673            .open(path)
1674            .unwrap();
1675        write_test_log(&mut file, &location, &uuid, start_seq, transactions).unwrap();
1676        drop(file);
1677        set_recover_flag(path);
1678    }
1679
1680    fn read_jbd2_superblock(path: &Path) -> Vec<u8> {
1681        let (location, _) = journal_location(path);
1682        let mut file = File::open(path).unwrap();
1683        let mut jsb = vec![0u8; 1024];
1684        file.seek(SeekFrom::Start(
1685            location.start_block * EXT4_BLOCK_SIZE as u64,
1686        ))
1687        .unwrap();
1688        file.read_exact(&mut jsb).unwrap();
1689        jsb
1690    }
1691
1692    fn assert_recover_cleared_everywhere(path: &Path) {
1693        let mut file = File::open(path).unwrap();
1694        let img = parse_and_validate(&mut file).unwrap();
1695        assert_eq!(
1696            get_le32(&img.sb, 0x60) & EXT4_FEATURE_INCOMPAT_RECOVER,
1697            0,
1698            "primary superblock still has RECOVER"
1699        );
1700        let geo = img.geometry();
1701        for group in 1..img.num_groups {
1702            if !sparse_super_group(group) {
1703                continue;
1704            }
1705            let mut backup = vec![0u8; SB_SIZE];
1706            file.seek(SeekFrom::Start(
1707                geo.group_start_block(group) * EXT4_BLOCK_SIZE as u64,
1708            ))
1709            .unwrap();
1710            file.read_exact(&mut backup).unwrap();
1711            assert_eq!(
1712                get_le32(&backup, 0x60) & EXT4_FEATURE_INCOMPAT_RECOVER,
1713                0,
1714                "backup superblock in group {group} still has RECOVER"
1715            );
1716        }
1717    }
1718
1719    fn hash_file(path: &Path) -> [u8; 32] {
1720        let mut file = File::open(path).unwrap();
1721        let mut hasher = Sha256::new();
1722        let mut buf = vec![0u8; 1 << 20];
1723        loop {
1724            let n = file.read(&mut buf).unwrap();
1725            if n == 0 {
1726                break;
1727            }
1728            hasher.update(&buf[..n]);
1729        }
1730        hasher.finalize().into()
1731    }
1732
1733    fn pattern_block(byte: u8) -> Vec<u8> {
1734        vec![byte; EXT4_BLOCK_SIZE as usize]
1735    }
1736
1737    #[test]
1738    fn test_freshly_formatted_image_passes_validation() {
1739        let dir = tempfile::tempdir().unwrap();
1740        let path = dir.path().join("fresh.ext4");
1741        format_image(&path, 256 * MIB);
1742
1743        let img = parse(&path);
1744        assert_eq!(img.num_blocks, 65536);
1745        assert_eq!(img.num_groups, 2);
1746        assert_eq!(img.gdt_blocks, 1);
1747        assert_eq!(img.reserved_gdt_blocks, RESERVED_GDT_BLOCKS);
1748        assert_eq!(get_le32(&img.sb, EXT4_SB_ERROR_COUNT_OFFSET), 0);
1749        assert_eq!(
1750            get_le32(&img.sb, EXT4_SB_OVERHEAD_BLOCKS_OFFSET),
1751            (0..img.num_groups)
1752                .map(|group| img.geometry().group_metadata_blocks(group))
1753                .sum::<u32>()
1754        );
1755        assert_image_invariants(&path);
1756    }
1757
1758    #[test]
1759    fn test_legacy_image_matches_pre_0_6_9_layout() {
1760        let dir = tempfile::tempdir().unwrap();
1761        let path = dir.path().join("legacy.ext4");
1762        format_legacy_image(&path, 256 * MIB);
1763
1764        let img = parse(&path);
1765        assert_eq!(img.resize_metadata, ResizeMetadata::Legacy);
1766        assert_eq!(get_le32(&img.sb, 0x5C), LEGACY_FEATURE_COMPAT);
1767        assert_eq!(get_le32(&img.sb, 0x60), 0xC2);
1768        assert_eq!(get_le32(&img.sb, 0x64), 0x46B);
1769        assert_ne!(get_le32(&img.sb, EXT4_SB_ERROR_COUNT_OFFSET), 0);
1770        assert_eq!(get_le32(&img.sb, EXT4_SB_OVERHEAD_BLOCKS_OFFSET), 0);
1771        assert_eq!(img.resize_inode_block().unwrap(), None);
1772        assert_image_invariants(&path);
1773    }
1774
1775    #[test]
1776    fn test_grow_legacy_image_preserves_layout_and_existing_data() {
1777        let dir = tempfile::tempdir().unwrap();
1778        let path = dir.path().join("legacy-grow.ext4");
1779        format_legacy_image(&path, 256 * MIB);
1780
1781        let before_img = parse(&path);
1782        let span = before_img.gdt_blocks + before_img.reserved_gdt_blocks;
1783        let before = hash_stable_prefix(&path, before_img.num_blocks, span);
1784
1785        let outcome = grow_image(&path, 512 * MIB).unwrap();
1786        assert_eq!(outcome.old_groups, 2);
1787        assert_eq!(outcome.new_groups, 4);
1788
1789        let after_img = parse(&path);
1790        assert_eq!(after_img.resize_metadata, ResizeMetadata::Legacy);
1791        assert_eq!(get_le32(&after_img.sb, 0x5C), LEGACY_FEATURE_COMPAT);
1792        assert_eq!(
1793            before,
1794            hash_stable_prefix(&path, before_img.num_blocks, span)
1795        );
1796        assert_image_invariants(&path);
1797    }
1798
1799    #[test]
1800    fn test_grow_legacy_image_with_mutated_root_extent_tree() {
1801        let dir = tempfile::tempdir().unwrap();
1802        let path = dir.path().join("legacy-mutated-root.ext4");
1803        format_legacy_image(&path, 256 * MIB);
1804        grow_legacy_root_extent_tree(&path);
1805
1806        let before = parse(&path);
1807        assert_eq!(before.resize_metadata, ResizeMetadata::Legacy);
1808        let mut file = File::open(&path).unwrap();
1809        let root_inode = read_inode(&mut file, &before.geometry(), EXT4_ROOT_INO).unwrap();
1810        assert_eq!(get_le16(&root_inode, 0x2E), 1);
1811        drop(file);
1812        assert_image_invariants(&path);
1813
1814        grow_image(&path, 512 * MIB).unwrap();
1815
1816        assert_eq!(parse(&path).resize_metadata, ResizeMetadata::Legacy);
1817        assert_image_invariants(&path);
1818    }
1819
1820    #[test]
1821    fn test_legacy_root_extent_checksum_mismatch_is_rejected_untouched() {
1822        let dir = tempfile::tempdir().unwrap();
1823        let path = dir.path().join("legacy-bad-root-extent.ext4");
1824        format_legacy_image(&path, 256 * MIB);
1825        let extent_block = grow_legacy_root_extent_tree(&path);
1826
1827        let mut file = OpenOptions::new().write(true).open(&path).unwrap();
1828        file.seek(SeekFrom::Start(
1829            extent_block * u64::from(EXT4_BLOCK_SIZE) + 24,
1830        ))
1831        .unwrap();
1832        file.write_all(&[0xA5]).unwrap();
1833        drop(file);
1834
1835        let before = hash_file(&path);
1836        let result = grow_image(&path, 512 * MIB);
1837        match result {
1838            Err(Ext4Error::Unsupported(message)) => assert!(
1839                message.contains("extent index checksum mismatch"),
1840                "message: {message}"
1841            ),
1842            other => panic!("expected Unsupported, got {other:?}"),
1843        }
1844        assert_eq!(hash_file(&path), before, "failed grow modified the image");
1845    }
1846
1847    #[test]
1848    fn test_grow_legacy_image_twice() {
1849        let dir = tempfile::tempdir().unwrap();
1850        let path = dir.path().join("legacy-twice.ext4");
1851        format_legacy_image(&path, 256 * MIB);
1852
1853        grow_image(&path, 512 * MIB).unwrap();
1854        assert_image_invariants(&path);
1855        let outcome = grow_image(&path, 1024 * MIB).unwrap();
1856
1857        assert_eq!(outcome.old_groups, 4);
1858        assert_eq!(outcome.new_groups, 8);
1859        assert_eq!(parse(&path).resize_metadata, ResizeMetadata::Legacy);
1860        assert_image_invariants(&path);
1861    }
1862
1863    #[test]
1864    fn test_grow_legacy_image_consumes_reserved_gdt_headroom() {
1865        let dir = tempfile::tempdir().unwrap();
1866        let path = dir.path().join("legacy-consume.ext4");
1867        format_legacy_image(&path, 256 * MIB);
1868
1869        let outcome = grow_image(&path, 68 * 128 * MIB).unwrap();
1870        assert_eq!(outcome.new_groups, 68);
1871
1872        let img = parse(&path);
1873        assert_eq!(img.resize_metadata, ResizeMetadata::Legacy);
1874        assert_eq!(img.gdt_blocks, 2);
1875        assert_eq!(img.reserved_gdt_blocks, RESERVED_GDT_BLOCKS - 1);
1876        assert_image_invariants(&path);
1877    }
1878
1879    #[test]
1880    fn test_grow_legacy_image_to_reported_30_gib_target() {
1881        let dir = tempfile::tempdir().unwrap();
1882        let path = dir.path().join("legacy-30g.ext4");
1883        format_legacy_image(&path, 4 * 1024 * MIB);
1884
1885        let outcome = grow_image(&path, 30 * 1024 * MIB).unwrap();
1886
1887        assert_eq!(outcome.old_groups, 32);
1888        assert_eq!(outcome.new_groups, 240);
1889        assert_eq!(std::fs::metadata(&path).unwrap().len(), 30 * 1024 * MIB);
1890        let img = parse(&path);
1891        assert_eq!(img.resize_metadata, ResizeMetadata::Legacy);
1892        assert_eq!(img.gdt_blocks, 4);
1893        assert_eq!(img.reserved_gdt_blocks, RESERVED_GDT_BLOCKS - 3);
1894        assert_image_invariants(&path);
1895    }
1896
1897    #[test]
1898    fn test_grow_replays_legacy_pending_journal() {
1899        let dir = tempfile::tempdir().unwrap();
1900        let path = dir.path().join("legacy-replay.ext4");
1901        format_legacy_image(&path, 256 * MIB);
1902
1903        let (location, _) = journal_location(&path);
1904        let target = location.start_block + location.len_blocks as u64 + 16;
1905        let data = pattern_block(0xA5);
1906        write_dirty_journal(
1907            &path,
1908            2,
1909            &[TestTransaction {
1910                writes: vec![(target, data.clone())],
1911                revokes: vec![],
1912                corrupt_commit: false,
1913            }],
1914        );
1915        assert_eq!(get_le32(&parse_dirty_superblock(&path), 0x60), 0xC6);
1916
1917        grow_image(&path, 512 * MIB).unwrap();
1918
1919        let mut file = File::open(&path).unwrap();
1920        assert_eq!(read_block_at(&mut file, target).unwrap(), data);
1921        drop(file);
1922        assert_recover_cleared_everywhere(&path);
1923        assert_eq!(parse(&path).resize_metadata, ResizeMetadata::Legacy);
1924        assert_image_invariants(&path);
1925    }
1926
1927    #[test]
1928    fn test_legacy_replay_rejects_unknown_journal_features_untouched() {
1929        let dir = tempfile::tempdir().unwrap();
1930        let path = dir.path().join("legacy-bad-journal.ext4");
1931        format_legacy_image(&path, 256 * MIB);
1932
1933        let (location, _) = journal_location(&path);
1934        let mut file = OpenOptions::new()
1935            .read(true)
1936            .write(true)
1937            .open(&path)
1938            .unwrap();
1939        let mut jsb = vec![0u8; 1024];
1940        file.seek(SeekFrom::Start(
1941            location.start_block * u64::from(EXT4_BLOCK_SIZE),
1942        ))
1943        .unwrap();
1944        file.read_exact(&mut jsb).unwrap();
1945        let incompat = get_be32(&jsb, 0x28);
1946        put_be32(&mut jsb, 0x28, incompat | 0x04);
1947        jsb[0xFC..0x100].fill(0);
1948        let checksum = crc32c::crc32c_raw(0xFFFF_FFFF, &jsb);
1949        put_be32(&mut jsb, 0xFC, checksum);
1950        file.seek(SeekFrom::Start(
1951            location.start_block * u64::from(EXT4_BLOCK_SIZE),
1952        ))
1953        .unwrap();
1954        file.write_all(&jsb).unwrap();
1955        drop(file);
1956        set_recover_flag(&path);
1957        let before = hash_file(&path);
1958
1959        let result = grow_image(&path, 512 * MIB);
1960        match result {
1961            Err(Ext4Error::Unsupported(message)) => {
1962                assert!(message.contains("journal feature"), "message: {message}")
1963            }
1964            other => panic!("expected Unsupported, got {other:?}"),
1965        }
1966        assert_eq!(
1967            hash_file(&path),
1968            before,
1969            "failed recovery modified the image"
1970        );
1971        assert_eq!(std::fs::metadata(&path).unwrap().len(), 256 * MIB);
1972    }
1973
1974    #[test]
1975    fn test_cleared_modern_resize_feature_is_not_misclassified_as_legacy() {
1976        let dir = tempfile::tempdir().unwrap();
1977        let path = dir.path().join("modern-with-cleared-feature.ext4");
1978        format_image(&path, 256 * MIB);
1979
1980        rewrite_primary_superblock(&path, |sb| {
1981            put_le32(sb, 0x5C, LEGACY_FEATURE_COMPAT);
1982        });
1983        let before = hash_file(&path);
1984
1985        let result = grow_image(&path, 512 * MIB);
1986        match result {
1987            Err(Ext4Error::Unsupported(message)) => {
1988                assert!(
1989                    message.contains("non-empty resize inode"),
1990                    "message: {message}"
1991                )
1992            }
1993            other => panic!("expected Unsupported, got {other:?}"),
1994        }
1995        assert_eq!(hash_file(&path), before, "rejected image was modified");
1996        assert_eq!(std::fs::metadata(&path).unwrap().len(), 256 * MIB);
1997    }
1998
1999    #[test]
2000    fn test_cleared_modern_feature_and_inode_still_fails_legacy_structure() {
2001        let dir = tempfile::tempdir().unwrap();
2002        let path = dir.path().join("modern-with-cleared-inode.ext4");
2003        format_image(&path, 256 * MIB);
2004        let img = parse(&path);
2005        let inode_offset = img.geometry().group_inode_table_block(0) * u64::from(EXT4_BLOCK_SIZE)
2006            + u64::from(EXT4_RESIZE_INO - 1) * u64::from(EXT4_INODE_SIZE);
2007
2008        let mut file = OpenOptions::new()
2009            .read(true)
2010            .write(true)
2011            .open(&path)
2012            .unwrap();
2013        file.seek(SeekFrom::Start(inode_offset)).unwrap();
2014        file.write_all(&vec![0u8; EXT4_INODE_SIZE as usize])
2015            .unwrap();
2016        drop(file);
2017        rewrite_primary_superblock(&path, |sb| {
2018            put_le32(sb, 0x5C, LEGACY_FEATURE_COMPAT);
2019        });
2020        let before = hash_file(&path);
2021
2022        let result = grow_image(&path, 512 * MIB);
2023        match result {
2024            Err(Ext4Error::Unsupported(message)) => assert!(
2025                message.contains("root directory is not immediately after the inode table"),
2026                "message: {message}"
2027            ),
2028            other => panic!("expected Unsupported, got {other:?}"),
2029        }
2030        assert_eq!(hash_file(&path), before, "rejected image was modified");
2031        assert_eq!(std::fs::metadata(&path).unwrap().len(), 256 * MIB);
2032    }
2033
2034    #[test]
2035    fn test_legacy_grow_rejects_nonzero_reserved_gdt_block_untouched() {
2036        let dir = tempfile::tempdir().unwrap();
2037        let path = dir.path().join("legacy-corrupt-reserved.ext4");
2038        format_legacy_image(&path, 256 * MIB);
2039        let img = parse(&path);
2040        let reserved_block = 1 + u64::from(img.gdt_blocks);
2041
2042        let mut file = OpenOptions::new()
2043            .read(true)
2044            .write(true)
2045            .open(&path)
2046            .unwrap();
2047        file.seek(SeekFrom::Start(reserved_block * u64::from(EXT4_BLOCK_SIZE)))
2048            .unwrap();
2049        file.write_all(&[1]).unwrap();
2050        drop(file);
2051        let before = hash_file(&path);
2052
2053        let result = grow_image(&path, 512 * MIB);
2054        match result {
2055            Err(Ext4Error::Unsupported(message)) => {
2056                assert!(message.contains("reserved GDT block"), "message: {message}")
2057            }
2058            other => panic!("expected Unsupported, got {other:?}"),
2059        }
2060        assert_eq!(hash_file(&path), before, "rejected image was modified");
2061        assert_eq!(std::fs::metadata(&path).unwrap().len(), 256 * MIB);
2062    }
2063
2064    #[test]
2065    fn test_grow_doubles_aligned_image() {
2066        let dir = tempfile::tempdir().unwrap();
2067        let path = dir.path().join("grow.ext4");
2068        format_image(&path, 256 * MIB);
2069
2070        let img = parse(&path);
2071        let span = img.gdt_blocks + img.reserved_gdt_blocks;
2072        let before = hash_stable_prefix(&path, img.num_blocks, span);
2073
2074        let outcome = grow_image(&path, 512 * MIB).unwrap();
2075        assert_eq!(
2076            outcome,
2077            GrowOutcome {
2078                old_blocks: 65536,
2079                new_blocks: 131072,
2080                old_groups: 2,
2081                new_groups: 4,
2082            }
2083        );
2084        assert_eq!(std::fs::metadata(&path).unwrap().len(), 512 * MIB);
2085
2086        // Group 3 is a sparse_super backup group, so the grow must have created its backup
2087        // superblock + GDT; assert_image_invariants verifies both.
2088        assert_image_invariants(&path);
2089
2090        let after = hash_stable_prefix(&path, img.num_blocks, span);
2091        assert_eq!(before, after, "pre-existing data blocks were modified");
2092    }
2093
2094    #[test]
2095    fn test_grow_crosses_sparse_super_backup_groups() {
2096        let dir = tempfile::tempdir().unwrap();
2097        let path = dir.path().join("backups.ext4");
2098        format_image(&path, 256 * MIB);
2099
2100        let outcome = grow_image(&path, 1024 * MIB).unwrap();
2101        assert_eq!(outcome.new_groups, 8);
2102
2103        let img = parse(&path);
2104        assert_eq!(img.num_groups, 8);
2105        assert_eq!(img.free_inodes, get_le32(&img.sb, 0x10));
2106        assert_image_invariants(&path);
2107    }
2108
2109    #[test]
2110    fn test_grow_consumes_reserved_gdt_blocks() {
2111        let dir = tempfile::tempdir().unwrap();
2112        let path = dir.path().join("consume.ext4");
2113        format_image(&path, 256 * MIB);
2114
2115        // 68 groups need two GDT blocks, so the second descriptor block comes out of the
2116        // reserved span while gdt_blocks + reserved stays 257.
2117        let outcome = grow_image(&path, 68 * 128 * MIB).unwrap();
2118        assert_eq!(outcome.new_groups, 68);
2119
2120        let img = parse(&path);
2121        assert_eq!(img.gdt_blocks, 2);
2122        assert_eq!(img.reserved_gdt_blocks, RESERVED_GDT_BLOCKS - 1);
2123        assert_image_invariants(&path);
2124    }
2125
2126    #[test]
2127    fn test_grow_rejects_corrupted_resize_inode_pointers() {
2128        let dir = tempfile::tempdir().unwrap();
2129        let path = dir.path().join("corrupt-resize-inode.ext4");
2130        format_image(&path, 256 * MIB);
2131
2132        let img = parse(&path);
2133        let mut file = OpenOptions::new()
2134            .read(true)
2135            .write(true)
2136            .open(&path)
2137            .unwrap();
2138        file.seek(SeekFrom::Start(
2139            img.resize_inode_block().unwrap().unwrap() * u64::from(EXT4_BLOCK_SIZE)
2140                + u64::from(img.gdt_blocks) * 4,
2141        ))
2142        .unwrap();
2143        file.write_all(&0u32.to_le_bytes()).unwrap();
2144        drop(file);
2145
2146        let result = grow_image(&path, 512 * MIB);
2147        match result {
2148            Err(Ext4Error::Unsupported(message)) => {
2149                assert!(
2150                    message.contains("double-indirect pointer"),
2151                    "message: {message}"
2152                )
2153            }
2154            other => panic!("expected Unsupported, got {other:?}"),
2155        }
2156    }
2157
2158    #[test]
2159    fn test_grow_twice_reuses_headroom() {
2160        let dir = tempfile::tempdir().unwrap();
2161        let path = dir.path().join("twice.ext4");
2162        format_image(&path, 256 * MIB);
2163
2164        grow_image(&path, 512 * MIB).unwrap();
2165        assert_image_invariants(&path);
2166
2167        let outcome = grow_image(&path, 1024 * MIB).unwrap();
2168        assert_eq!(outcome.old_groups, 4);
2169        assert_eq!(outcome.new_groups, 8);
2170        assert_image_invariants(&path);
2171    }
2172
2173    #[test]
2174    fn test_grow_extends_partial_final_group() {
2175        let dir = tempfile::tempdir().unwrap();
2176        let path = dir.path().join("partial-old.ext4");
2177        format_image(&path, 200 * MIB);
2178
2179        let outcome = grow_image(&path, 256 * MIB).unwrap();
2180        assert_eq!(outcome.old_groups, 2);
2181        assert_eq!(outcome.new_groups, 2);
2182        assert_eq!(outcome.new_blocks - outcome.old_blocks, 56 * MIB / 4096);
2183        assert_image_invariants(&path);
2184    }
2185
2186    #[test]
2187    fn test_grow_creates_partial_final_group() {
2188        let dir = tempfile::tempdir().unwrap();
2189        let path = dir.path().join("partial-new.ext4");
2190        format_image(&path, 256 * MIB);
2191
2192        let outcome = grow_image(&path, 448 * MIB).unwrap();
2193        assert_eq!(outcome.new_groups, 4);
2194        assert_image_invariants(&path);
2195    }
2196
2197    #[test]
2198    fn test_grow_rejects_shrink_and_noop() {
2199        let dir = tempfile::tempdir().unwrap();
2200        let path = dir.path().join("shrink.ext4");
2201        format_image(&path, 256 * MIB);
2202
2203        let result = grow_image(&path, 128 * MIB);
2204        assert!(matches!(result, Err(Ext4Error::InvalidSize(_))));
2205
2206        let result = grow_image(&path, 256 * MIB);
2207        assert!(matches!(result, Err(Ext4Error::InvalidSize(_))));
2208    }
2209
2210    #[test]
2211    fn test_grow_rejects_unaligned_size() {
2212        let dir = tempfile::tempdir().unwrap();
2213        let path = dir.path().join("unaligned.ext4");
2214        format_image(&path, 256 * MIB);
2215
2216        let result = grow_image(&path, 512 * MIB + 1);
2217        assert!(matches!(result, Err(Ext4Error::InvalidSize(_))));
2218    }
2219
2220    #[test]
2221    fn test_grow_rejects_size_beyond_32_bit_block_addresses() {
2222        let dir = tempfile::tempdir().unwrap();
2223        let path = dir.path().join("huge.ext4");
2224        format_image(&path, 256 * MIB);
2225
2226        let result = grow_image(&path, (MAX_BLOCKS + 1) * EXT4_BLOCK_SIZE as u64);
2227        assert!(matches!(result, Err(Ext4Error::TooLarge { .. })));
2228    }
2229
2230    #[test]
2231    fn test_grow_over_capacity_reports_max_growable_size() {
2232        let dir = tempfile::tempdir().unwrap();
2233        let path = dir.path().join("pre-headroom.ext4");
2234        let opts = Ext4FormatOptions {
2235            size_bytes: 256 * MIB,
2236            journal_blocks: 4096,
2237        };
2238        format_ext4_for_test_with_reserved_gdt(&path, &opts, 0).unwrap();
2239
2240        // One GDT block and no reserved headroom caps the image at 64 groups (8 GiB).
2241        let max_size_bytes = 64 * 128 * MIB;
2242        let result = grow_image(&path, 16 * 1024 * MIB);
2243        match result {
2244            Err(Ext4Error::ExceedsGdtCapacity {
2245                requested_bytes,
2246                max_size_bytes: reported_max,
2247            }) => {
2248                assert_eq!(requested_bytes, 16 * 1024 * MIB);
2249                assert_eq!(reported_max, max_size_bytes);
2250            }
2251            other => panic!("expected ExceedsGdtCapacity, got {other:?}"),
2252        }
2253
2254        // Growing to exactly the capacity limit uses the remaining slack in the allocated
2255        // GDT block and succeeds.
2256        let outcome = grow_image(&path, max_size_bytes).unwrap();
2257        assert_eq!(outcome.new_groups, 64);
2258        assert_image_invariants(&path);
2259    }
2260
2261    #[test]
2262    fn test_grow_rejects_corrupted_superblock() {
2263        let dir = tempfile::tempdir().unwrap();
2264        let path = dir.path().join("corrupt.ext4");
2265        format_image(&path, 256 * MIB);
2266
2267        let mut file = OpenOptions::new()
2268            .read(true)
2269            .write(true)
2270            .open(&path)
2271            .unwrap();
2272        file.seek(SeekFrom::Start(SB_OFFSET + 0x20)).unwrap();
2273        file.write_all(&[0xFF]).unwrap();
2274        drop(file);
2275
2276        let result = grow_image(&path, 512 * MIB);
2277        assert!(matches!(result, Err(Ext4Error::Unsupported(_))));
2278    }
2279
2280    #[test]
2281    fn test_grow_rejects_foreign_feature_flags() {
2282        let dir = tempfile::tempdir().unwrap();
2283        let path = dir.path().join("foreign.ext4");
2284        format_image(&path, 256 * MIB);
2285
2286        // Set an extra ro_compat flag and re-checksum so only the feature check can reject it.
2287        let mut file = OpenOptions::new()
2288            .read(true)
2289            .write(true)
2290            .open(&path)
2291            .unwrap();
2292        let mut sb = vec![0u8; SB_SIZE];
2293        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
2294        file.read_exact(&mut sb).unwrap();
2295        let ro_compat = get_le32(&sb, 0x64);
2296        put_le32(&mut sb, 0x64, ro_compat | 0x8000);
2297        let checksum = superblock_checksum(&sb);
2298        put_le32(&mut sb, 0x3FC, checksum);
2299        file.seek(SeekFrom::Start(SB_OFFSET)).unwrap();
2300        file.write_all(&sb).unwrap();
2301        drop(file);
2302
2303        let result = grow_image(&path, 512 * MIB);
2304        match result {
2305            Err(Ext4Error::Unsupported(message)) => {
2306                assert!(message.contains("feature flags"), "message: {message}")
2307            }
2308            other => panic!("expected Unsupported, got {other:?}"),
2309        }
2310    }
2311
2312    #[test]
2313    fn test_grow_replays_pending_journal() {
2314        let dir = tempfile::tempdir().unwrap();
2315        let path = dir.path().join("replay.ext4");
2316        format_image(&path, 256 * MIB);
2317
2318        let (location, _) = journal_location(&path);
2319        let data_target = location.start_block + location.len_blocks as u64 + 16;
2320        let second_data_target = data_target + 1;
2321        let second_file_data = pattern_block(0xA5);
2322        let file_data = pattern_block(0x5A);
2323        write_dirty_journal(
2324            &path,
2325            2,
2326            &[TestTransaction {
2327                writes: vec![
2328                    (data_target, file_data.clone()),
2329                    (second_data_target, second_file_data.clone()),
2330                ],
2331                revokes: vec![],
2332                corrupt_commit: false,
2333            }],
2334        );
2335
2336        let outcome = grow_image(&path, 512 * MIB).unwrap();
2337        assert_eq!(outcome.new_groups, 4);
2338
2339        let mut file = File::open(&path).unwrap();
2340        assert_eq!(
2341            read_block_at(&mut file, data_target).unwrap(),
2342            file_data,
2343            "journaled data-block write was not replayed"
2344        );
2345        assert_eq!(
2346            read_block_at(&mut file, second_data_target).unwrap(),
2347            second_file_data,
2348            "second journaled data-block write was not replayed"
2349        );
2350        drop(file);
2351
2352        assert_recover_cleared_everywhere(&path);
2353        let jsb = read_jbd2_superblock(&path);
2354        assert_eq!(get_be32(&jsb, 0x1C), 0, "journal s_start not reset");
2355        // Sequence 2 replayed, end-of-log at sequence 3, and the kernel-mirroring reset restarts one past that.
2356        assert_eq!(
2357            get_be32(&jsb, 0x18),
2358            4,
2359            "journal s_sequence not advanced past the replayed transaction"
2360        );
2361        assert_image_invariants(&path);
2362    }
2363
2364    #[test]
2365    fn test_replay_restores_escaped_blocks() {
2366        let dir = tempfile::tempdir().unwrap();
2367        let path = dir.path().join("escape.ext4");
2368        format_image(&path, 256 * MIB);
2369
2370        let (location, _) = journal_location(&path);
2371        let target = location.start_block + location.len_blocks as u64 + 16;
2372        let mut data = pattern_block(0x11);
2373        put_be32(&mut data, 0, JBD2_MAGIC);
2374        write_dirty_journal(
2375            &path,
2376            2,
2377            &[TestTransaction {
2378                writes: vec![(target, data.clone())],
2379                revokes: vec![],
2380                corrupt_commit: false,
2381            }],
2382        );
2383
2384        grow_image(&path, 512 * MIB).unwrap();
2385
2386        let mut file = File::open(&path).unwrap();
2387        let replayed = read_block_at(&mut file, target).unwrap();
2388        assert_eq!(
2389            get_be32(&replayed, 0),
2390            JBD2_MAGIC,
2391            "escape magic not restored"
2392        );
2393        assert_eq!(replayed, data);
2394    }
2395
2396    #[test]
2397    fn test_replay_honors_revocations() {
2398        let dir = tempfile::tempdir().unwrap();
2399        let path = dir.path().join("revoke.ext4");
2400        format_image(&path, 256 * MIB);
2401
2402        let (location, _) = journal_location(&path);
2403        let data_start = location.start_block + location.len_blocks as u64 + 16;
2404        let revoked_target = data_start;
2405        let kept_target = data_start + 1;
2406        let late_target = data_start + 2;
2407        // 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.
2408        write_dirty_journal(
2409            &path,
2410            2,
2411            &[
2412                TestTransaction {
2413                    writes: vec![
2414                        (revoked_target, pattern_block(0xDE)),
2415                        (kept_target, pattern_block(0x22)),
2416                    ],
2417                    revokes: vec![],
2418                    corrupt_commit: false,
2419                },
2420                TestTransaction {
2421                    writes: vec![(late_target, pattern_block(0x33))],
2422                    revokes: vec![revoked_target],
2423                    corrupt_commit: false,
2424                },
2425            ],
2426        );
2427
2428        grow_image(&path, 512 * MIB).unwrap();
2429
2430        let mut file = File::open(&path).unwrap();
2431        assert_eq!(
2432            read_block_at(&mut file, revoked_target).unwrap(),
2433            vec![0u8; EXT4_BLOCK_SIZE as usize],
2434            "revoked block was replayed"
2435        );
2436        assert_eq!(
2437            read_block_at(&mut file, kept_target).unwrap(),
2438            pattern_block(0x22)
2439        );
2440        assert_eq!(
2441            read_block_at(&mut file, late_target).unwrap(),
2442            pattern_block(0x33)
2443        );
2444    }
2445
2446    #[test]
2447    fn test_replay_stops_at_corrupt_commit() {
2448        let dir = tempfile::tempdir().unwrap();
2449        let path = dir.path().join("badcommit.ext4");
2450        format_image(&path, 256 * MIB);
2451
2452        let (location, _) = journal_location(&path);
2453        let applied_target = location.start_block + location.len_blocks as u64 + 16;
2454        let dropped_target = applied_target + 1;
2455        write_dirty_journal(
2456            &path,
2457            2,
2458            &[
2459                TestTransaction {
2460                    writes: vec![(applied_target, pattern_block(0x44))],
2461                    revokes: vec![],
2462                    corrupt_commit: false,
2463                },
2464                TestTransaction {
2465                    writes: vec![(dropped_target, pattern_block(0x55))],
2466                    revokes: vec![],
2467                    corrupt_commit: true,
2468                },
2469            ],
2470        );
2471
2472        grow_image(&path, 512 * MIB).unwrap();
2473
2474        let mut file = File::open(&path).unwrap();
2475        assert_eq!(
2476            read_block_at(&mut file, applied_target).unwrap(),
2477            pattern_block(0x44),
2478            "committed transaction was not replayed"
2479        );
2480        assert_eq!(
2481            read_block_at(&mut file, dropped_target).unwrap(),
2482            vec![0u8; EXT4_BLOCK_SIZE as usize],
2483            "uncommitted transaction was replayed"
2484        );
2485        drop(file);
2486
2487        // end-of-log at the corrupt commit: sequence 2 replayed, sequence 3 discarded, so the reset journal restarts at 4.
2488        let jsb = read_jbd2_superblock(&path);
2489        assert_eq!(get_be32(&jsb, 0x1C), 0);
2490        assert_eq!(get_be32(&jsb, 0x18), 4);
2491        assert_recover_cleared_everywhere(&path);
2492    }
2493
2494    #[test]
2495    fn test_grow_clears_recover_flag_with_empty_journal() {
2496        let dir = tempfile::tempdir().unwrap();
2497        let path = dir.path().join("recover-clean.ext4");
2498        format_image(&path, 256 * MIB);
2499        set_recover_flag(&path);
2500
2501        let outcome = grow_image(&path, 512 * MIB).unwrap();
2502        assert_eq!(outcome.new_groups, 4);
2503
2504        assert_recover_cleared_everywhere(&path);
2505        // An empty log (s_start == 0) needs no recovery, so the journal superblock is left exactly as formatted.
2506        let jsb = read_jbd2_superblock(&path);
2507        assert_eq!(get_be32(&jsb, 0x1C), 0);
2508        assert_eq!(get_be32(&jsb, 0x18), 1);
2509        assert_image_invariants(&path);
2510    }
2511
2512    #[test]
2513    fn test_replay_rejects_unknown_journal_features() {
2514        let dir = tempfile::tempdir().unwrap();
2515        let path = dir.path().join("badjournal.ext4");
2516        format_image(&path, 256 * MIB);
2517
2518        // ASYNC_COMMIT (0x4) is a real jbd2 feature, but not one the formatter writes, so recovery must refuse it rather than misparse commit blocks.
2519        let (location, _) = journal_location(&path);
2520        let mut file = OpenOptions::new()
2521            .read(true)
2522            .write(true)
2523            .open(&path)
2524            .unwrap();
2525        let mut jsb = vec![0u8; 1024];
2526        file.seek(SeekFrom::Start(
2527            location.start_block * EXT4_BLOCK_SIZE as u64,
2528        ))
2529        .unwrap();
2530        file.read_exact(&mut jsb).unwrap();
2531        let incompat = get_be32(&jsb, 0x28);
2532        put_be32(&mut jsb, 0x28, incompat | 0x04);
2533        jsb[0xFC..0x100].fill(0);
2534        let checksum = crc32c::crc32c_raw(0xFFFF_FFFF, &jsb);
2535        put_be32(&mut jsb, 0xFC, checksum);
2536        file.seek(SeekFrom::Start(
2537            location.start_block * EXT4_BLOCK_SIZE as u64,
2538        ))
2539        .unwrap();
2540        file.write_all(&jsb).unwrap();
2541        drop(file);
2542        set_recover_flag(&path);
2543
2544        let before = hash_file(&path);
2545        let result = grow_image(&path, 512 * MIB);
2546        match result {
2547            Err(Ext4Error::Unsupported(message)) => {
2548                assert!(message.contains("journal feature"), "message: {message}")
2549            }
2550            other => panic!("expected Unsupported, got {other:?}"),
2551        }
2552        assert_eq!(
2553            hash_file(&path),
2554            before,
2555            "failed recovery modified the image"
2556        );
2557    }
2558
2559    #[test]
2560    fn test_replay_rejects_target_beyond_filesystem() {
2561        let dir = tempfile::tempdir().unwrap();
2562        let path = dir.path().join("oob.ext4");
2563        format_image(&path, 256 * MIB);
2564
2565        // 256 MiB = 65536 blocks, so this target is past the end of the filesystem.
2566        write_dirty_journal(
2567            &path,
2568            2,
2569            &[TestTransaction {
2570                writes: vec![(70_000, pattern_block(0x66))],
2571                revokes: vec![],
2572                corrupt_commit: false,
2573            }],
2574        );
2575
2576        let before = hash_file(&path);
2577        let result = grow_image(&path, 512 * MIB);
2578        match result {
2579            Err(Ext4Error::Unsupported(message)) => {
2580                assert!(
2581                    message.contains("beyond the filesystem"),
2582                    "message: {message}"
2583                )
2584            }
2585            other => panic!("expected Unsupported, got {other:?}"),
2586        }
2587        assert_eq!(
2588            hash_file(&path),
2589            before,
2590            "failed recovery modified the image"
2591        );
2592    }
2593
2594    /// Run the reference checker and reject diagnostics that `e2fsck -n` can emit without making
2595    /// its process status the only source of truth. In particular, a declined `Fix? no` means the
2596    /// image is not clean enough to publish even if the local e2fsprogs version exits successfully.
2597    fn assert_e2fsck_clean(path: &Path, label: &str) -> bool {
2598        let output = match std::process::Command::new("e2fsck")
2599            .arg("-fn")
2600            .arg(path)
2601            .output()
2602        {
2603            Ok(output) => output,
2604            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2605                eprintln!("e2fsck not found; skipping");
2606                return false;
2607            }
2608            Err(error) => panic!("failed to run e2fsck: {error}"),
2609        };
2610
2611        let stdout = String::from_utf8_lossy(&output.stdout);
2612        let stderr = String::from_utf8_lossy(&output.stderr);
2613        let diagnostics = format!("{stdout}\n{stderr}");
2614        let rejected_diagnostics = [
2615            "Fix? no",
2616            "WARNING:",
2617            "UNEXPECTED INCONSISTENCY",
2618            "Filesystem still has errors",
2619            "does not have resize_inode enabled",
2620        ];
2621        assert!(
2622            output.status.success()
2623                && rejected_diagnostics
2624                    .iter()
2625                    .all(|diagnostic| !diagnostics.contains(diagnostic)),
2626            "e2fsck found an inconsistency after {label}:\nstdout: {stdout}\nstderr: {stderr}"
2627        );
2628        true
2629    }
2630
2631    /// Validate a released legacy layout without pretending its known formatter defect is new.
2632    /// e2fsprogs has always diagnosed the reserved-GDT field because these images predate the
2633    /// resize inode; growth is acceptable only when that remains the sole diagnostic.
2634    fn assert_e2fsck_legacy_baseline(path: &Path, label: &str) -> bool {
2635        let output = match std::process::Command::new("e2fsck")
2636            .arg("-fn")
2637            .arg(path)
2638            .output()
2639        {
2640            Ok(output) => output,
2641            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2642                eprintln!("e2fsck not found; skipping");
2643                return false;
2644            }
2645            Err(error) => panic!("failed to run e2fsck: {error}"),
2646        };
2647
2648        let stdout = String::from_utf8_lossy(&output.stdout);
2649        let stderr = String::from_utf8_lossy(&output.stderr);
2650        let diagnostics = format!("{stdout}\n{stderr}");
2651        let expected = "Filesystem does not have resize_inode enabled, but s_reserved_gdt_blocks";
2652        let unexpected = [
2653            "WARNING:",
2654            "UNEXPECTED INCONSISTENCY",
2655            "Filesystem still has errors",
2656            "Block bitmap differences",
2657            "Inode bitmap differences",
2658            "Free blocks count wrong",
2659            "Free inodes count wrong",
2660            "multiply-claimed",
2661            "checksum does not match",
2662        ];
2663        assert!(
2664            output.status.success()
2665                && diagnostics.contains(expected)
2666                && diagnostics.contains("should be zero.  Fix? no")
2667                && unexpected
2668                    .iter()
2669                    .all(|diagnostic| !diagnostics.contains(diagnostic)),
2670            "e2fsck found a non-baseline inconsistency after {label}:\nstdout: {stdout}\nstderr: {stderr}"
2671        );
2672        true
2673    }
2674
2675    /// Ask e2fsprogs to perform the same mutation as a busy guest: enough top-level files force
2676    /// the root directory to allocate additional, potentially fragmented extents. This gives the
2677    /// ignored interoperability test an independently produced legacy image rather than another
2678    /// image assembled solely by this crate.
2679    fn populate_legacy_root_with_debugfs(path: &Path) -> bool {
2680        let dir = tempfile::tempdir().unwrap();
2681        let empty = dir.path().join("empty");
2682        let commands = dir.path().join("debugfs.commands");
2683        std::fs::write(&empty, []).unwrap();
2684
2685        let mut script = String::new();
2686        for index in 0..600 {
2687            script.push_str(&format!(
2688                "write {} /root-entry-{index:04}\n",
2689                empty.display()
2690            ));
2691        }
2692        std::fs::write(&commands, script).unwrap();
2693
2694        let output = match std::process::Command::new("debugfs")
2695            .arg("-w")
2696            .arg("-f")
2697            .arg(&commands)
2698            .arg(path)
2699            .output()
2700        {
2701            Ok(output) => output,
2702            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2703                eprintln!("debugfs not found; skipping");
2704                return false;
2705            }
2706            Err(error) => panic!("failed to run debugfs: {error}"),
2707        };
2708        assert!(
2709            output.status.success(),
2710            "debugfs failed:\nstdout: {}\nstderr: {}",
2711            String::from_utf8_lossy(&output.stdout),
2712            String::from_utf8_lossy(&output.stderr)
2713        );
2714
2715        let img = parse(path);
2716        let mut file = File::open(path).unwrap();
2717        let root_inode = read_inode(&mut file, &img.geometry(), EXT4_ROOT_INO).unwrap();
2718        assert!(
2719            get_le16(&root_inode, 0x2A) > 1 || get_le16(&root_inode, 0x2E) > 0,
2720            "debugfs did not grow the legacy root extent tree"
2721        );
2722        true
2723    }
2724
2725    /// Full `e2fsck -fn` validation of a formatted and grown image. Gated behind `--ignored`
2726    /// because e2fsprogs is only guaranteed on Linux CI; skips cleanly when the binary is absent.
2727    #[test]
2728    #[ignore]
2729    fn test_e2fsck_validates_formatted_and_grown_image() {
2730        let dir = tempfile::tempdir().unwrap();
2731        let path = dir.path().join("fsck.ext4");
2732        format_image(&path, 256 * MIB);
2733
2734        if !assert_e2fsck_clean(&path, "format") {
2735            return;
2736        }
2737        grow_image(&path, 512 * MIB).unwrap();
2738        assert_e2fsck_clean(&path, "grow to 512 MiB");
2739        grow_image(&path, 1024 * MIB).unwrap();
2740        assert_e2fsck_clean(&path, "grow to 1 GiB");
2741    }
2742
2743    /// Cross a 64-group descriptor boundary so one reserved-GDT block becomes a live GDT block,
2744    /// then let the reference checker validate the rebuilt resize inode and all backup pointers.
2745    #[test]
2746    #[ignore]
2747    fn test_e2fsck_validates_consumed_reserved_gdt_block() {
2748        let dir = tempfile::tempdir().unwrap();
2749        let path = dir.path().join("fsck-consumed-gdt.ext4");
2750        format_image(&path, 256 * MIB);
2751
2752        grow_image(&path, 68 * 128 * MIB).unwrap();
2753        assert_e2fsck_clean(&path, "consuming reserved GDT headroom");
2754    }
2755
2756    /// 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`.
2757    #[test]
2758    #[ignore]
2759    fn test_e2fsck_validates_replayed_and_grown_image() {
2760        let dir = tempfile::tempdir().unwrap();
2761        let path = dir.path().join("fsck-replay.ext4");
2762        format_image(&path, 256 * MIB);
2763
2764        let (location, _) = journal_location(&path);
2765        let data_start = location.start_block + location.len_blocks as u64 + 16;
2766        let mut escaped = pattern_block(0x11);
2767        put_be32(&mut escaped, 0, JBD2_MAGIC);
2768        write_dirty_journal(
2769            &path,
2770            2,
2771            &[
2772                TestTransaction {
2773                    writes: vec![(data_start + 2, pattern_block(0xA5)), (data_start, escaped)],
2774                    revokes: vec![],
2775                    corrupt_commit: false,
2776                },
2777                TestTransaction {
2778                    writes: vec![(data_start + 1, pattern_block(0x22))],
2779                    revokes: vec![data_start],
2780                    corrupt_commit: false,
2781                },
2782            ],
2783        );
2784
2785        grow_image(&path, 512 * MIB).unwrap();
2786        assert_e2fsck_clean(&path, "replay + grow");
2787    }
2788
2789    /// Compare a legacy image before and after repeated growth using e2fsprogs. The one accepted
2790    /// warning is present in released v0.6.8 images before this resizer touches them; no additional
2791    /// inconsistency may appear after either grow.
2792    #[test]
2793    #[ignore]
2794    fn test_e2fsck_legacy_baseline_survives_repeated_growth() {
2795        let dir = tempfile::tempdir().unwrap();
2796        let path = dir.path().join("fsck-legacy.ext4");
2797        format_legacy_image(&path, 256 * MIB);
2798
2799        if !populate_legacy_root_with_debugfs(&path) {
2800            return;
2801        }
2802
2803        if !assert_e2fsck_legacy_baseline(&path, "legacy format with a grown root extent tree") {
2804            return;
2805        }
2806        grow_image(&path, 512 * MIB).unwrap();
2807        assert_e2fsck_legacy_baseline(&path, "legacy grow to 512 MiB");
2808        grow_image(&path, 1024 * MIB).unwrap();
2809        assert_e2fsck_legacy_baseline(&path, "legacy grow to 1 GiB");
2810    }
2811}