1use 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
42const SB_OFFSET: u64 = 1024;
48
49const SB_SIZE: usize = 1024;
51
52const LEGACY_FEATURE_COMPAT: u32 =
55 EXT4_FEATURE_COMPAT_HAS_JOURNAL | EXT4_FEATURE_COMPAT_EXT_ATTR | EXT4_FEATURE_COMPAT_DIR_INDEX;
56
57const MODERN_FEATURE_COMPAT: u32 = LEGACY_FEATURE_COMPAT | EXT4_FEATURE_COMPAT_RESIZE_INODE;
59
60const EXT4_MAX_EXTENT_DEPTH: u16 = 5;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct GrowOutcome {
71 pub old_blocks: u64,
73
74 pub new_blocks: u64,
76
77 pub old_groups: u32,
79
80 pub new_groups: u32,
82}
83
84#[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 Self::Legacy => EXT4_SB_ERROR_COUNT_OFFSET,
99 Self::Modern { .. } => EXT4_SB_OVERHEAD_BLOCKS_OFFSET,
100 }
101 }
102}
103
104struct ParsedImage {
107 sb: Vec<u8>,
109
110 gdt: Vec<u8>,
112
113 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
128impl 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 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
163pub 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
186pub(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 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 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 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 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 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 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 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 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 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 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 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
446pub(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
515fn 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 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 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 if img.needs_recovery {
675 return Ok(img);
676 }
677
678 if get_le32(&img.sb, 0xE8) != 0 {
680 return Err(unsupported("filesystem has a pending orphan inode list"));
681 }
682
683 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
743fn 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 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 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
814fn 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
916fn 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 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 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
968fn 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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}