fs_ext4/fsck.rs
1//! Read-only filesystem audit — a small subset of `ext4 audit tool -n`.
2//!
3//! Walks the directory tree from inode 2 (root), counting how many
4//! directory entries reference each inode. Compares the observed
5//! reference count against the inode's stored `i_links_count` and
6//! flags mismatches. Also reports directories whose `..` entry does
7//! not point at the true parent.
8//!
9//! Three surfaces:
10//! - [`audit`] — synchronous, collects every [`Anomaly`] into a
11//! `Vec` on the returned [`AuditReport`]. Read-only; used by Rust
12//! callers and tests.
13//! - [`audit_with_callbacks`] — same read-only walk, but emits
14//! per-phase progress and per-finding events through
15//! caller-supplied closures. Used by the C ABI
16//! (`fs_ext4_fsck_run`) so the host UI can stream progress and
17//! findings live without buffering the full anomaly list for huge
18//! volumes.
19//! - [`audit_with_repair`] — same walk + an optional repair pass.
20//! When `repair == true` the function mutates the on-disk image
21//! through the journal writer to fix the subset of anomalies it
22//! knows how to repair (currently: duplicate dirents pointing at
23//! one directory inode, and link-count drift). The C ABI is
24//! intentionally not wired through to this surface yet — the
25//! stable shape of `Anomaly` plus a versioned ABI bump is a
26//! separate task.
27
28use crate::bgd;
29use crate::dir::{self, DirBlockIter, DirEntryType};
30use crate::error::{Error, Result};
31use crate::extent;
32use crate::features;
33use crate::fs::{BlockBuffer, Filesystem};
34use crate::inode::Inode;
35use crate::superblock::Superblock;
36use std::collections::HashMap;
37use std::time::{Duration, Instant};
38
39/// One problem found by [`audit`]. Each variant carries the inode or
40/// path needed to act on the finding.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Anomaly {
43 /// A directory entry references an inode whose `i_links_count` is
44 /// *less than* the observed reference count. Stored value is too
45 /// low — fsck would increase it to `observed`.
46 LinkCountTooLow {
47 ino: u32,
48 stored: u16,
49 observed: u32,
50 },
51 /// Inode's `i_links_count` is *greater than* the observed reference
52 /// count. Stored value is too high — fsck would decrease it.
53 LinkCountTooHigh {
54 ino: u32,
55 stored: u16,
56 observed: u32,
57 },
58 /// Dangling directory entry: a dir entry points to an inode with
59 /// `i_links_count == 0` or one we couldn't read.
60 ///
61 /// `observed` is how many dirents reference `child_ino` from the
62 /// audit's directory walk. For the readable-but-zero-links case
63 /// the rescue path writes `observed` into the inode's
64 /// `i_links_count` (it's the same fix as `LinkCountTooLow` from
65 /// `stored=0`). For the unreadable case we synthesise
66 /// `observed = 0` so the rescue is correctly refused — there's no
67 /// safe way to repair an inode we can't read without orphan-list
68 /// or `/lost+found` machinery.
69 DanglingEntry {
70 parent_ino: u32,
71 child_ino: u32,
72 observed: u32,
73 },
74 /// A directory's `..` entry does not point at its true parent.
75 WrongDotDot {
76 dir_ino: u32,
77 claims: u32,
78 actual_parent: u32,
79 },
80 /// A directory entry inside `parent_ino` claims its target
81 /// (`child_ino`) is a directory, but the target inode's mode bits
82 /// are not `S_IFDIR`. Read failures on the child are surfaced as
83 /// `DanglingEntry` from the inodes phase instead. Carrying both
84 /// inodes lets a repair pass either rewrite the dirent's
85 /// `file_type` byte (when the child is a valid non-dir) or
86 /// unlink the dirent.
87 /// Carries the dirent's `name` so the repair pass can target the
88 /// exact record by (parent, name) — necessary when the parent has
89 /// multiple hardlinks to the same inode and only one of them has
90 /// the wrong `file_type` byte. Stored as raw bytes since ext4
91 /// dirent names are not required to be UTF-8.
92 BogusEntry {
93 parent_ino: u32,
94 child_ino: u32,
95 name: Vec<u8>,
96 },
97 /// One block group's free-block / free-inode counters drift from
98 /// the bitmap reality. Either the bitmap claims fewer free bits
99 /// than the descriptor says (over-count) or more (under-count).
100 /// Common after crashes that interrupted bitmap+descriptor pairs
101 /// mid-write. Repair walks the bitmap, recomputes the truth, and
102 /// patches the descriptor (incl. checksum when metadata_csum is on).
103 BlockGroupFreeCountDrift {
104 group_index: u32,
105 stored_blocks: u32,
106 observed_blocks: u32,
107 stored_inodes: u32,
108 observed_inodes: u32,
109 },
110 /// Superblock free-block / free-inode totals don't match the sum
111 /// across all group descriptors' (post-bitmap) counts. Independent
112 /// of `BlockGroupFreeCountDrift`: the per-group descriptors might
113 /// agree with their bitmaps but the SB total still disagree (e.g.
114 /// a torn write of just the SB block). Repair recomputes from the
115 /// bitmaps and writes the SB.
116 SuperblockFreeCountDrift {
117 stored_blocks: u64,
118 observed_blocks: u64,
119 stored_inodes: u32,
120 observed_inodes: u32,
121 },
122 /// Multiple directory entries reference the same directory inode.
123 /// Illegal — directories can have only one parent dirent (plus . and ..).
124 /// Caused by an inode-allocator bug in early `apply_mkdir` (fixed) but
125 /// the on-disk wreckage remains until repair runs.
126 DuplicateDirentForDirInode {
127 ino: u32,
128 /// Every (parent_ino, name) tuple that references `ino`.
129 /// Sorted: (parent_ino asc, name asc) — repair keeps element 0,
130 /// removes the rest.
131 dirents: Vec<(u32, String)>,
132 },
133}
134
135/// Summary returned by [`audit`]. Empty `anomalies` means the subset
136/// of invariants checked all held.
137#[derive(Debug, Clone, Default)]
138pub struct AuditReport {
139 /// Every problem found, in no particular order. Populated by the
140 /// legacy [`audit`] entry point; left empty by
141 /// [`audit_with_callbacks`] (it streams findings through the
142 /// caller's closure to avoid buffering on huge volumes).
143 pub anomalies: Vec<Anomaly>,
144 /// Number of distinct inodes visited via directory entries.
145 pub inodes_visited: u32,
146 /// Number of directory entries scanned (including `.`, `..`, and tombstones).
147 pub entries_scanned: u64,
148 /// Number of directories scanned.
149 pub directories_scanned: u32,
150 /// **Authoritative current count** of anomalies on the
151 /// filesystem. After [`audit`] / [`audit_with_callbacks`] this
152 /// is the count from the single scan. After a repair pass
153 /// (`audit_with_repair` with `repair = true`) this is the
154 /// **post-repair re-scan** count — the actual remaining
155 /// problems on disk, NOT the pre-repair number minus repaired
156 /// (which would be unreliable if our repair logic itself
157 /// introduces new anomalies).
158 pub anomalies_count: u64,
159 /// Number of anomalies the audit ORIGINALLY found, before any
160 /// repair commits. Equal to `anomalies_count` for non-repair
161 /// runs. After a repair pass: `initial_anomalies_count -
162 /// repaired_count` is what we *expect* to remain; the actual
163 /// `anomalies_count` from the post-repair re-scan is what
164 /// REALLY remains. Discrepancies between the two are how we
165 /// notice repair logic has bugs.
166 pub initial_anomalies_count: u64,
167 /// Anomalies the repair pass actually mutated the disk to fix.
168 /// Always zero unless [`audit_with_repair`] ran with `repair = true`.
169 /// A repair that failed midway leaves this counter at the number of
170 /// successfully-committed fixes — partial progress is intentional
171 /// (each commit is its own journal transaction so a crash mid-pass
172 /// can't compound the damage).
173 pub repaired_count: u64,
174}
175
176impl AuditReport {
177 pub fn is_clean(&self) -> bool {
178 self.anomalies_count == 0
179 }
180}
181
182/// Phase identifier for [`audit_with_callbacks`] progress callbacks.
183///
184/// Numeric values match `fs_ext4_fsck_phase_t` in `include/fs_ext4.h`
185/// and **must not be reordered** — the C ABI is locked.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187#[repr(u32)]
188pub enum FsckPhase {
189 Superblock = 0,
190 Journal = 1,
191 Directory = 2,
192 Inodes = 3,
193 Finalize = 4,
194}
195
196impl FsckPhase {
197 /// Short ASCII label, mirrored to the C ABI.
198 pub fn name(self) -> &'static str {
199 match self {
200 FsckPhase::Superblock => "superblock",
201 FsckPhase::Journal => "journal",
202 FsckPhase::Directory => "directory",
203 FsckPhase::Inodes => "inodes",
204 FsckPhase::Finalize => "finalize",
205 }
206 }
207}
208
209/// Walk the filesystem from `/`, counting directory-entry references
210/// to each inode and comparing against each inode's `i_links_count`.
211///
212/// Capped by `max_dirs_visited` and `max_entries_per_dir` so a
213/// deliberately-cyclic or extremely large image can still be audited
214/// in bounded time. For a real fsck pass, set both to `u32::MAX`.
215pub fn audit(
216 fs: &Filesystem,
217 max_dirs_visited: u32,
218 max_entries_per_dir: u32,
219) -> Result<AuditReport> {
220 let mut report = AuditReport::default();
221 let mut collected: Vec<Anomaly> = Vec::new();
222 audit_inner(
223 fs,
224 max_dirs_visited,
225 max_entries_per_dir,
226 &mut |_, _, _| {},
227 &mut |a| collected.push(a.clone()),
228 &mut report,
229 )?;
230 report.anomalies = collected;
231 Ok(report)
232}
233
234/// Same walk as [`audit`], but emits progress and findings through
235/// caller-supplied closures. The callbacks see each [`Anomaly`] as it
236/// is discovered (no buffering of the full list) and per-phase
237/// progress so a host UI can render a live progress bar.
238///
239/// On return, `report.anomalies` is **empty** — findings are delivered
240/// only through `on_finding`. The summary counters
241/// (`directories_scanned`, `entries_scanned`, `inodes_visited`,
242/// `anomalies_found` … via the C ABI helpers) are still populated.
243///
244/// Phase emission contract:
245/// - `Superblock` once at start (0/1 → 1/1) — superblock validity
246/// was already checked at mount.
247/// - `Directory` per directory popped (`done` = directories scanned
248/// so far, `total` = scanned + queue depth).
249/// - `Inodes` once around the link-count comparison pass (0/1 → 1/1).
250/// - `Finalize` once just before return (0/1 → 1/1).
251///
252/// `Journal` is **not** emitted here — the FFI shim drives journal
253/// replay before calling this function and emits the phase from
254/// there.
255pub fn audit_with_callbacks<P, F>(
256 fs: &Filesystem,
257 max_dirs_visited: u32,
258 max_entries_per_dir: u32,
259 mut on_progress: P,
260 mut on_finding: F,
261) -> Result<AuditReport>
262where
263 P: FnMut(FsckPhase, u64, u64),
264 F: FnMut(&Anomaly),
265{
266 let mut report = AuditReport::default();
267 on_progress(FsckPhase::Superblock, 0, 1);
268 on_progress(FsckPhase::Superblock, 1, 1);
269
270 audit_inner(
271 fs,
272 max_dirs_visited,
273 max_entries_per_dir,
274 &mut on_progress,
275 &mut on_finding,
276 &mut report,
277 )?;
278
279 Ok(report)
280}
281
282/// Core walk shared by [`audit`] and [`audit_with_callbacks`].
283///
284/// Findings are emitted through `on_finding`; nothing is pushed onto
285/// `report.anomalies` from here. Callers that want the legacy
286/// "collect into a vec" behaviour wrap `on_finding` accordingly.
287fn audit_inner(
288 fs: &Filesystem,
289 max_dirs_visited: u32,
290 max_entries_per_dir: u32,
291 on_progress: &mut dyn FnMut(FsckPhase, u64, u64),
292 on_finding: &mut dyn FnMut(&Anomaly),
293 report: &mut AuditReport,
294) -> Result<()> {
295 // Observed: ino → reference-count.
296 let mut observed: HashMap<u32, u32> = HashMap::new();
297 // What each directory's ".." entry CLAIMS the parent is (read off
298 // disk). Compared post-walk against `actual_parent` to flag
299 // WrongDotDot.
300 let mut parent_claim: HashMap<u32, u32> = HashMap::new();
301 // The directory that ACTUALLY enqueued each child during the walk
302 // — this is the source of truth for "who is your parent?". Built
303 // up as we pop work items. Root maps to itself by convention so a
304 // corrupted root ".." still flags WrongDotDot.
305 let mut actual_parent: HashMap<u32, u32> = HashMap::new();
306 // Directories we couldn't fully walk (parse failure, inline overflow
307 // we don't decode, bound cap). Any link-count anomalies that could
308 // have been explained by their missing entries are suppressed below.
309 let mut incomplete_dirs: std::collections::HashSet<u32> = std::collections::HashSet::new();
310 // ino → list of (parent_ino, name_bytes) that reference it. Skips
311 // "." / ".." (those are self-references, not aliases). Kept as
312 // bytes so non-UTF-8 names (legal on ext4) round-trip; the
313 // `DuplicateDirentForDirInode` variant lossy-converts to String at
314 // emission time only, since the user-visible report can tolerate
315 // U+FFFD where the disk has a non-UTF-8 byte.
316 let mut dirent_index: HashMap<u32, Vec<(u32, Vec<u8>)>> = HashMap::new();
317
318 // (ino, parent_ino, dirent_name) — `dirent_name` is the name in
319 // `parent_ino` whose dirent points at `ino`. Carried so a
320 // `BogusEntry` finding can disambiguate when the parent has
321 // multiple hardlinks to the same inode. Empty for the root
322 // self-seed (root never triggers BogusEntry — its inode is
323 // always a directory).
324 let mut work: Vec<(u32, u32, Vec<u8>)> = Vec::new();
325 work.push((
326 crate::path::EXT4_ROOT_INODE,
327 crate::path::EXT4_ROOT_INODE,
328 Vec::new(),
329 ));
330 let mut visited: std::collections::HashSet<u32> = std::collections::HashSet::new();
331
332 let has_filetype = fs.sb.feature_incompat & features::Incompat::FILETYPE.bits() != 0;
333 let block_size = fs.sb.block_size();
334
335 // Initial directory progress pulse: 0 of (just root).
336 on_progress(FsckPhase::Directory, 0, work.len() as u64);
337
338 while let Some((dir_ino, parent_ino, dirent_name)) = work.pop() {
339 if report.directories_scanned >= max_dirs_visited {
340 incomplete_dirs.insert(dir_ino);
341 break;
342 }
343 if !visited.insert(dir_ino) {
344 continue;
345 }
346 // Record who really enqueued us. `parent_ino` is the directory
347 // we were popped under; for the root self-seed it's root
348 // itself. First-seen wins on the rare case a buggy filesystem
349 // has the same inode reachable from two different parents
350 // (the duplicate-dirent class is detected separately).
351 actual_parent.entry(dir_ino).or_insert(parent_ino);
352 report.directories_scanned += 1;
353
354 let (inode, _raw) = match fs.read_inode_verified(dir_ino) {
355 Ok(p) => p,
356 Err(_) => {
357 incomplete_dirs.insert(dir_ino);
358 emit_dir_progress(on_progress, report.directories_scanned, work.len());
359 continue;
360 }
361 };
362 if !inode.is_dir() {
363 // Parent claimed this child was a directory (file_type
364 // byte in the dirent) but the inode's mode bits disagree.
365 // Carry both inodes plus the dirent name so a repair pass
366 // can either rewrite the dirent's file_type byte (precise
367 // (parent, name) match avoids hardlink ambiguity) or
368 // unlink the dirent.
369 let a = Anomaly::BogusEntry {
370 parent_ino,
371 child_ino: dir_ino,
372 name: dirent_name.clone(),
373 };
374 on_finding(&a);
375 report.anomalies_count += 1;
376 emit_dir_progress(on_progress, report.directories_scanned, work.len());
377 continue;
378 }
379
380 // Skip directories the audit can't fully enumerate (inline dirs
381 // whose entries overflow into the xattr region — a valid
382 // on-disk layout we don't decode here).
383 if inode.has_inline_data() {
384 incomplete_dirs.insert(dir_ino);
385 emit_dir_progress(on_progress, report.directories_scanned, work.len());
386 continue;
387 }
388
389 let entries = match collect_dir_entries(fs, &inode, has_filetype, block_size) {
390 Ok(e) => e,
391 Err(_) => {
392 incomplete_dirs.insert(dir_ino);
393 emit_dir_progress(on_progress, report.directories_scanned, work.len());
394 continue;
395 }
396 };
397
398 let mut truncated = false;
399 for (n_scanned, entry) in (0u32..).zip(entries) {
400 if n_scanned >= max_entries_per_dir {
401 truncated = true;
402 break;
403 }
404 report.entries_scanned += 1;
405
406 if entry.name == b"." {
407 *observed.entry(dir_ino).or_insert(0) += 1;
408 continue;
409 }
410 if entry.name == b".." {
411 parent_claim.insert(dir_ino, entry.inode);
412 *observed.entry(entry.inode).or_insert(0) += 1;
413 continue;
414 }
415
416 *observed.entry(entry.inode).or_insert(0) += 1;
417 // Track every real (parent, name) edge so the post-walk
418 // pass can flag inodes referenced by more than one dirent.
419 dirent_index
420 .entry(entry.inode)
421 .or_default()
422 .push((dir_ino, entry.name.clone()));
423
424 if matches!(entry.file_type, DirEntryType::Directory) {
425 work.push((entry.inode, dir_ino, entry.name.clone()));
426 }
427 }
428 if truncated {
429 incomplete_dirs.insert(dir_ino);
430 }
431 emit_dir_progress(on_progress, report.directories_scanned, work.len());
432 }
433
434 report.inodes_visited = observed.len() as u32;
435
436 // Inode link-count compare phase.
437 let inodes_total = observed.len() as u64;
438 on_progress(FsckPhase::Inodes, 0, inodes_total.max(1));
439
440 // Compare observed vs stored. When an inode's reference came from a
441 // directory we couldn't fully enumerate, we suppress TooHigh (we
442 // under-counted) but still report TooLow (we already saw more than
443 // the stored value — the image is genuinely wrong).
444 let have_incomplete = !incomplete_dirs.is_empty();
445 let mut inodes_done: u64 = 0;
446 let mut last_tick = Instant::now();
447 let tick = Duration::from_millis(500);
448 for (&ino, &count) in observed.iter() {
449 match fs.read_inode_verified(ino) {
450 Ok((inode, _)) => {
451 let stored = inode.links_count;
452 if stored == 0 {
453 let a = Anomaly::DanglingEntry {
454 parent_ino: 0,
455 child_ino: ino,
456 observed: count,
457 };
458 on_finding(&a);
459 report.anomalies_count += 1;
460 continue;
461 }
462 if (stored as u32) < count {
463 let a = Anomaly::LinkCountTooLow {
464 ino,
465 stored,
466 observed: count,
467 };
468 on_finding(&a);
469 report.anomalies_count += 1;
470 }
471 if (stored as u32) > count && !have_incomplete {
472 let a = Anomaly::LinkCountTooHigh {
473 ino,
474 stored,
475 observed: count,
476 };
477 on_finding(&a);
478 report.anomalies_count += 1;
479 }
480 }
481 Err(_) => {
482 // Unreadable inode that somebody linked to. Surface
483 // observed = 0 as a sentinel meaning "rescue not safe"
484 // — repair_link_count refuses observed == 0 already,
485 // so the rescue path correctly leaves this case alone.
486 let a = Anomaly::DanglingEntry {
487 parent_ino: 0,
488 child_ino: ino,
489 observed: 0,
490 };
491 on_finding(&a);
492 report.anomalies_count += 1;
493 }
494 }
495 inodes_done += 1;
496 if last_tick.elapsed() >= tick {
497 on_progress(FsckPhase::Inodes, inodes_done, inodes_total.max(1));
498 last_tick = Instant::now();
499 }
500 }
501
502 // Surface inodes referenced by more than one dirent that are
503 // themselves directories. Multi-link is fine for files (POSIX
504 // hardlinks) but illegal for dirs; the canonical case here is the
505 // pre-fix `apply_mkdir` allocator bug which left N siblings all
506 // pointing at the same inode. Detection runs over the index built
507 // during the walk; emission order is sorted so repair has a
508 // deterministic "keep first" choice.
509 let mut dup_keys: Vec<u32> = dirent_index
510 .iter()
511 .filter_map(|(ino, refs)| if refs.len() > 1 { Some(*ino) } else { None })
512 .collect();
513 dup_keys.sort_unstable();
514 for ino in dup_keys {
515 // Only directories trip the alias rule. Files with multiple
516 // dirents are normal hardlinks and already covered by the
517 // link-count comparison above.
518 let is_dir = match fs.read_inode_verified(ino) {
519 Ok((inode, _)) => inode.is_dir(),
520 // Unreadable inodes were already reported as DanglingEntry;
521 // skip here rather than double-count.
522 Err(_) => continue,
523 };
524 if !is_dir {
525 continue;
526 }
527 let mut refs = dirent_index.get(&ino).cloned().unwrap_or_default();
528 refs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
529 let dirents: Vec<(u32, String)> = refs
530 .into_iter()
531 .map(|(p, n)| (p, String::from_utf8_lossy(&n).into_owned()))
532 .collect();
533 let a = Anomaly::DuplicateDirentForDirInode { ino, dirents };
534 on_finding(&a);
535 report.anomalies_count += 1;
536 }
537
538 // Check `..` claims against the actual enqueueing parent. Root
539 // is special-cased to compare against itself (ext4 convention:
540 // root's ".." points at root). For every other directory we
541 // compare against `actual_parent` — the directory that enqueued
542 // it during the walk. A directory we never reached has no
543 // entry in `actual_parent`; we skip those (any anomaly inside an
544 // unreachable subtree is invisible to the audit by definition).
545 for (&dir_ino, &claimed) in parent_claim.iter() {
546 let truth = if dir_ino == crate::path::EXT4_ROOT_INODE {
547 crate::path::EXT4_ROOT_INODE
548 } else {
549 match actual_parent.get(&dir_ino) {
550 Some(&p) => p,
551 None => continue,
552 }
553 };
554 if claimed != truth {
555 let a = Anomaly::WrongDotDot {
556 dir_ino,
557 claims: claimed,
558 actual_parent: truth,
559 };
560 on_finding(&a);
561 report.anomalies_count += 1;
562 }
563 }
564
565 on_progress(FsckPhase::Inodes, inodes_total.max(1), inodes_total.max(1));
566
567 // Free-count drift scan. Walks every group's block + inode bitmaps,
568 // counts the free bits, compares against the descriptors and the
569 // superblock. Emits per-group + per-superblock drift findings as
570 // it goes. Folded under the existing Inodes phase rather than its
571 // own phase so the C ABI's locked phase enum doesn't need
572 // extending.
573 audit_free_counts(fs, on_finding, report)?;
574
575 on_progress(FsckPhase::Finalize, 0, 1);
576 on_progress(FsckPhase::Finalize, 1, 1);
577
578 Ok(())
579}
580
581/// Walk every block group's bitmaps, count free bits, and emit
582/// drift findings against the on-disk descriptors and superblock.
583/// Detection only — repair is wired through `audit_with_repair`.
584///
585/// The bits past a group's actual block/inode range are reserved as
586/// "always allocated" (set to 1) on a healthy ext4 image; we only
587/// count zero bits within `[0, group_size_bits)` to mirror that
588/// convention so a partial last group doesn't get double-counted.
589fn audit_free_counts(
590 fs: &Filesystem,
591 on_finding: &mut dyn FnMut(&Anomaly),
592 report: &mut AuditReport,
593) -> Result<()> {
594 let bpg = fs.sb.blocks_per_group as u64;
595 let ipg = fs.sb.inodes_per_group as u64;
596 let total_blocks = fs.sb.blocks_count;
597 let first_data = fs.sb.first_data_block as u64;
598
599 // Re-read BGDs and SB from disk for each scan rather than
600 // trusting `fs.groups` / `fs.sb` (mount-time snapshots, never
601 // mutated). The post-repair re-scan needs the patched values to
602 // avoid re-emitting drift findings the repair pass just fixed:
603 // bitmaps come from `fs.read_block` (sees pinned post-commit
604 // bytes), so the comparison's "stored" side has to match — read
605 // the descriptors and SB the same way for both halves of the
606 // compare to look at the same point in time.
607 let live_groups = bgd::read_all(fs.dev.as_ref(), &fs.sb, &fs.csum)?;
608 let live_sb = Superblock::read(fs.dev.as_ref())?;
609
610 let mut sum_free_blocks: u64 = 0;
611 let mut sum_free_inodes: u64 = 0;
612
613 for (gi, bg) in live_groups.iter().enumerate() {
614 // Block bitmap: count zero bits across the bytes covering the
615 // bits that actually correspond to this group's blocks. The
616 // last group may be partial. Go through `read_block` so the
617 // post-repair re-scan sees post-commit-pre-checkpoint pinned
618 // bytes — `dev.read_at` would skip the cache and surface the
619 // stale on-disk image, falsely re-emitting drift findings the
620 // repair pass just fixed.
621 let group_first_block = first_data + gi as u64 * bpg;
622 let group_block_count = std::cmp::min(bpg, total_blocks.saturating_sub(group_first_block));
623 let block_bitmap = fs.read_block(bg.block_bitmap)?;
624 let observed_blocks = count_zero_bits_le(&block_bitmap, group_block_count as u32);
625
626 // Inode bitmap: every group has exactly inodes_per_group
627 // inodes (the ext4 layout doesn't leave a partial last group
628 // for inodes; the trailing bits are reserved-as-1).
629 let inode_bitmap = fs.read_block(bg.inode_bitmap)?;
630 let observed_inodes = count_zero_bits_le(&inode_bitmap, ipg as u32);
631
632 sum_free_blocks += observed_blocks as u64;
633 sum_free_inodes += observed_inodes as u64;
634
635 if observed_blocks != bg.free_blocks_count || observed_inodes != bg.free_inodes_count {
636 let a = Anomaly::BlockGroupFreeCountDrift {
637 group_index: gi as u32,
638 stored_blocks: bg.free_blocks_count,
639 observed_blocks,
640 stored_inodes: bg.free_inodes_count,
641 observed_inodes,
642 };
643 on_finding(&a);
644 report.anomalies_count += 1;
645 }
646 }
647
648 // SB totals. Compare against the BITMAP-derived sum, not the
649 // descriptor-derived sum — a torn SB write can leave the SB
650 // disagreeing with descriptors that themselves agree with the
651 // bitmaps. We want the truth.
652 if sum_free_blocks != live_sb.free_blocks_count
653 || (sum_free_inodes as u32) != live_sb.free_inodes_count
654 {
655 let a = Anomaly::SuperblockFreeCountDrift {
656 stored_blocks: live_sb.free_blocks_count,
657 observed_blocks: sum_free_blocks,
658 stored_inodes: live_sb.free_inodes_count,
659 observed_inodes: sum_free_inodes as u32,
660 };
661 on_finding(&a);
662 report.anomalies_count += 1;
663 }
664 Ok(())
665}
666
667/// Count zero (= free) bits inside the first `total_bits` bits of a
668/// little-endian bitmap. Bits beyond `total_bits` are treated as
669/// "reserved/allocated" and are NOT counted, even when the bitmap
670/// happens to have them at zero — matches the ext4 convention for
671/// trailing reserved bits in a partial group.
672fn count_zero_bits_le(buf: &[u8], total_bits: u32) -> u32 {
673 let full_bytes = (total_bits / 8) as usize;
674 let mut free: u32 = 0;
675 for i in 0..full_bytes {
676 if i >= buf.len() {
677 break;
678 }
679 free += buf[i].count_zeros();
680 }
681 let leftover_bits = total_bits % 8;
682 if leftover_bits > 0 && full_bytes < buf.len() {
683 let last = buf[full_bytes];
684 let mask = (1u8 << leftover_bits) - 1;
685 let ones_in_used_bits = (last & mask).count_ones();
686 free += leftover_bits - ones_in_used_bits;
687 }
688 free
689}
690
691fn emit_dir_progress(
692 on_progress: &mut dyn FnMut(FsckPhase, u64, u64),
693 scanned: u32,
694 queue_len: usize,
695) {
696 let done = scanned as u64;
697 let total = done + queue_len as u64;
698 on_progress(FsckPhase::Directory, done, total);
699}
700
701fn collect_dir_entries(
702 fs: &Filesystem,
703 inode: &Inode,
704 has_filetype: bool,
705 block_size: u32,
706) -> Result<Vec<crate::dir::DirEntry>> {
707 let mut entries = Vec::new();
708 if inode.has_inline_data() {
709 for entry in DirBlockIter::new(&inode.block, has_filetype) {
710 entries.push(entry?);
711 }
712 return Ok(entries);
713 }
714 if !inode.has_extents() {
715 return Err(Error::Corrupt(
716 "legacy non-extent dirs not supported by audit",
717 ));
718 }
719 let total_blocks = inode.size.div_ceil(block_size as u64);
720 let mut buf = vec![0u8; block_size as usize];
721 for logical in 0..total_blocks {
722 let Some(phys) = extent::map_logical(&inode.block, fs.dev.as_ref(), block_size, logical)?
723 else {
724 continue;
725 };
726 let offset = phys
727 .checked_mul(block_size as u64)
728 .ok_or(Error::Corrupt("audit: dir block offset overflow"))?;
729 fs.dev.read_at(offset, &mut buf)?;
730 for entry in DirBlockIter::new(&buf, has_filetype) {
731 // Ignore parse errors on dx_root first block of indexed dirs
732 match entry {
733 Ok(e) => entries.push(e),
734 Err(_) if logical == 0 => continue,
735 Err(e) => return Err(e),
736 }
737 }
738 }
739 Ok(entries)
740}
741
742/// Audit, then optionally repair the subset of anomalies the repair
743/// pass knows how to fix. When `repair == false` this is the read+write
744/// equivalent of [`audit_with_callbacks`] — same findings, no disk
745/// mutation. When `repair == true`, after the read pass completes the
746/// function walks `report.anomalies` and commits a fix per repairable
747/// finding through the journal writer.
748///
749/// Repairable today:
750/// - [`Anomaly::DuplicateDirentForDirInode`]: keeps `dirents[0]`, removes
751/// the rest from their respective parent directories, then recomputes
752/// the surviving directory's `i_links_count` from a fresh count of its
753/// subdirectories.
754/// - [`Anomaly::LinkCountTooLow`] / [`Anomaly::LinkCountTooHigh`]: writes
755/// the observed count back into `i_links_count`.
756///
757/// Each repair commit is its own [`BlockBuffer`] transaction. Crash
758/// mid-pass: the surviving on-disk state is the union of fixes that
759/// committed up to that point; subsequent fsck runs continue from
760/// there. `report.repaired_count` reflects how many fixes actually
761/// landed (0 if `repair == false`).
762///
763/// The `findings` collected via `on_finding` and the `report.anomalies`
764/// vec follow the same population contract as [`audit`] /
765/// [`audit_with_callbacks`]: whichever caller wires up the closure
766/// gets the streaming events; the returned report counts are always
767/// authoritative.
768pub fn audit_with_repair<P, F>(
769 fs: &Filesystem,
770 max_dirs_visited: u32,
771 max_entries_per_dir: u32,
772 mut on_progress: P,
773 mut on_finding: F,
774 repair: bool,
775) -> Result<AuditReport>
776where
777 P: FnMut(FsckPhase, u64, u64),
778 F: FnMut(&Anomaly),
779{
780 // Refuse repair on read-only mounts before any scanning. The full
781 // walk is expensive, and a refused repair shouldn't look like a
782 // partially-successful audit to the caller.
783 if repair && !fs.dev.is_writable() {
784 return Err(Error::ReadOnly);
785 }
786
787 let mut report = AuditReport::default();
788 on_progress(FsckPhase::Superblock, 0, 1);
789 on_progress(FsckPhase::Superblock, 1, 1);
790
791 // Buffer findings locally so the repair pass can iterate them
792 // without re-walking. The caller's closure still sees each finding
793 // streamed as it's discovered — we tee through `on_finding`.
794 let mut collected: Vec<Anomaly> = Vec::new();
795 audit_inner(
796 fs,
797 max_dirs_visited,
798 max_entries_per_dir,
799 &mut on_progress,
800 &mut |a| {
801 on_finding(a);
802 collected.push(a.clone());
803 },
804 &mut report,
805 )?;
806
807 // Snapshot the pre-repair count before anything mutates state.
808 // Even non-repair runs get this for symmetry — both fields will
809 // hold the same number on a `repair = false` call.
810 report.initial_anomalies_count = report.anomalies_count;
811
812 if !repair {
813 report.anomalies = collected;
814 return Ok(report);
815 }
816
817 // For directories that ALSO appear in a DuplicateDirentForDirInode
818 // finding, the captured `actual_parent` is the parent the walker
819 // saw first — which may not be the one that survives dedup
820 // (`repair_duplicate_dir_inode` keeps `dirents[0]`). Override the
821 // WrongDotDot target with the post-dedup surviving parent so we
822 // don't rewrite `..` to point at the alias we just removed.
823 let surviving_parent_after_dedup: HashMap<u32, u32> = collected
824 .iter()
825 .filter_map(|a| match a {
826 Anomaly::DuplicateDirentForDirInode { ino, dirents } if !dirents.is_empty() => {
827 Some((*ino, dirents[0].0))
828 }
829 _ => None,
830 })
831 .collect();
832
833 for finding in &collected {
834 match finding {
835 Anomaly::DuplicateDirentForDirInode { ino, dirents } => {
836 repair_duplicate_dir_inode(fs, *ino, dirents, &mut report)?;
837 }
838 Anomaly::LinkCountTooLow {
839 ino,
840 stored: _,
841 observed,
842 }
843 | Anomaly::LinkCountTooHigh {
844 ino,
845 stored: _,
846 observed,
847 } => {
848 repair_link_count(fs, *ino, *observed, &mut report)?;
849 }
850 Anomaly::WrongDotDot {
851 dir_ino,
852 claims: _,
853 actual_parent,
854 } => {
855 let target_parent = surviving_parent_after_dedup
856 .get(dir_ino)
857 .copied()
858 .unwrap_or(*actual_parent);
859 repair_wrong_dotdot(fs, *dir_ino, target_parent, &mut report)?;
860 }
861 Anomaly::BogusEntry {
862 parent_ino,
863 child_ino,
864 name,
865 } => {
866 repair_bogus_entry(fs, *parent_ino, *child_ino, name, &mut report)?;
867 }
868 Anomaly::DanglingEntry {
869 parent_ino: _,
870 child_ino,
871 observed,
872 } => {
873 // Rescue: write observed into the inode's links_count
874 // when the inode is readable. The unreadable case
875 // arrives with observed = 0 and repair_link_count
876 // refuses that, leaving the anomaly for a future
877 // orphan-list / lost+found path.
878 repair_link_count(fs, *child_ino, *observed, &mut report)?;
879 }
880 Anomaly::BlockGroupFreeCountDrift {
881 group_index,
882 stored_blocks,
883 observed_blocks,
884 stored_inodes,
885 observed_inodes,
886 } => {
887 repair_block_group_free_counts(
888 fs,
889 *group_index,
890 *stored_blocks,
891 *observed_blocks,
892 *stored_inodes,
893 *observed_inodes,
894 &mut report,
895 )?;
896 }
897 Anomaly::SuperblockFreeCountDrift {
898 stored_blocks,
899 observed_blocks,
900 stored_inodes,
901 observed_inodes,
902 } => {
903 repair_superblock_free_counts(
904 fs,
905 *stored_blocks,
906 *observed_blocks,
907 *stored_inodes,
908 *observed_inodes,
909 &mut report,
910 )?;
911 }
912 }
913 }
914
915 // Post-repair re-scan. Walking the tree again is expensive, but
916 // it's the only way to give the caller a TRUTHFUL "what's still
917 // wrong" count. If our repair logic accidentally introduced new
918 // anomalies (or didn't actually fix the ones we thought we fixed),
919 // this re-scan surfaces it as a count mismatch:
920 // expected_remaining = initial_anomalies_count - repaired_count
921 // actual_remaining = anomalies_count (from this re-scan)
922 // The caller's `on_progress` is reused so the host UI keeps
923 // rendering progress for the second walk. `on_finding` is a
924 // no-op closure here because the pre-repair stream already gave
925 // the caller the per-finding detail and re-emitting would
926 // double-count in the UI — but we DO buffer remaining findings
927 // locally so `report.anomalies` reflects the post-repair truth
928 // (consistent with `report.anomalies_count`).
929 let mut post_report = AuditReport::default();
930 let mut remaining: Vec<Anomaly> = Vec::new();
931 audit_inner(
932 fs,
933 max_dirs_visited,
934 max_entries_per_dir,
935 &mut on_progress,
936 &mut |a| {
937 remaining.push(a.clone());
938 },
939 &mut post_report,
940 )?;
941
942 // Replace the live count + anomalies list with the post-repair
943 // numbers. Keep `initial_anomalies_count` unchanged so the
944 // caller can see the before-vs-after delta.
945 report.anomalies = remaining;
946 report.anomalies_count = post_report.anomalies_count;
947
948 Ok(report)
949}
950
951/// Repair a `DuplicateDirentForDirInode` finding.
952///
953/// `dirents` is sorted (parent_ino asc, name asc). We keep
954/// `dirents[0]` as the canonical edge and remove every entry in
955/// `dirents[1..]` from its parent block. Each removal is a separate
956/// journal commit so a crash mid-loop leaves a deterministic
957/// partial-fix state (some duplicates gone, the rest still pending —
958/// fsck on next mount finishes the job).
959///
960/// After the duplicates are gone we recompute the kept directory's
961/// `i_links_count` from a fresh subdir count: ext4 link count for a
962/// dir is `2 + (number of child subdirectories)` (2 = self via "." +
963/// parent's dirent). Stale link counts caused by the multi-parent
964/// state are corrected here so a subsequent audit returns clean.
965fn repair_duplicate_dir_inode(
966 fs: &Filesystem,
967 ino: u32,
968 dirents: &[(u32, String)],
969 report: &mut AuditReport,
970) -> Result<()> {
971 if dirents.len() < 2 {
972 // Defensive — detection only emits this variant when len > 1.
973 return Ok(());
974 }
975
976 let has_ft = fs.sb.feature_incompat & features::Incompat::FILETYPE.bits() != 0;
977 let bs = fs.sb.block_size();
978
979 // Drop every duplicate edge except the first. Each iteration reads
980 // the parent's dir blocks fresh so a previous removal in the same
981 // parent (rare — needs two duplicates with the same parent) is
982 // visible. `repaired_count` advances once per finding, not once
983 // per alias removed — the caller's
984 // `initial_anomalies_count - repaired_count` reconciliation
985 // counts findings, so a 3-alias finding still represents one
986 // repaired anomaly.
987 let mut any_removed = false;
988 for (parent_ino, name) in dirents.iter().skip(1) {
989 let (parent_inode, _parent_raw) = fs.read_inode_verified(*parent_ino)?;
990 if !parent_inode.is_dir() {
991 // The parent itself isn't a directory anymore — bail on
992 // this duplicate and let a later pass clean up.
993 continue;
994 }
995 let mut buf = BlockBuffer::new(bs);
996 let parent_blocks = parent_inode.size.div_ceil(bs as u64);
997 let mut removed = false;
998 for logical in 0..parent_blocks {
999 let Some(phys) = fs.map_inode_logical(&parent_inode, logical)? else {
1000 continue;
1001 };
1002 let block = buf.get_mut(fs, phys)?;
1003 // dir_entry_tail occupies the last 12 bytes when
1004 // metadata_csum is on. Mirror apply_unlink's reservation
1005 // so removal doesn't scribble the tail.
1006 let reserved_tail = if fs.csum.enabled && dir::has_csum_tail(block) {
1007 12
1008 } else {
1009 0
1010 };
1011 if dir::remove_entry_from_block(block, name.as_bytes(), has_ft, reserved_tail)? {
1012 if fs.csum.enabled && reserved_tail == 12 {
1013 fs.csum
1014 .patch_dir_entry_tail(*parent_ino, parent_inode.generation, block);
1015 }
1016 removed = true;
1017 break;
1018 }
1019 }
1020 if !removed {
1021 // Detection saw the dirent but the on-disk parent doesn't
1022 // contain it now — racy concurrent mutation, or audit was
1023 // run with a partial cache. Skip rather than fail the
1024 // whole pass; the next audit will resurface or clear it.
1025 continue;
1026 }
1027 fs.commit_block_buffer(buf)?;
1028 any_removed = true;
1029 }
1030 if any_removed {
1031 report.repaired_count += 1;
1032 }
1033
1034 // Recompute i_links_count for the surviving directory. Walking
1035 // children counts only proper subdirectories (not "." / "..") —
1036 // the canonical formula for a directory's nlink in ext4.
1037 let (kept_inode, mut kept_raw) = fs.read_inode_verified(ino)?;
1038 if !kept_inode.is_dir() {
1039 return Ok(());
1040 }
1041 let subdir_count = count_subdirs(fs, &kept_inode, has_ft, bs)?;
1042 let new_nlink: u16 = 2u16.saturating_add(subdir_count.min(u16::MAX as u32 - 2) as u16);
1043 kept_raw[0x1A..0x1C].copy_from_slice(&new_nlink.to_le_bytes());
1044 finalize_and_commit_inode(fs, ino, kept_inode.generation, &mut kept_raw)?;
1045 Ok(())
1046}
1047
1048/// Walk `dir_inode`'s data blocks and count entries whose file_type is
1049/// Directory, excluding "." and "..". Used by repair to recompute
1050/// `i_links_count` from scratch after removing duplicate dirents.
1051fn count_subdirs(
1052 fs: &Filesystem,
1053 dir_inode: &Inode,
1054 has_filetype: bool,
1055 block_size: u32,
1056) -> Result<u32> {
1057 let entries = collect_dir_entries(fs, dir_inode, has_filetype, block_size)?;
1058 let mut n = 0u32;
1059 for e in entries {
1060 if e.name == b"." || e.name == b".." {
1061 continue;
1062 }
1063 // Don't trust the dirent's `file_type` byte — that's exactly
1064 // what `BogusEntry` exists to detect. Verify the child
1065 // inode's mode bits before counting. Unreadable children and
1066 // mode mismatches are silently skipped: repair_duplicate_dir
1067 // _inode just needs the truthful current count, and a
1068 // BogusEntry repair (if one runs in the same pass) will fix
1069 // the dirent byte separately.
1070 if matches!(e.file_type, DirEntryType::Directory) {
1071 if let Ok((child_inode, _)) = fs.read_inode_verified(e.inode) {
1072 if child_inode.is_dir() {
1073 n = n.saturating_add(1);
1074 }
1075 }
1076 }
1077 }
1078 Ok(n)
1079}
1080
1081/// Repair a link-count mismatch by writing `observed` into
1082/// `i_links_count`. Stays narrow on purpose — anything that needs more
1083/// surgery (e.g. observed == 0 should trigger the dead-inode reaping
1084/// path, not a 0 link count) is left as a TODO and the audit still
1085/// reports the underlying anomaly.
1086fn repair_link_count(
1087 fs: &Filesystem,
1088 ino: u32,
1089 observed: u32,
1090 report: &mut AuditReport,
1091) -> Result<()> {
1092 // Safety net: don't write 0 into i_links_count. A 0 nlink is the
1093 // contract for "this inode is unreachable, reaper will dispose of
1094 // it" — the right fix in that case is unlink+free, not a count
1095 // patch. Leave the anomaly as-is and let a future repair pass
1096 // (with orphan-relink wired up) handle it.
1097 if observed == 0 || observed > u16::MAX as u32 {
1098 // TODO: hook into orphan recovery for observed==0; for now,
1099 // surface the mismatch unchanged.
1100 return Ok(());
1101 }
1102 let (inode, mut raw) = fs.read_inode_verified(ino)?;
1103 raw[0x1A..0x1C].copy_from_slice(&(observed as u16).to_le_bytes());
1104 finalize_and_commit_inode(fs, ino, inode.generation, &mut raw)?;
1105 report.repaired_count += 1;
1106 Ok(())
1107}
1108
1109/// Repair a `WrongDotDot` finding by rewriting the directory's ".."
1110/// dirent to point at `actual_parent`.
1111///
1112/// In ext4, a non-empty directory's ".." entry always lives in the
1113/// first data block (logical block 0) — the kernel writes it there at
1114/// directory creation time, immediately after the "." entry, and
1115/// nothing ever moves it. So we read just block 0, find the entry
1116/// with name "..", overwrite its 4-byte inode field, recompute the
1117/// per-block CRC tail (when metadata_csum is on), and commit through
1118/// the journal.
1119///
1120/// Out-of-scope cases that bail without bumping `repaired_count`:
1121/// - `dir_ino` is no longer a directory (raced delete during audit).
1122/// - Block 0 is unallocated (empty directory — ".." can't exist
1123/// without "."; treat as nothing-to-fix).
1124/// - The ".." entry isn't found in block 0 (corruption broader than
1125/// what this repair handles).
1126fn repair_wrong_dotdot(
1127 fs: &Filesystem,
1128 dir_ino: u32,
1129 actual_parent: u32,
1130 report: &mut AuditReport,
1131) -> Result<()> {
1132 let (dir_inode, _raw) = fs.read_inode_verified(dir_ino)?;
1133 if !dir_inode.is_dir() {
1134 return Ok(());
1135 }
1136 let bs = fs.sb.block_size();
1137 let Some(phys) = fs.map_inode_logical(&dir_inode, 0)? else {
1138 return Ok(());
1139 };
1140 let mut buf = BlockBuffer::new(bs);
1141 let block = buf.get_mut(fs, phys)?;
1142 let reserved_tail = if fs.csum.enabled && dir::has_csum_tail(block) {
1143 12
1144 } else {
1145 0
1146 };
1147 let usable_end = block.len().saturating_sub(reserved_tail);
1148
1149 // Walk the dirent records in block 0. Same shape as the audit
1150 // walker uses, just inline so we can mutate in place.
1151 let mut off = 0usize;
1152 let mut found = false;
1153 while off + 8 <= usable_end {
1154 let rec_len = u16::from_le_bytes([block[off + 4], block[off + 5]]) as usize;
1155 if rec_len == 0 || off + rec_len > usable_end {
1156 break;
1157 }
1158 let name_len = block[off + 6] as usize;
1159 let name_start = off + 8;
1160 let name_end = name_start + name_len;
1161 if name_end <= off + rec_len && &block[name_start..name_end] == b".." {
1162 block[off..off + 4].copy_from_slice(&actual_parent.to_le_bytes());
1163 found = true;
1164 break;
1165 }
1166 off += rec_len;
1167 }
1168 if !found {
1169 return Ok(());
1170 }
1171
1172 // Recompute the dir block CRC if metadata_csum reserved a tail.
1173 // Same recipe as repair_duplicate_dir_inode — see comments there.
1174 if fs.csum.enabled && reserved_tail == 12 {
1175 fs.csum
1176 .patch_dir_entry_tail(dir_ino, dir_inode.generation, block);
1177 }
1178
1179 fs.commit_block_buffer(buf)?;
1180 report.repaired_count += 1;
1181 Ok(())
1182}
1183
1184/// Repair a `BogusEntry` finding by rewriting the parent dirent's
1185/// `file_type` byte to match the child inode's actual mode bits.
1186///
1187/// The audit emits this when a parent's dirent claims its child is a
1188/// directory (`file_type == 2`) but the child's inode mode bits say
1189/// otherwise. The on-disk fix in the common case is one byte: change
1190/// the dirent's `file_type` to whatever the child actually is (regular
1191/// file, symlink, etc.). Same dir-block CRC recompute pattern as
1192/// `repair_wrong_dotdot`.
1193///
1194/// Out-of-scope cases that bail without bumping `repaired_count`
1195/// (audit will resurface on next pass):
1196/// - The FILETYPE incompat feature is off (the byte at offset 7 is
1197/// the high half of `name_len` rather than `file_type`; rewriting
1198/// would be silent corruption).
1199/// - The parent isn't a directory anymore (raced with rmdir).
1200/// - The child reads back AS a directory (raced with the audit; not
1201/// bogus anymore).
1202/// - The child is unreadable (proper handling needs unlink + orphan
1203/// accounting; left for a follow-up).
1204/// - The child's mode bits are zero or otherwise nonsensical.
1205fn repair_bogus_entry(
1206 fs: &Filesystem,
1207 parent_ino: u32,
1208 child_ino: u32,
1209 name: &[u8],
1210 report: &mut AuditReport,
1211) -> Result<()> {
1212 let has_ft = fs.sb.feature_incompat & features::Incompat::FILETYPE.bits() != 0;
1213 if !has_ft {
1214 return Ok(());
1215 }
1216 if name.is_empty() {
1217 // Defensive: detection always populates `name` for non-root
1218 // BogusEntry findings. An empty name would mean "first match
1219 // by inode," which is exactly the hardlink-ambiguity bug we
1220 // moved away from. Bail rather than risk patching the wrong
1221 // dirent.
1222 return Ok(());
1223 }
1224
1225 let (parent_inode, _parent_raw) = fs.read_inode_verified(parent_ino)?;
1226 if !parent_inode.is_dir() {
1227 return Ok(());
1228 }
1229
1230 // Read the child to determine its actual file_type. If it's
1231 // genuinely unreadable or genuinely a directory, bail.
1232 let child_filetype: DirEntryType = match fs.read_inode_verified(child_ino) {
1233 Ok((child_inode, _)) => {
1234 let mode_bits = child_inode.mode & crate::inode::S_IFMT;
1235 match mode_bits {
1236 crate::inode::S_IFREG => DirEntryType::RegFile,
1237 crate::inode::S_IFDIR => return Ok(()),
1238 crate::inode::S_IFLNK => DirEntryType::Symlink,
1239 crate::inode::S_IFBLK => DirEntryType::BlockDev,
1240 crate::inode::S_IFCHR => DirEntryType::CharDev,
1241 crate::inode::S_IFIFO => DirEntryType::Fifo,
1242 crate::inode::S_IFSOCK => DirEntryType::Socket,
1243 _ => return Ok(()),
1244 }
1245 }
1246 Err(_) => return Ok(()),
1247 };
1248
1249 // Walk parent dir blocks to find the dirent matching BOTH
1250 // (inode == child_ino) AND (name == this finding's name). The
1251 // (parent, name) pair is the unique key for a dirent — matching
1252 // by inode alone misfires when the parent has multiple hardlinks
1253 // to the same inode.
1254 let bs = fs.sb.block_size();
1255 let parent_blocks = parent_inode.size.div_ceil(bs as u64);
1256 let mut buf = BlockBuffer::new(bs);
1257 let mut found = false;
1258 for logical in 0..parent_blocks {
1259 let Some(phys) = fs.map_inode_logical(&parent_inode, logical)? else {
1260 continue;
1261 };
1262 let block = buf.get_mut(fs, phys)?;
1263 let reserved_tail = if fs.csum.enabled && dir::has_csum_tail(block) {
1264 12
1265 } else {
1266 0
1267 };
1268 let usable_end = block.len().saturating_sub(reserved_tail);
1269 let mut off = 0usize;
1270 let mut hit_off: Option<usize> = None;
1271 while off + 8 <= usable_end {
1272 let cur_inode =
1273 u32::from_le_bytes([block[off], block[off + 1], block[off + 2], block[off + 3]]);
1274 let rec_len = u16::from_le_bytes([block[off + 4], block[off + 5]]) as usize;
1275 if rec_len == 0 || off + rec_len > usable_end {
1276 break;
1277 }
1278 // FILETYPE feature is on (we bailed otherwise above), so
1279 // byte 6 is the full name_len; byte 7 is file_type.
1280 // Bound name comparison to the current record (8 +
1281 // name_len <= rec_len) so a malformed dirent with
1282 // name_len > rec_len can't read into the next record and
1283 // patch the wrong file_type byte.
1284 let name_len = block[off + 6] as usize;
1285 if cur_inode == child_ino
1286 && 8 + name_len <= rec_len
1287 && off + 8 + name_len <= usable_end
1288 && &block[off + 8..off + 8 + name_len] == name
1289 {
1290 hit_off = Some(off);
1291 break;
1292 }
1293 off += rec_len;
1294 }
1295 if let Some(off) = hit_off {
1296 block[off + 7] = child_filetype as u8;
1297 if fs.csum.enabled && reserved_tail == 12 {
1298 fs.csum
1299 .patch_dir_entry_tail(parent_ino, parent_inode.generation, block);
1300 }
1301 found = true;
1302 break;
1303 }
1304 }
1305
1306 if found {
1307 fs.commit_block_buffer(buf)?;
1308 report.repaired_count += 1;
1309 }
1310 Ok(())
1311}
1312
1313/// Repair a `BlockGroupFreeCountDrift` finding by patching the
1314/// descriptor's free-block / free-inode counters to match the bitmap
1315/// reality. Reuses `Filesystem::patch_bgd_counters` (the same path the
1316/// allocator already uses for live counter updates), which handles
1317/// the lo+hi 64-bit fields and recomputes the GD checksum when
1318/// `metadata_csum` is on.
1319fn repair_block_group_free_counts(
1320 fs: &Filesystem,
1321 group_index: u32,
1322 stored_blocks: u32,
1323 observed_blocks: u32,
1324 stored_inodes: u32,
1325 observed_inodes: u32,
1326 report: &mut AuditReport,
1327) -> Result<()> {
1328 let block_delta = (observed_blocks as i64) - (stored_blocks as i64);
1329 let inode_delta = (observed_inodes as i64) - (stored_inodes as i64);
1330 if block_delta == 0 && inode_delta == 0 {
1331 // Nothing to do; raced with another writer or audit was wrong.
1332 return Ok(());
1333 }
1334 if block_delta < i32::MIN as i64
1335 || block_delta > i32::MAX as i64
1336 || inode_delta < i32::MIN as i64
1337 || inode_delta > i32::MAX as i64
1338 {
1339 // Drift larger than i32 in a single group is implausible (a
1340 // group's blocks_per_group is bounded by 8 * block_size, which
1341 // tops out around 32k for 4 KiB blocks). Bail rather than
1342 // truncate.
1343 return Ok(());
1344 }
1345 fs.patch_bgd_counters(
1346 group_index as usize,
1347 block_delta as i32,
1348 inode_delta as i32,
1349 0,
1350 )?;
1351 report.repaired_count += 1;
1352 Ok(())
1353}
1354
1355/// Repair a `SuperblockFreeCountDrift` finding by patching the
1356/// superblock totals. Reuses `Filesystem::patch_sb_counters` so the SB
1357/// checksum is recomputed on metadata_csum images.
1358fn repair_superblock_free_counts(
1359 fs: &Filesystem,
1360 stored_blocks: u64,
1361 observed_blocks: u64,
1362 stored_inodes: u32,
1363 observed_inodes: u32,
1364 report: &mut AuditReport,
1365) -> Result<()> {
1366 let block_delta = (observed_blocks as i64) - (stored_blocks as i64);
1367 let inode_delta = (observed_inodes as i64) - (stored_inodes as i64);
1368 if block_delta == 0 && inode_delta == 0 {
1369 return Ok(());
1370 }
1371 if inode_delta < i32::MIN as i64 || inode_delta > i32::MAX as i64 {
1372 return Ok(());
1373 }
1374 fs.patch_sb_counters(block_delta, inode_delta as i32)?;
1375 report.repaired_count += 1;
1376 Ok(())
1377}
1378
1379/// Recompute the inode's CRC32C (when enabled) and commit the inode
1380/// back through the journal writer. Inode-only mutations get a single
1381/// journal txn — matches how `commit_inode_write` does chmod / chown.
1382fn finalize_and_commit_inode(
1383 fs: &Filesystem,
1384 ino: u32,
1385 generation: u32,
1386 raw: &mut [u8],
1387) -> Result<()> {
1388 if fs.csum.enabled {
1389 if let Some((lo, hi)) = fs.csum.compute_inode_checksum(ino, generation, raw) {
1390 raw[0x7C..0x7E].copy_from_slice(&lo.to_le_bytes());
1391 if raw.len() >= 0x84 {
1392 raw[0x82..0x84].copy_from_slice(&hi.to_le_bytes());
1393 }
1394 }
1395 }
1396 let mut buf = BlockBuffer::new(fs.sb.block_size());
1397 fs.buffer_write_inode(&mut buf, ino, raw)?;
1398 fs.commit_block_buffer(buf)
1399}
1400
1401impl Filesystem {
1402 /// Run an ext4 audit tool-style read-only audit.
1403 ///
1404 /// Walks from root, counts how many directory entries reference
1405 /// each inode, and compares that against each inode's
1406 /// `i_links_count`. Returns an [`AuditReport`] — empty
1407 /// `anomalies` means every invariant we check held.
1408 ///
1409 /// The pass is bounded: never visits more than
1410 /// `max_dirs_visited` directories and never scans more than
1411 /// `max_entries_per_dir` entries within a single directory.
1412 /// Pass `u32::MAX` for an unbounded pass.
1413 pub fn audit(&self, max_dirs_visited: u32, max_entries_per_dir: u32) -> Result<AuditReport> {
1414 audit(self, max_dirs_visited, max_entries_per_dir)
1415 }
1416
1417 /// Audit + repair convenience wrapper. See [`audit_with_repair`]
1418 /// for semantics. No-op on read-only mounts when `repair == true`
1419 /// (returns `Error::ReadOnly`).
1420 pub fn audit_repair(
1421 &self,
1422 max_dirs_visited: u32,
1423 max_entries_per_dir: u32,
1424 repair: bool,
1425 ) -> Result<AuditReport> {
1426 audit_with_repair(
1427 self,
1428 max_dirs_visited,
1429 max_entries_per_dir,
1430 |_, _, _| {},
1431 |_| {},
1432 repair,
1433 )
1434 }
1435}