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