Skip to main content

fs_ext4/
verify.rs

1//! In-tree structural verifier for ext2 / ext3 / ext4 volumes.
2//!
3//! Walks the on-disk superblock + BGDs + per-group bitmaps + every
4//! allocated inode's block tree, then reconciles: every block any inode
5//! claims must be marked allocated in its group's block bitmap, and every
6//! allocated block should be claimed by exactly one inode (orphans surface
7//! as warnings). Flavor-aware via [`crate::indirect::map_logical_any`] —
8//! works equally on ext4 (extent tree) and ext2/3 (legacy direct/indirect).
9//!
10//! This is the sibling of [`crate::fsck`]: fsck audits *logical* invariants
11//! (link counts, dir entries, .. parents); this module audits *physical*
12//! invariants (bitmap consistency, block double-claims, leaked blocks).
13//! The two are complementary — both should run clean on any image we
14//! produce or accept as input.
15//!
16//! Pure read-only. Never mutates the volume. Designed to run as a test
17//! oracle: "format → write → mount → call `verify()` → assert clean".
18//! The `VerifyReport` lists every finding as a human-readable string so
19//! tests can substring-match on the failure mode without bespoke types.
20
21use crate::error::Result;
22use crate::extent;
23use crate::fs::Filesystem;
24use crate::indirect;
25use crate::inode::{Inode, InodeFlags};
26
27/// Outcome of a verifier run. `errors` are violations of physical
28/// invariants (e.g. an inode references a block the bitmap says is free —
29/// the FS is corrupt). `warnings` are anomalies that don't break correctness
30/// but suggest waste or stale state (e.g. an allocated block no inode
31/// claims — leaked). `is_clean()` checks only `errors`.
32#[derive(Debug, Clone, Default)]
33pub struct VerifyReport {
34    pub errors: Vec<String>,
35    pub warnings: Vec<String>,
36    pub inodes_walked: u32,
37    pub blocks_claimed: u64,
38}
39
40impl VerifyReport {
41    pub fn is_clean(&self) -> bool {
42        self.errors.is_empty()
43    }
44
45    /// Single-line summary suitable for test panics + log output.
46    pub fn summary(&self) -> String {
47        format!(
48            "verify: {} errors, {} warnings, {} inodes, {} blocks claimed",
49            self.errors.len(),
50            self.warnings.len(),
51            self.inodes_walked,
52            self.blocks_claimed,
53        )
54    }
55}
56
57/// Walk the volume mounted on `fs` and produce a structural verification
58/// report. Errors are physical-corruption-grade (the volume should not be
59/// trusted for further writes); warnings are recoverable.
60///
61/// Costs: O(allocated_inodes + claimed_blocks). For test-fixture-sized
62/// images (single group, < 100 MiB) this is sub-millisecond. Multi-group
63/// volumes scale linearly with allocated content.
64pub fn verify(fs: &Filesystem) -> Result<VerifyReport> {
65    crate::fsck::refuse_bigalloc(fs)?;
66    let mut report = VerifyReport::default();
67
68    // --- Phase 1: superblock sanity ---
69    verify_superblock(fs, &mut report);
70
71    // --- Phase 2: BGD self-consistency ---
72    verify_bgds(fs, &mut report);
73
74    // --- Phase 3: reconcile inode-claimed blocks vs the on-disk bitmap.
75    // We build a "claimed" bitmap (one bit per fs block) by walking every
76    // allocated inode's block tree, then compare against the union of all
77    // groups' on-disk block bitmaps.
78    let bs = fs.sb.block_size() as u64;
79    let total_blocks = fs.sb.blocks_count;
80    if total_blocks == 0 || bs == 0 {
81        return Ok(report);
82    }
83    // u32 cap: fs blocks_count is u64 on disk (INCOMPAT_64BIT) but our
84    // single-group test fixtures stay well under 2^32. Larger volumes
85    // would need a smarter representation — guard so we don't silently
86    // truncate and produce nonsense.
87    if total_blocks > u32::MAX as u64 {
88        report.errors.push(
89            "verify: volume exceeds u32 blocks (multi-group triple-indirect tier untested)".into(),
90        );
91        return Ok(report);
92    }
93
94    let mut claimed = ClaimedBitmap::new(total_blocks as u32);
95
96    // Pre-mark the always-unavailable / metadata-fixed regions so they don't
97    // surface as "leaked" warnings:
98    // - Block 0 (boot sector / SB region) is permanently used by the FS.
99    // - Each group's superblock copy + BGT + bitmaps + inode table are
100    //   metadata, owned by the FS itself, not by any inode.
101    mark_metadata_blocks(fs, &mut claimed);
102
103    walk_all_allocated_inodes(fs, &mut claimed, &mut report)?;
104
105    // Compare claimed bitmap against on-disk bitmaps group by group.
106    reconcile_with_bitmaps(fs, &claimed, &mut report)?;
107
108    report.blocks_claimed = claimed.count_set();
109    Ok(report)
110}
111
112// ---------------------------------------------------------------------------
113// Phase 1: superblock
114// ---------------------------------------------------------------------------
115
116fn verify_superblock(fs: &Filesystem, r: &mut VerifyReport) {
117    let sb = &fs.sb;
118
119    if sb.magic != crate::superblock::EXT4_MAGIC {
120        r.errors.push(format!(
121            "superblock: magic 0x{:04X} != 0x{:04X}",
122            sb.magic,
123            crate::superblock::EXT4_MAGIC
124        ));
125    }
126    let bs = sb.block_size() as u64;
127    if bs == 0 || !bs.is_power_of_two() || !(1024..=65536).contains(&bs) {
128        r.errors.push(format!(
129            "superblock: block_size {} out of range [1024..=65536, power of 2]",
130            bs
131        ));
132    }
133    if sb.blocks_count == 0 {
134        r.errors.push("superblock: blocks_count == 0".into());
135    }
136    if sb.free_blocks_count > sb.blocks_count {
137        r.errors.push(format!(
138            "superblock: free_blocks_count {} > blocks_count {}",
139            sb.free_blocks_count, sb.blocks_count
140        ));
141    }
142    if sb.free_inodes_count > sb.inodes_count {
143        r.errors.push(format!(
144            "superblock: free_inodes_count {} > inodes_count {}",
145            sb.free_inodes_count, sb.inodes_count
146        ));
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Phase 2: BGDs
152// ---------------------------------------------------------------------------
153
154fn verify_bgds(fs: &Filesystem, r: &mut VerifyReport) {
155    let total = fs.sb.blocks_count;
156    let bpg = fs.sb.blocks_per_group as u64;
157    let ipg = fs.sb.inodes_per_group;
158    for (gi, bgd) in fs.groups.iter().enumerate() {
159        if bgd.block_bitmap >= total {
160            r.errors.push(format!(
161                "bgd[{}]: block_bitmap {} >= blocks_count {}",
162                gi, bgd.block_bitmap, total
163            ));
164        }
165        if bgd.inode_bitmap >= total {
166            r.errors.push(format!(
167                "bgd[{}]: inode_bitmap {} >= blocks_count {}",
168                gi, bgd.inode_bitmap, total
169            ));
170        }
171        if bgd.inode_table >= total {
172            r.errors.push(format!(
173                "bgd[{}]: inode_table {} >= blocks_count {}",
174                gi, bgd.inode_table, total
175            ));
176        }
177        if bgd.free_blocks_count as u64 > bpg {
178            r.errors.push(format!(
179                "bgd[{}]: free_blocks_count {} > blocks_per_group {}",
180                gi, bgd.free_blocks_count, bpg
181            ));
182        }
183        if bgd.free_inodes_count > ipg {
184            r.errors.push(format!(
185                "bgd[{}]: free_inodes_count {} > inodes_per_group {}",
186                gi, bgd.free_inodes_count, ipg
187            ));
188        }
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Phase 3: claimed-block bitmap + reconciliation
194// ---------------------------------------------------------------------------
195
196/// Bit-packed per-block flags. A set bit means "some inode claims this
197/// block" (or "this block is permanent FS metadata" — see
198/// `mark_metadata_blocks`).
199struct ClaimedBitmap {
200    bits: Vec<u8>,
201    total: u32,
202}
203
204impl ClaimedBitmap {
205    fn new(total_blocks: u32) -> Self {
206        Self {
207            bits: vec![0u8; total_blocks.div_ceil(8) as usize],
208            total: total_blocks,
209        }
210    }
211
212    fn set(&mut self, block: u32) -> bool {
213        debug_assert!(block < self.total);
214        let byte = (block / 8) as usize;
215        let mask = 1u8 << (block % 8);
216        let was = self.bits[byte] & mask != 0;
217        self.bits[byte] |= mask;
218        was
219    }
220
221    fn get(&self, block: u32) -> bool {
222        debug_assert!(block < self.total);
223        let byte = (block / 8) as usize;
224        let mask = 1u8 << (block % 8);
225        self.bits[byte] & mask != 0
226    }
227
228    fn count_set(&self) -> u64 {
229        self.bits.iter().map(|b| b.count_ones() as u64).sum()
230    }
231}
232
233fn mark_metadata_blocks(fs: &Filesystem, claimed: &mut ClaimedBitmap) {
234    // Block 0 (boot sector + first SB) is FS-owned. For 1 KiB blocks the
235    // SB lives in block 1 too — also FS-owned.
236    let bs = fs.sb.block_size();
237    let sb_logical_block = 1024 / bs;
238    if sb_logical_block < claimed.total {
239        claimed.set(sb_logical_block);
240    }
241    if 0 < claimed.total {
242        claimed.set(0);
243    }
244
245    // Per group: BGT block (typically first_data_block + 1), block bitmap,
246    // inode bitmap, inode table.
247    for bgd in &fs.groups {
248        if bgd.block_bitmap < claimed.total as u64 {
249            claimed.set(bgd.block_bitmap as u32);
250        }
251        if bgd.inode_bitmap < claimed.total as u64 {
252            claimed.set(bgd.inode_bitmap as u32);
253        }
254        let inodes_per_group = fs.sb.inodes_per_group as u64;
255        let inode_size = fs.sb.inode_size as u64;
256        let it_blocks = (inodes_per_group * inode_size).div_ceil(bs as u64);
257        for i in 0..it_blocks {
258            let b = bgd.inode_table + i;
259            if b < claimed.total as u64 {
260                claimed.set(b as u32);
261            }
262        }
263    }
264
265    // BGT block: derive from first_data_block. Single-group volumes (Phase A
266    // mkfs constraint) put it at first_data_block + 1; multi-group needs a
267    // walk over `bg_block_bitmap - 1` etc., which we approximate by also
268    // marking the block immediately preceding each bitmap (the BGT lives
269    // there in mkfs's layout). This is a heuristic — false positives
270    // (over-marking) only suppress potential leaked-block warnings, never
271    // generate false errors.
272    let first_data = fs.sb.first_data_block as u64;
273    let bgt = first_data + 1;
274    if bgt < claimed.total as u64 {
275        claimed.set(bgt as u32);
276    }
277}
278
279fn walk_all_allocated_inodes(
280    fs: &Filesystem,
281    claimed: &mut ClaimedBitmap,
282    r: &mut VerifyReport,
283) -> Result<()> {
284    let bs = fs.sb.block_size() as u64;
285    let ipg = fs.sb.inodes_per_group;
286
287    for (gi, bgd) in fs.groups.iter().enumerate() {
288        // Read this group's inode bitmap.
289        let mut bm = vec![0u8; bs as usize];
290        fs.dev.read_at(bgd.inode_bitmap * bs, &mut bm)?;
291
292        for bit in 0..ipg {
293            let byte = (bit / 8) as usize;
294            let mask = 1u8 << (bit % 8);
295            if byte >= bm.len() || bm[byte] & mask == 0 {
296                continue;
297            }
298            // ext4 inode numbers are 1-based; bit i = inode (gi*ipg + i + 1).
299            let ino = (gi as u32) * ipg + bit + 1;
300
301            // Skip the reserved range (1..first_inode) silently — those
302            // are FS-internal slots (resize, journal, etc.).
303            if ino < fs.sb.first_inode && ino != 2 {
304                continue;
305            }
306
307            r.inodes_walked += 1;
308            let raw = match fs.read_inode_raw(ino) {
309                Ok(b) => b,
310                Err(e) => {
311                    r.errors
312                        .push(format!("inode {}: read failed: {:?}", ino, e));
313                    continue;
314                }
315            };
316            let inode = match Inode::parse(&raw) {
317                Ok(i) => i,
318                Err(e) => {
319                    r.errors
320                        .push(format!("inode {}: parse failed: {:?}", ino, e));
321                    continue;
322                }
323            };
324            // Inline-data files keep content inside i_block + xattrs — no
325            // separate data blocks to claim. Skip.
326            if (inode.flags & InodeFlags::INLINE_DATA.bits()) != 0 {
327                continue;
328            }
329            // Skip inodes with no allocated data blocks. This covers:
330            //   * Empty regular files (`size == 0`).
331            //   * "Fast" symlinks whose target fits in the 60-byte i_block
332            //     region — `size > 0` but `i_blocks == 0` because the
333            //     inode owns zero on-disk sectors.
334            //   * Special files (chr/blk/fifo/sock) which carry no data.
335            // Without this guard the walker would interpret i_block as
336            // raw block pointers and "discover" garbage block numbers far
337            // outside the volume.
338            if inode.blocks == 0 {
339                continue;
340            }
341            walk_inode_block_tree(fs, ino, &inode, claimed, r)?;
342        }
343    }
344    Ok(())
345}
346
347/// Mark every block reachable from `inode`'s i_block region. Catches
348/// double-claims (set bit was already set → another inode beat us).
349fn walk_inode_block_tree(
350    fs: &Filesystem,
351    ino: u32,
352    inode: &Inode,
353    claimed: &mut ClaimedBitmap,
354    r: &mut VerifyReport,
355) -> Result<()> {
356    let bs = fs.sb.block_size() as u64;
357    let bs32 = fs.sb.block_size();
358    let n_blocks = inode.size.div_ceil(bs);
359    let total = claimed.total as u64;
360
361    if (inode.flags & InodeFlags::EXTENTS.bits()) != 0 {
362        // Extent tree walk: enumerate all leaf extents and intermediate
363        // nodes via `extent::collect_all`, marking everything they cover.
364        let extents = match extent::collect_all(&inode.block, fs.dev.as_ref(), bs32) {
365            Ok(es) => es,
366            Err(e) => {
367                r.errors
368                    .push(format!("inode {}: extent walk failed: {:?}", ino, e));
369                return Ok(());
370            }
371        };
372        for ext in &extents {
373            for off in 0..ext.length as u64 {
374                let phys = ext.physical_block + off;
375                if phys >= total {
376                    r.errors.push(format!(
377                        "inode {}: extent block {} >= blocks_count {}",
378                        ino, phys, total
379                    ));
380                    continue;
381                }
382                if claimed.set(phys as u32) {
383                    r.errors.push(format!(
384                        "inode {}: block {} double-claimed (extent)",
385                        ino, phys
386                    ));
387                }
388            }
389        }
390    } else {
391        // Indirect tree walk: just enumerate logical 0..n_blocks via the
392        // dispatcher. For blocks with mappings, mark them. We don't yet
393        // mark the indirect-tree metadata blocks themselves (single, double,
394        // triple) — those would require a deeper walk. Treating them as
395        // potential leaked-block warnings is acceptable for Phase A; can
396        // tighten in Phase B.
397        for logical in 0..n_blocks {
398            let phys_opt = indirect::map_logical_any(
399                &inode.block,
400                inode.flags,
401                fs.dev.as_ref(),
402                bs32,
403                logical,
404            )?;
405            if let Some(phys) = phys_opt {
406                if phys >= total {
407                    r.errors.push(format!(
408                        "inode {}: indirect block {} >= blocks_count {}",
409                        ino, phys, total
410                    ));
411                    continue;
412                }
413                if claimed.set(phys as u32) {
414                    r.errors.push(format!(
415                        "inode {}: block {} double-claimed (indirect)",
416                        ino, phys
417                    ));
418                }
419            }
420        }
421        // Mark indirect-tree metadata blocks (single/double/triple slots
422        // in i_block) so they don't show up as leaked.
423        for slot in [12usize, 13, 14] {
424            let off = slot * 4;
425            let p = u32::from_le_bytes(inode.block[off..off + 4].try_into().unwrap()) as u64;
426            if p != 0 && p < total {
427                claimed.set(p as u32);
428                // Walk one tier deeper: read the indirect block and mark
429                // any inner indirect blocks it points at. We don't recurse
430                // into double/triple inner-inner-inner — handled
431                // approximately via the `mark_metadata_blocks` over-
432                // marking heuristic.
433                let mut buf = vec![0u8; bs32 as usize];
434                if fs.dev.read_at(p * bs, &mut buf).is_ok() {
435                    let ppb = (bs32 / 4) as usize;
436                    for i in 0..ppb {
437                        let off = i * 4;
438                        let inner =
439                            u32::from_le_bytes(buf[off..off + 4].try_into().unwrap()) as u64;
440                        if inner != 0 && inner < total && slot >= 13 {
441                            claimed.set(inner as u32);
442                        }
443                    }
444                }
445            }
446        }
447    }
448    Ok(())
449}
450
451fn reconcile_with_bitmaps(
452    fs: &Filesystem,
453    claimed: &ClaimedBitmap,
454    r: &mut VerifyReport,
455) -> Result<()> {
456    let bs = fs.sb.block_size() as u64;
457    let bpg = fs.sb.blocks_per_group as u64;
458    let total = fs.sb.blocks_count;
459    let first_data = fs.sb.first_data_block as u64;
460
461    for (gi, bgd) in fs.groups.iter().enumerate() {
462        let mut bm = vec![0u8; bs as usize];
463        fs.dev.read_at(bgd.block_bitmap * bs, &mut bm)?;
464
465        // For each block in this group's range, compare claimed-state
466        // against bitmap-state. Block N in group `gi` is at logical
467        // index `gi * bpg + N + first_data` in our flat claimed map.
468        let group_start = first_data + (gi as u64) * bpg;
469        let group_end = (group_start + bpg).min(total);
470        for b in group_start..group_end {
471            let in_bitmap = {
472                let bit_idx = b - group_start;
473                let byte = (bit_idx / 8) as usize;
474                let mask = 1u8 << (bit_idx % 8);
475                byte < bm.len() && bm[byte] & mask != 0
476            };
477            let in_claimed = claimed.get(b as u32);
478            match (in_bitmap, in_claimed) {
479                (true, true) => {}   // consistent: allocated and claimed
480                (false, false) => {} // consistent: free and unclaimed
481                (false, true) => {
482                    // CORRUPTION: an inode claims this block but the bitmap
483                    // says it's free → double-allocation risk on next write.
484                    r.errors.push(format!(
485                        "block {} (group {}): claimed by inode but marked free in bitmap",
486                        b, gi
487                    ));
488                }
489                (true, false) => {
490                    // LEAK: bitmap says allocated but no inode claims it.
491                    // Could be a transient (we walked at the same time as
492                    // a write) or genuinely leaked.
493                    r.warnings.push(format!(
494                        "block {} (group {}): allocated but unclaimed",
495                        b, gi
496                    ));
497                }
498            }
499        }
500    }
501    Ok(())
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    /// Build an empty `VerifyReport` (helper for direct construction in tests).
509    #[test]
510    fn report_default_is_clean() {
511        let r = VerifyReport::default();
512        assert!(r.is_clean());
513        assert_eq!(
514            r.summary(),
515            "verify: 0 errors, 0 warnings, 0 inodes, 0 blocks claimed"
516        );
517    }
518
519    #[test]
520    fn claimed_bitmap_set_get_count() {
521        let mut bm = ClaimedBitmap::new(100);
522        assert_eq!(bm.count_set(), 0);
523        assert!(!bm.set(50));
524        assert!(bm.set(50)); // second set returns true (was already set)
525        assert!(bm.get(50));
526        assert!(!bm.get(51));
527        assert!(!bm.set(99));
528        assert_eq!(bm.count_set(), 2);
529    }
530}