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}