Skip to main content

fs_ext4/
checksum.rs

1//! Metadata checksum verification (RO_COMPAT_METADATA_CSUM).
2//!
3//! Spec: kernel.org/doc/html/latest/filesystems/ext4/checksums.html
4//!
5//! When `RO_COMPAT_METADATA_CSUM` is set, ext4 stores a CRC32C of every
6//! metadata structure to detect on-disk corruption. The checksum is salted
7//! by the **filesystem UUID** (or by `s_checksum_seed` when
8//! `INCOMPAT_CSUM_SEED` is also set) so the same byte pattern in two
9//! different filesystems hashes differently.
10//!
11//! The spec uses a chained CRC pattern:
12//!
13//! 1. Start with the seed (UUID or s_checksum_seed).
14//! 2. For per-group/per-inode structures, fold in the group number / inode
15//!    number / generation as a "context" prefix.
16//! 3. Then CRC the actual structure with the checksum field zeroed.
17//!
18//! This module exposes two helpers:
19//!   - [`Checksummer::seed`] — derived once at mount time
20//!   - [`Checksummer::superblock`], [`Checksummer::inode`], etc.
21//!
22//! Phase 1: read-only verification. We do NOT recompute checksums on writes
23//! (no writes yet). Verification is currently INFORMATIONAL — corrupt
24//! metadata would still parse; this module just lets callers decide whether
25//! to trust the result.
26
27use crate::features::{Incompat, RoCompat};
28use crate::superblock::Superblock;
29
30/// Linux-semantics CRC32C: no final XOR at either end. The `crc32c` crate's
31/// `crc32c_append(s, d)` is `~iterate(~s, d)`; the kernel's `__crc32c_le(c, d, l)`
32/// is `iterate(c, d)`. Wrap to get the kernel's semantics out of the crate.
33///
34/// Public so write-path callers that rewrite a metadata block (dir, BGD, SB)
35/// can recompute the tail checksum inline without rebuilding a `Checksummer`.
36#[inline]
37pub fn linux_crc32c(seed: u32, data: &[u8]) -> u32 {
38    !crc32c::crc32c_append(!seed, data)
39}
40
41/// Per-mount checksum context: the seed and "is it enabled" flag.
42#[derive(Debug, Clone, Copy)]
43pub struct Checksummer {
44    pub seed: u32,
45    pub enabled: bool,
46}
47
48/// `file_type` of the fake dirent that ends a checksummed directory
49/// block — `EXT4_FT_DIR_CSUM` in e2fsprogs.
50///
51/// Not a real file type: it is out of range for one, which is how a
52/// reader tells the tail apart from an entry.
53pub const DIR_ENTRY_TAIL_FILE_TYPE: u8 = 0xDE;
54
55impl Checksummer {
56    /// Derive the checksum context from a parsed superblock.
57    ///
58    /// Per spec: if `INCOMPAT_CSUM_SEED` is set, use the explicit
59    /// `s_checksum_seed` field. Otherwise, the seed is the kernel's
60    /// `__crc32c_le(~0, UUID, 16)` — i.e. our `linux_crc32c(!0, UUID)`.
61    pub fn from_superblock(sb: &Superblock) -> Self {
62        let enabled = (sb.feature_ro_compat & RoCompat::METADATA_CSUM.bits()) != 0;
63        let seed = if (sb.feature_incompat & Incompat::CSUM_SEED.bits()) != 0 {
64            sb.checksum_seed
65        } else {
66            linux_crc32c(!0, &sb.uuid)
67        };
68        Self { seed, enabled }
69    }
70
71    /// Linux-semantics CRC32C of a buffer using the mount-wide seed.
72    pub fn crc(&self, data: &[u8]) -> u32 {
73        linux_crc32c(self.seed, data)
74    }
75
76    /// Linux-semantics CRC32C with a 32-bit context prefix folded in first.
77    pub fn crc_with_prefix(&self, prefix: u32, data: &[u8]) -> u32 {
78        let mid = linux_crc32c(self.seed, &prefix.to_le_bytes());
79        linux_crc32c(mid, data)
80    }
81
82    /// Verify the superblock checksum. Stored at byte offset 0x3FC; CRC
83    /// covers the first 0x3FC bytes. Initial seed is `~0` (NOT the per-FS
84    /// seed — superblock checksum is special since the seed lives inside it).
85    pub fn verify_superblock(&self, sb_raw: &[u8]) -> bool {
86        if !self.enabled {
87            return true;
88        }
89        if sb_raw.len() < 1024 {
90            return false;
91        }
92        let stored = u32::from_le_bytes(sb_raw[0x3FC..0x400].try_into().unwrap());
93        let computed = linux_crc32c(!0, &sb_raw[..0x3FC]);
94        stored == computed
95    }
96
97    /// Verify a block group descriptor's checksum.
98    ///
99    /// Per spec (`ext4/group_descr.html`), when `RO_COMPAT_METADATA_CSUM` is
100    /// set the GDT checksum is computed as:
101    ///
102    /// ```text
103    ///   crc32c(seed, group_no_le_u32 || bgd_with_csum_zeroed) & 0xFFFF
104    /// ```
105    ///
106    /// `desc_size` is the on-disk descriptor size (32 or 64).
107    /// `bgd_raw` must be at least `desc_size` bytes; the stored checksum at
108    /// offset 0x1E is treated as zero for the computation.
109    pub fn verify_bgd(&self, group_no: u32, bgd_raw: &[u8], desc_size: u16) -> bool {
110        if !self.enabled {
111            return true;
112        }
113        let n = desc_size as usize;
114        if bgd_raw.len() < n || n < 0x20 {
115            return false;
116        }
117        let stored = u16::from_le_bytes(bgd_raw[0x1E..0x20].try_into().unwrap());
118
119        let mut tmp = bgd_raw[..n].to_vec();
120        tmp[0x1E] = 0;
121        tmp[0x1F] = 0;
122
123        let computed16 = self.crc_with_prefix(group_no, &tmp) as u16;
124        computed16 == stored
125    }
126
127    /// Verify a directory block's trailing `ext4_dir_entry_tail` checksum.
128    ///
129    /// Linear directory blocks with `metadata_csum` enabled end in a 12-byte
130    /// `struct ext4_dir_entry_tail { u32 det_reserved_zero1; u16 det_rec_len;
131    /// u8 det_reserved_zero2; u8 det_reserved_ft; u32 det_checksum; }`.
132    ///
133    /// Per Linux `fs/ext4/dir.c::ext4_dirent_csum_set` the CRC covers
134    /// **`block[0..block_size - 12]`** — i.e. everything BEFORE the tail.
135    /// The tail's own bytes (including `det_checksum`) are excluded:
136    ///
137    /// ```text
138    ///   crc32c(seed, ino_le) → crc32c(., gen_le) → crc32c(., block[..len-12])
139    /// ```
140    ///
141    /// `block` is the whole directory block including the trailing tail.
142    pub fn verify_dir_entry_tail(&self, ino: u32, generation: u32, block: &[u8]) -> bool {
143        if !self.enabled {
144            return true;
145        }
146        if block.len() < 12 {
147            return false;
148        }
149        let end = block.len();
150        let stored = u32::from_le_bytes(block[end - 4..end].try_into().unwrap());
151
152        let mut c = linux_crc32c(self.seed, &ino.to_le_bytes());
153        c = linux_crc32c(c, &generation.to_le_bytes());
154        c = linux_crc32c(c, &block[..end - 12]);
155        c == stored
156    }
157
158    /// Plant the `ext4_dir_entry_tail` and checksum a directory block.
159    ///
160    /// The mirror of [`Self::verify_dir_entry_tail`], and the sibling
161    /// that was missing: `patch_extent_tail` and `patch_xattr_block`
162    /// both existed, so the one recipe written most often was the one
163    /// with no helper. It was hand-rolled at **sixteen** sites across
164    /// `fs.rs`, `fsck.rs`, `mkfs.rs` and the tests, in two different
165    /// addressing idioms (`bs - 12` with `+4/+6/+7`, and
166    /// `block.len()` with `-8/-6/-5`) that a reader has to prove
167    /// equivalent at each one.
168    ///
169    /// The tail is a **fake directory entry** occupying the last 12
170    /// bytes: `inode = 0` so no scan mistakes it for a real one,
171    /// `rec_len = 12` so a walk steps over it, `name_len = 0`, and
172    /// `file_type = 0xDE` as the marker `has_csum_tail` looks for. The
173    /// CRC then covers `block[..len - 12]` — the whole block *except*
174    /// the tail entry, which is what separates this from
175    /// [`Self::patch_extent_tail`], where only the trailing 4 bytes are
176    /// excluded.
177    ///
178    /// Planting is idempotent: a block that already carries the tail
179    /// gets the same twelve bytes back, so callers that only need the
180    /// checksum recomputed can use this too rather than keeping a
181    /// second recipe for that case.
182    ///
183    /// Every constant here has to agree with e2fsprogs, and a
184    /// disagreement produces no error until the volume is mounted
185    /// somewhere else — which is the argument for there being one copy
186    /// of them.
187    ///
188    /// No-op when checksums are disabled or the block is under 12
189    /// bytes; returns true when it patched.
190    pub fn patch_dir_entry_tail(&self, ino: u32, generation: u32, block: &mut [u8]) -> bool {
191        if !self.enabled || block.len() < 12 {
192            return false;
193        }
194        let end = block.len();
195        block[end - 12..end - 8].copy_from_slice(&0u32.to_le_bytes()); // inode = 0
196        block[end - 8..end - 6].copy_from_slice(&12u16.to_le_bytes()); // rec_len
197        block[end - 6] = 0; // name_len
198        block[end - 5] = DIR_ENTRY_TAIL_FILE_TYPE;
199
200        let mut c = linux_crc32c(self.seed, &ino.to_le_bytes());
201        c = linux_crc32c(c, &generation.to_le_bytes());
202        c = linux_crc32c(c, &block[..end - 12]);
203        block[end - 4..end].copy_from_slice(&c.to_le_bytes());
204        true
205    }
206
207    /// Verify an extent-block tail checksum.
208    ///
209    /// Extent index/leaf blocks (those read off-inode when the tree has
210    /// internal nodes) end in a 4-byte `struct ext4_extent_tail
211    /// { u32 et_checksum; }`. Per Linux
212    /// `fs/ext4/extents.c::ext4_extent_block_csum_set` the CRC covers
213    /// **`block[0..len-4]`** — only the trailing `et_checksum` field is
214    /// excluded:
215    ///
216    /// ```text
217    ///   crc32c(seed, ino_le) → crc32c(., gen_le) → crc32c(., block[..len-4])
218    /// ```
219    ///
220    /// Different from `verify_dir_entry_tail`, which excludes the full
221    /// 12-byte tail entry.
222    pub fn verify_extent_tail(&self, ino: u32, generation: u32, block: &[u8]) -> bool {
223        if !self.enabled {
224            return true;
225        }
226        if block.len() < 4 {
227            return false;
228        }
229        let end = block.len();
230        let stored = u32::from_le_bytes(block[end - 4..end].try_into().unwrap());
231
232        let mut c = linux_crc32c(self.seed, &ino.to_le_bytes());
233        c = linux_crc32c(c, &generation.to_le_bytes());
234        c = linux_crc32c(c, &block[..end - 4]);
235        c == stored
236    }
237
238    /// Verify a parsed inode's checksum.
239    /// Chained: seed → ino_le → gen_le → inode_bytes (with checksum slots zeroed).
240    pub fn verify_inode(&self, ino: u32, generation: u32, inode_raw: &[u8]) -> bool {
241        if !self.enabled {
242            return true;
243        }
244        // A truncated read is a refusal, not a pass — the same answer
245        // `verify_superblock`, `verify_dir_entry_tail` and
246        // `verify_extent_tail` give, and for the same reason: the
247        // caller got fewer bytes than it asked for, so a checksum
248        // computed here would cover bytes that are not the ones on
249        // disk. Reporting `true` says an inode verified when nothing
250        // verified it.
251        //
252        // This used to read `if !self.enabled || inode_raw.len() < 128`,
253        // one `||` instead of two `if`s — a difference invisible unless
254        // you read all four verifiers together.
255        if inode_raw.len() < crate::inode::GOOD_OLD_INODE_SIZE {
256            return false;
257        }
258        let stored_lo = u16::from_le_bytes(inode_raw[0x7C..0x7E].try_into().unwrap()) as u32;
259        let stored_hi = if inode_raw.len() >= 0x84 {
260            u16::from_le_bytes(inode_raw[0x82..0x84].try_into().unwrap()) as u32
261        } else {
262            0
263        };
264        let stored = (stored_hi << 16) | stored_lo;
265        match self.compute_inode_checksum(ino, generation, inode_raw) {
266            Some((lo, hi)) => ((hi as u32) << 16 | lo as u32) == stored,
267            None => true, // disabled / too short — accept
268        }
269    }
270
271    /// Write the `ext4_extent_tail.et_checksum` u32 at the end of a freshly-
272    /// built extent index/leaf block. Mirrors `verify_extent_tail`: the CRC
273    /// covers `block[..len-4]`, chained seed → ino → generation → body.
274    /// No-op when checksums are disabled; returns true when it patched.
275    pub fn patch_extent_tail(&self, ino: u32, generation: u32, block: &mut [u8]) -> bool {
276        if !self.enabled || block.len() < 4 {
277            return false;
278        }
279        let end = block.len();
280        let mut c = linux_crc32c(self.seed, &ino.to_le_bytes());
281        c = linux_crc32c(c, &generation.to_le_bytes());
282        c = linux_crc32c(c, &block[..end - 4]);
283        block[end - 4..end].copy_from_slice(&c.to_le_bytes());
284        true
285    }
286
287    /// Verify an external xattr block's checksum.
288    ///
289    /// Per Linux `fs/ext4/xattr.c::ext4_xattr_block_csum`, the recipe is:
290    ///
291    /// ```text
292    ///   crc32c(seed, block_nr_le_u64)
293    ///   → crc32c(., block[0x00..0x10])      // magic, refcount, blocks, hash
294    ///   → crc32c(., [0u32])                  // h_checksum slot zeroed (4 bytes)
295    ///   → crc32c(., block[0x14..end])        // rest of block
296    /// ```
297    ///
298    /// The stored u32 lives at offset 0x10 of the block.
299    pub fn verify_xattr_block(&self, block_nr: u64, block: &[u8]) -> bool {
300        if !self.enabled {
301            return true;
302        }
303        if block.len() < 0x20 {
304            return false;
305        }
306        let stored = u32::from_le_bytes(block[0x10..0x14].try_into().unwrap());
307        let computed = self.compute_xattr_block_csum(block_nr, block);
308        stored == computed
309    }
310
311    /// Patch the `h_checksum` field of an external xattr block in place.
312    /// Mirrors [`verify_xattr_block`]. No-op when checksums are disabled.
313    /// Returns `true` when the block was patched.
314    pub fn patch_xattr_block(&self, block_nr: u64, block: &mut [u8]) -> bool {
315        if !self.enabled || block.len() < 0x20 {
316            return false;
317        }
318        let csum = self.compute_xattr_block_csum(block_nr, block);
319        block[0x10..0x14].copy_from_slice(&csum.to_le_bytes());
320        true
321    }
322
323    fn compute_xattr_block_csum(&self, block_nr: u64, block: &[u8]) -> u32 {
324        let mut c = linux_crc32c(self.seed, &block_nr.to_le_bytes());
325        c = linux_crc32c(c, &block[0x00..0x10]);
326        c = linux_crc32c(c, &0u32.to_le_bytes());
327        c = linux_crc32c(c, &block[0x14..]);
328        c
329    }
330
331    /// Compute the inode checksum as two u16 halves (lo=checksum_lo at 0x7C,
332    /// hi=checksum_hi at 0x82). Returns `None` when checksums are disabled
333    /// or the buffer is too short to patch. Callers use this after mutating
334    /// an inode image to restore the checksum before writing back.
335    pub fn compute_inode_checksum(
336        &self,
337        ino: u32,
338        generation: u32,
339        inode_raw: &[u8],
340    ) -> Option<(u16, u16)> {
341        if !self.enabled || inode_raw.len() < crate::inode::GOOD_OLD_INODE_SIZE {
342            return None;
343        }
344        // i_checksum_hi (0x82) is part of the checksum only when i_extra_isize
345        // (0x80) is large enough to cover it — the kernel's EXT4_FITS_IN_INODE
346        // test, which here means i_extra_isize >= 4. On a zeroed freed inode
347        // (i_extra_isize = 0) the kernel uses ONLY the 16-bit lo checksum and
348        // treats 0x82 as ordinary (zero) data; zeroing hi and storing a full
349        // 32-bit value there mismatches ("checksum does not match inode").
350        let fits_hi = inode_raw.len() >= 0x84
351            && u16::from_le_bytes(inode_raw[0x80..0x82].try_into().unwrap()) >= 4;
352        let mut tmp = inode_raw.to_vec();
353        tmp[0x7C] = 0;
354        tmp[0x7D] = 0;
355        if fits_hi {
356            tmp[0x82] = 0;
357            tmp[0x83] = 0;
358        }
359        let mut c = linux_crc32c(self.seed, &ino.to_le_bytes());
360        c = linux_crc32c(c, &generation.to_le_bytes());
361        c = linux_crc32c(c, &tmp);
362        let lo = (c & 0xFFFF) as u16;
363        let hi = if fits_hi {
364            ((c >> 16) & 0xFFFF) as u16
365        } else {
366            0
367        };
368        Some((lo, hi))
369    }
370}
371
372#[cfg(test)]
373mod tests {
374
375    // --- a short buffer is a refusal, on every verifier ------------------
376    //
377    // Written before the fix. `verify_inode` passed a truncated buffer;
378    // its three siblings refused one. Nothing in the crate noticed the
379    // difference, because the short-buffer axis had no coverage at all.
380
381    /// A checksummer that is switched on, with an arbitrary seed.
382    fn enabled() -> Checksummer {
383        Checksummer {
384            enabled: true,
385            seed: 0xDEAD_BEEF,
386        }
387    }
388
389    /// Every verifier refuses a buffer too short to hold the field it
390    /// checks.
391    ///
392    /// This is a policy, not four separate decisions, and it is the
393    /// safe direction: a truncated read means the caller got less than
394    /// it asked for, and the checksum it would compute covers bytes
395    /// that are not the ones on disk. Returning `true` there reports a
396    /// structure as verified when nothing verified it.
397    ///
398    /// Asserted as a set so a fifth verifier cannot quietly pick the
399    /// other answer — which is exactly how `verify_inode` came to
400    /// differ from the other three.
401    #[test]
402    fn every_verifier_refuses_a_buffer_too_short_to_check() {
403        let c = enabled();
404        assert!(
405            !c.verify_superblock(&[0u8; 64]),
406            "superblock: a 64-byte buffer cannot hold a checksum at 0x3FC"
407        );
408        assert!(
409            !c.verify_dir_entry_tail(2, 0, &[0u8; 8]),
410            "dir entry tail: an 8-byte buffer cannot hold a 12-byte tail"
411        );
412        assert!(
413            !c.verify_extent_tail(2, 0, &[0u8; 2]),
414            "extent tail: a 2-byte buffer cannot hold a 4-byte checksum"
415        );
416        assert!(
417            !c.verify_inode(2, 0, &[0u8; 64]),
418            "inode: a 64-byte buffer cannot hold a checksum at 0x7C"
419        );
420    }
421
422    /// And every one of them still passes everything when checksums are
423    /// off, short buffer included.
424    ///
425    /// The two conditions are separate for a reason: "we do not check"
426    /// and "we checked and it failed" are different answers, and a
427    /// filesystem without `metadata_csum` must not start failing reads
428    /// because a buffer was short.
429    #[test]
430    fn a_disabled_checksummer_passes_even_a_short_buffer() {
431        let c = Checksummer {
432            enabled: false,
433            seed: 0,
434        };
435        assert!(c.verify_superblock(&[0u8; 4]));
436        assert!(c.verify_dir_entry_tail(2, 0, &[0u8; 4]));
437        assert!(c.verify_extent_tail(2, 0, &[0u8; 1]));
438        assert!(c.verify_inode(2, 0, &[0u8; 4]));
439    }
440
441    /// The boundary itself: 127 bytes is refused, 128 is checked.
442    ///
443    /// 128 is where `verify_inode`'s own field lives — `i_checksum_lo`
444    /// at 0x7C..0x7E — so a buffer one byte shorter cannot hold it.
445    #[test]
446    fn the_inode_length_boundary_is_where_the_field_ends() {
447        let c = enabled();
448        assert!(!c.verify_inode(2, 0, &[0u8; 127]), "127 is too short");
449        // 128 bytes of zeros is a real check that simply fails: the
450        // stored checksum is zero and the computed one is not.
451        assert!(
452            !c.verify_inode(2, 0, &[0u8; 128]),
453            "128 is checked, and all-zero bytes do not verify"
454        );
455    }
456
457    use super::*;
458
459    #[test]
460    fn disabled_when_feature_off() {
461        // crc32c of an empty seed always yields 0; not interesting.
462        let c = Checksummer {
463            seed: 0,
464            enabled: false,
465        };
466        assert!(c.verify_superblock(&[]));
467        assert!(c.verify_inode(2, 0, &[]));
468        assert!(c.verify_dir_entry_tail(2, 0, &[0u8; 12]));
469        assert!(c.verify_extent_tail(2, 0, &[0u8; 64]));
470    }
471
472    #[test]
473    fn dir_tail_roundtrip_and_tamper() {
474        let c = Checksummer {
475            seed: 0xCAFEBABE,
476            enabled: true,
477        };
478        let ino = 42u32;
479        let gen = 0xDEADBEEFu32;
480        let mut block = vec![0u8; 4096];
481        // Plant a fake `ext4_dir_entry_tail` at the last 12 bytes — the spec
482        // reserves these and the CRC excludes them entirely.
483        let end = block.len();
484        block[end - 12..end - 8].copy_from_slice(&0u32.to_le_bytes()); // det_reserved_zero1
485        block[end - 8..end - 6].copy_from_slice(&12u16.to_le_bytes()); // det_rec_len
486        block[end - 6] = 0; // det_reserved_zero2
487        block[end - 5] = 0xDE; // det_reserved_ft
488                               // Some plausible directory content bytes (BEFORE the tail).
489        block[0..8].copy_from_slice(&[2, 0, 0, 0, 12, 0, 1, 2]);
490        block[100] = 0x5A;
491        // CRC covers block[..len-12]; tail's last 4 bytes hold the result.
492        let mut expected = linux_crc32c(c.seed, &ino.to_le_bytes());
493        expected = linux_crc32c(expected, &gen.to_le_bytes());
494        expected = linux_crc32c(expected, &block[..end - 12]);
495        block[end - 4..end].copy_from_slice(&expected.to_le_bytes());
496        assert!(c.verify_dir_entry_tail(ino, gen, &block));
497
498        // Tamper one byte inside the covered region, expect failure.
499        block[100] ^= 0xFF;
500        assert!(!c.verify_dir_entry_tail(ino, gen, &block));
501    }
502
503    #[test]
504    fn extent_tail_excludes_only_last_4_bytes() {
505        // The extent tail recipe excludes only the final u32 et_checksum,
506        // unlike dir_entry_tail which excludes the full 12-byte tail.
507        let c = Checksummer {
508            seed: 0x12345678,
509            enabled: true,
510        };
511        let mut block = vec![0u8; 1024];
512        block[0..12].copy_from_slice(&[0x0A, 0xF3, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0]);
513        block[500] = 0xAB;
514        let end = block.len();
515        let mut expected = linux_crc32c(c.seed, &7u32.to_le_bytes());
516        expected = linux_crc32c(expected, &9u32.to_le_bytes());
517        expected = linux_crc32c(expected, &block[..end - 4]);
518        block[end - 4..end].copy_from_slice(&expected.to_le_bytes());
519        assert!(c.verify_extent_tail(7, 9, &block));
520    }
521
522    #[test]
523    fn patch_extent_tail_is_verify_inverse() {
524        let c = Checksummer {
525            seed: 0xFEEDFACE,
526            enabled: true,
527        };
528        let mut block = vec![0u8; 4096];
529        // Synthetic leaf header + one entry so the body is interesting.
530        block[0..12].copy_from_slice(&[0x0A, 0xF3, 1, 0, 0x54, 0x01, 0, 0, 1, 2, 3, 4]);
531        block[12..24].copy_from_slice(&[0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0xE8, 0x03]);
532        let patched = c.patch_extent_tail(42, 0xABCDEF01, &mut block);
533        assert!(patched);
534        assert!(c.verify_extent_tail(42, 0xABCDEF01, &block));
535        // Tampering with body invalidates.
536        block[200] ^= 0xFF;
537        assert!(!c.verify_extent_tail(42, 0xABCDEF01, &block));
538    }
539
540    #[test]
541    fn patch_extent_tail_disabled_is_noop() {
542        let c = Checksummer {
543            seed: 0,
544            enabled: false,
545        };
546        let mut block = vec![0u8; 64];
547        assert!(!c.patch_extent_tail(1, 1, &mut block));
548        assert_eq!(&block[..], &[0u8; 64]);
549    }
550
551    #[test]
552    fn dir_tail_rejects_too_short_block_when_enabled() {
553        let c = Checksummer {
554            seed: 0,
555            enabled: true,
556        };
557        // Less than 12 bytes cannot even hold the tail struct.
558        assert!(!c.verify_dir_entry_tail(2, 0, &[0u8; 8]));
559    }
560
561    #[test]
562    fn crc_helpers_are_deterministic() {
563        let c = Checksummer {
564            seed: 0xDEAD_BEEF,
565            enabled: true,
566        };
567        let a = c.crc(b"hello");
568        let b = c.crc(b"hello");
569        assert_eq!(a, b);
570        let p1 = c.crc_with_prefix(1, b"hello");
571        let p2 = c.crc_with_prefix(2, b"hello");
572        assert_ne!(p1, p2, "prefix changes hash");
573    }
574
575    /// Verify our superblock-checksum routine against a real ext4-basic.img
576    /// (which has metadata_csum enabled).
577    #[test]
578    fn verifies_real_superblock() {
579        use crate::block_io::FileDevice;
580
581        let path = "test-disks/ext4-basic.img";
582        let dev = match FileDevice::open(path) {
583            Ok(d) => d,
584            Err(_) => {
585                eprintln!("skip: {path} not present");
586                return;
587            }
588        };
589        let sb = Superblock::read(&dev).expect("parse sb");
590        let csum = Checksummer::from_superblock(&sb);
591        if !csum.enabled {
592            eprintln!("skip: metadata_csum not enabled in ext4-basic.img");
593            return;
594        }
595        assert!(
596            csum.verify_superblock(&sb.raw),
597            "superblock checksum mismatch on {path}"
598        );
599    }
600
601    /// Verify our BGD checksum against a real image — every group must pass.
602    #[test]
603    fn verifies_real_bgd() {
604        use crate::block_io::{BlockDevice, FileDevice};
605
606        let path = "test-disks/ext4-basic.img";
607        let dev = match FileDevice::open(path) {
608            Ok(d) => d,
609            Err(_) => {
610                eprintln!("skip: {path} not present");
611                return;
612            }
613        };
614        let sb = Superblock::read(&dev).expect("parse sb");
615        let csum = Checksummer::from_superblock(&sb);
616        if !csum.enabled {
617            eprintln!("skip: metadata_csum not enabled");
618            return;
619        }
620        // Read raw BGT and verify each descriptor.
621        let block_size = sb.block_size() as u64;
622        let bgt_off = (sb.first_data_block as u64 + 1) * block_size;
623        let group_count = sb.block_group_count();
624        let total = group_count as usize * sb.desc_size as usize;
625        let mut buf = vec![0u8; total];
626        dev.read_at(bgt_off, &mut buf).expect("read bgt");
627        for i in 0..group_count as usize {
628            let off = i * sb.desc_size as usize;
629            let raw = &buf[off..off + sb.desc_size as usize];
630            assert!(
631                csum.verify_bgd(i as u32, raw, sb.desc_size),
632                "BGD {i} checksum mismatch on {path}"
633            );
634        }
635    }
636
637    /// Verify our inode checksum against a real image — root inode (2) must pass.
638    #[test]
639    fn verifies_real_inode() {
640        use crate::block_io::FileDevice;
641        use crate::fs::Filesystem;
642        use std::sync::Arc;
643
644        let path = "test-disks/ext4-basic.img";
645        let dev = match FileDevice::open(path) {
646            Ok(d) => d,
647            Err(_) => {
648                eprintln!("skip: {path} not present");
649                return;
650            }
651        };
652        let dev_dyn: Arc<dyn crate::block_io::BlockDevice> = Arc::new(dev);
653        let fs = Filesystem::mount(dev_dyn).expect("mount");
654        if !fs.csum.enabled {
655            eprintln!("skip: metadata_csum not enabled");
656            return;
657        }
658        // Inode 2 = root dir.
659        let (inode, raw) = fs.read_inode_verified(2).expect("read root inode");
660        assert!(inode.is_dir());
661        assert!(fs.csum.verify_inode(2, inode.generation, &raw));
662    }
663
664    /// Verify dir-block tail csum against a real image. Root dir on
665    /// ext4-basic.img is a single-block linear directory with a tail.
666    #[test]
667    fn verifies_real_dir_tail() {
668        use crate::block_io::{BlockDevice, FileDevice};
669        use crate::dir;
670        use crate::extent;
671        use crate::fs::Filesystem;
672        use std::sync::Arc;
673
674        let path = "test-disks/ext4-basic.img";
675        let dev = match FileDevice::open(path) {
676            Ok(d) => d,
677            Err(_) => {
678                eprintln!("skip: {path} not present");
679                return;
680            }
681        };
682        let dev_dyn: Arc<dyn BlockDevice> = Arc::new(dev);
683        let fs = Filesystem::mount(dev_dyn.clone()).expect("mount");
684        if !fs.csum.enabled {
685            eprintln!("skip: metadata_csum not enabled");
686            return;
687        }
688        let (root_inode, _raw) = fs.read_inode_verified(2).expect("root inode");
689        let bs = fs.sb.block_size();
690        let phys = extent::map_logical(&root_inode.block, dev_dyn.as_ref(), bs, 0)
691            .expect("map_logical")
692            .expect("dir block 0 mapped");
693        let mut block = vec![0u8; bs as usize];
694        dev_dyn.read_at(phys * bs as u64, &mut block).unwrap();
695        assert!(
696            dir::has_csum_tail(&block),
697            "expected tail on root dir block"
698        );
699        assert!(
700            fs.csum
701                .verify_dir_entry_tail(2, root_inode.generation, &block),
702            "dir tail csum mismatch on {path} root dir"
703        );
704    }
705
706    /// Verify extent-block tail csum against ext4-deep-extents.img: any file
707    /// with depth > 0 has off-inode extent index/leaf blocks. We pick the
708    /// largest regular file and traverse one internal-node block.
709    #[test]
710    fn verifies_real_extent_tail() {
711        use crate::block_io::{BlockDevice, FileDevice};
712        use crate::extent::{self, ExtentHeader, ExtentIdx, EXT4_EXT_NODE_SIZE};
713        use crate::fs::Filesystem;
714        use std::sync::Arc;
715
716        let path = "test-disks/ext4-deep-extents.img";
717        let dev = match FileDevice::open(path) {
718            Ok(d) => d,
719            Err(_) => {
720                eprintln!("skip: {path} not present");
721                return;
722            }
723        };
724        let dev_dyn: Arc<dyn BlockDevice> = Arc::new(dev);
725        let fs = Filesystem::mount(dev_dyn.clone()).expect("mount");
726        if !fs.csum.enabled {
727            eprintln!("skip: metadata_csum not enabled");
728            return;
729        }
730        // Walk first ~50 inodes looking for one with depth>0.
731        let bs = fs.sb.block_size();
732        let mut found = None;
733        for ino in 11..200u32 {
734            let (inode, _raw) = match fs.read_inode_verified(ino) {
735                Ok(x) => x,
736                Err(_) => continue,
737            };
738            if !inode.is_file() || !inode.has_extents() {
739                continue;
740            }
741            let header = match ExtentHeader::parse(&inode.block) {
742                Ok(h) => h,
743                Err(_) => continue,
744            };
745            if header.depth > 0 && header.entries >= 1 {
746                let idx =
747                    ExtentIdx::parse(&inode.block[EXT4_EXT_NODE_SIZE..2 * EXT4_EXT_NODE_SIZE])
748                        .expect("parse first idx");
749                found = Some((ino, inode.generation, idx.leaf_block));
750                break;
751            }
752        }
753        let (ino, gen, child_block) = match found {
754            Some(x) => x,
755            None => {
756                eprintln!("skip: no depth>0 inode in first 200 of {path}");
757                return;
758            }
759        };
760        let mut buf = vec![0u8; bs as usize];
761        dev_dyn.read_at(child_block * bs as u64, &mut buf).unwrap();
762        assert!(
763            fs.csum.verify_extent_tail(ino, gen, &buf),
764            "extent block csum mismatch (ino={ino} child_block={child_block} on {path})"
765        );
766
767        // Also exercise the verified traversal API end-to-end.
768        let (inode, _) = fs.read_inode_verified(ino).unwrap();
769        let ctx = extent::ExtentVerifyCtx {
770            ino,
771            generation: gen,
772            csum: &fs.csum,
773        };
774        let _ = extent::lookup_verified(&inode.block, dev_dyn.as_ref(), bs, 0, Some(&ctx))
775            .expect("lookup_verified must accept valid extent blocks");
776    }
777}