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::features;
32use crate::fs::{BlockBuffer, Filesystem};
33use crate::inode::Inode;
34use crate::runtime::Stopwatch;
35use crate::superblock::Superblock;
36use std::collections::HashMap;
37use std::time::Duration;
38
39/// One problem found by [`audit`]. Each variant carries the inode or
40/// path needed to act on the finding.
41///
42/// `#[non_exhaustive]`: the audit learns new findings, and a `match` on
43/// this outside the crate needs a `_` arm so a new one does not break it.
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum Anomaly {
47 /// A directory entry references an inode whose `i_links_count` is
48 /// *less than* the observed reference count. Stored value is too
49 /// low — fsck would increase it to `observed`.
50 LinkCountTooLow {
51 ino: u32,
52 stored: u16,
53 observed: u32,
54 },
55 /// Inode's `i_links_count` is *greater than* the observed reference
56 /// count. Stored value is too high — fsck would decrease it.
57 LinkCountTooHigh {
58 ino: u32,
59 stored: u16,
60 observed: u32,
61 },
62 /// Dangling directory entry: a dir entry points to an inode with
63 /// `i_links_count == 0` or one we couldn't read.
64 ///
65 /// `observed` is how many dirents reference `child_ino` from the
66 /// audit's directory walk. For the readable-but-zero-links case
67 /// the rescue path writes `observed` into the inode's
68 /// `i_links_count` (it's the same fix as `LinkCountTooLow` from
69 /// `stored=0`). For the unreadable case we synthesise
70 /// `observed = 0` so the rescue is correctly refused — there's no
71 /// safe way to repair an inode we can't read without orphan-list
72 /// or `/lost+found` machinery.
73 DanglingEntry {
74 parent_ino: u32,
75 child_ino: u32,
76 observed: u32,
77 },
78 /// A directory's `..` entry does not point at its true parent.
79 WrongDotDot {
80 dir_ino: u32,
81 claims: u32,
82 actual_parent: u32,
83 },
84 /// A directory entry inside `parent_ino` claims its target
85 /// (`child_ino`) is a directory, but the target inode's mode bits
86 /// are not `S_IFDIR`. Read failures on the child are surfaced as
87 /// `DanglingEntry` from the inodes phase instead. Carrying both
88 /// inodes lets a repair pass either rewrite the dirent's
89 /// `file_type` byte (when the child is a valid non-dir) or
90 /// unlink the dirent.
91 /// Carries the dirent's `name` so the repair pass can target the
92 /// exact record by (parent, name) — necessary when the parent has
93 /// multiple hardlinks to the same inode and only one of them has
94 /// the wrong `file_type` byte. Stored as raw bytes since ext4
95 /// dirent names are not required to be UTF-8.
96 BogusEntry {
97 parent_ino: u32,
98 child_ino: u32,
99 name: Vec<u8>,
100 },
101 /// One block group's free-block / free-inode counters drift from
102 /// the bitmap reality. Either the bitmap claims fewer free bits
103 /// than the descriptor says (over-count) or more (under-count).
104 /// Common after crashes that interrupted bitmap+descriptor pairs
105 /// mid-write. Repair walks the bitmap, recomputes the truth, and
106 /// patches the descriptor (incl. checksum when metadata_csum is on).
107 BlockGroupFreeCountDrift {
108 group_index: u32,
109 stored_blocks: u32,
110 observed_blocks: u32,
111 stored_inodes: u32,
112 observed_inodes: u32,
113 },
114 /// Superblock free-block / free-inode totals don't match the sum
115 /// across all group descriptors' (post-bitmap) counts. Independent
116 /// of `BlockGroupFreeCountDrift`: the per-group descriptors might
117 /// agree with their bitmaps but the SB total still disagree (e.g.
118 /// a torn write of just the SB block). Repair recomputes from the
119 /// bitmaps and writes the SB.
120 SuperblockFreeCountDrift {
121 stored_blocks: u64,
122 observed_blocks: u64,
123 stored_inodes: u32,
124 observed_inodes: u32,
125 },
126 /// Multiple directory entries reference the same directory inode.
127 /// Illegal — directories can have only one parent dirent (plus . and ..).
128 /// Caused by an inode-allocator bug in early `apply_mkdir` (fixed) but
129 /// the on-disk wreckage remains until repair runs.
130 DuplicateDirentForDirInode {
131 ino: u32,
132 /// Every (parent_ino, name) tuple that references `ino`.
133 /// Sorted: (parent_ino asc, name asc) — repair keeps element 0,
134 /// removes the rest.
135 dirents: Vec<(u32, String)>,
136 },
137 /// A directory block's checksum does not match its contents: e2fsck's
138 /// "directory passes checks but fails checksum" for a linear block, and
139 /// "root node fails checksum" / "internal node fails checksum" when
140 /// `htree` is set and the block is an htree index.
141 ///
142 /// Only reported on `metadata_csum` volumes, and only for a block that
143 /// carries a checksum to check. A repair never restamps a block whose
144 /// checksum fails unless it is repairing this finding (#344): the
145 /// repair pass fixes the checksum of a linear block that passes the
146 /// structural checks, as e2fsck does, and leaves an htree index, which
147 /// e2fsck rebuilds, reported and untouched.
148 DirBlockChecksumMismatch {
149 dir_ino: u32,
150 logical_block: u64,
151 htree: bool,
152 },
153 /// A directory the walk could not read: its inode does not verify, or
154 /// its entries cannot be listed (an extent tree that does not parse, a
155 /// block that cannot be read). Nothing beneath it was examined, so the
156 /// audit cannot call the volume clean (#445). `reason` is the error the
157 /// read failed with.
158 ///
159 /// Not repairable: e2fsck clears such an inode (`Inode 2 has corrupt
160 /// extent header. Clear inode?`), which loses everything beneath it,
161 /// and that is not a decision this repair pass makes.
162 UnreadableDirectory { ino: u32, reason: String },
163}
164
165/// Summary returned by [`audit`]. Empty `anomalies` means the subset
166/// of invariants checked all held.
167#[derive(Debug, Clone, Default)]
168pub struct AuditReport {
169 /// Every problem found, in no particular order. Populated by the
170 /// legacy [`audit`] entry point; left empty by
171 /// [`audit_with_callbacks`] (it streams findings through the
172 /// caller's closure to avoid buffering on huge volumes).
173 pub anomalies: Vec<Anomaly>,
174 /// Number of distinct inodes visited via directory entries.
175 pub inodes_visited: u32,
176 /// Number of directory entries scanned (including `.`, `..`, and tombstones).
177 pub entries_scanned: u64,
178 /// Number of directories scanned.
179 pub directories_scanned: u32,
180 /// **Authoritative current count** of anomalies on the
181 /// filesystem. After [`audit`] / [`audit_with_callbacks`] this
182 /// is the count from the single scan. After a repair pass
183 /// (`audit_with_repair` with `repair = true`) this is the
184 /// **post-repair re-scan** count — the actual remaining
185 /// problems on disk, NOT the pre-repair number minus repaired
186 /// (which would be unreliable if our repair logic itself
187 /// introduces new anomalies).
188 pub anomalies_count: u64,
189 /// Number of anomalies the audit ORIGINALLY found, before any
190 /// repair commits. Equal to `anomalies_count` for non-repair
191 /// runs. After a repair pass: `initial_anomalies_count -
192 /// repaired_count` is what we *expect* to remain; the actual
193 /// `anomalies_count` from the post-repair re-scan is what
194 /// REALLY remains. Discrepancies between the two are how we
195 /// notice repair logic has bugs.
196 pub initial_anomalies_count: u64,
197 /// Anomalies the repair pass actually mutated the disk to fix.
198 /// Always zero unless [`audit_with_repair`] ran with `repair = true`.
199 /// A repair that failed midway leaves this counter at the number of
200 /// successfully-committed fixes — partial progress is intentional
201 /// (each commit is its own journal transaction so a crash mid-pass
202 /// can't compound the damage).
203 pub repaired_count: u64,
204}
205
206impl AuditReport {
207 pub fn is_clean(&self) -> bool {
208 self.anomalies_count == 0
209 }
210}
211
212/// Phase identifier for [`audit_with_callbacks`] progress callbacks.
213///
214/// Numeric values match `fs_ext4_fsck_phase_t` in `include/fs_ext4.h`
215/// and **must not be reordered** — the C ABI is locked.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217#[repr(u32)]
218pub enum FsckPhase {
219 Superblock = 0,
220 Journal = 1,
221 Directory = 2,
222 Inodes = 3,
223 Finalize = 4,
224}
225
226impl FsckPhase {
227 /// Short ASCII label, mirrored to the C ABI.
228 pub fn name(self) -> &'static str {
229 match self {
230 FsckPhase::Superblock => "superblock",
231 FsckPhase::Journal => "journal",
232 FsckPhase::Directory => "directory",
233 FsckPhase::Inodes => "inodes",
234 FsckPhase::Finalize => "finalize",
235 }
236 }
237}
238
239/// Walk the filesystem from `/`, counting directory-entry references
240/// to each inode and comparing against each inode's `i_links_count`.
241///
242/// Capped by `max_dirs_visited` and `max_entries_per_dir` so a
243/// deliberately-cyclic or extremely large image can still be audited
244/// in bounded time. For a real fsck pass, set both to `u32::MAX`.
245pub fn audit(
246 fs: &Filesystem,
247 max_dirs_visited: u32,
248 max_entries_per_dir: u32,
249) -> Result<AuditReport> {
250 let mut report = AuditReport::default();
251 let mut collected: Vec<Anomaly> = Vec::new();
252 audit_inner(
253 fs,
254 max_dirs_visited,
255 max_entries_per_dir,
256 &mut |_, _, _| {},
257 &mut |a| collected.push(a.clone()),
258 &mut report,
259 )?;
260 report.anomalies = collected;
261 Ok(report)
262}
263
264/// Same walk as [`audit`], but emits progress and findings through
265/// caller-supplied closures. The callbacks see each [`Anomaly`] as it
266/// is discovered (no buffering of the full list) and per-phase
267/// progress so a host UI can render a live progress bar.
268///
269/// On return, `report.anomalies` is **empty** — findings are delivered
270/// only through `on_finding`. The summary counters
271/// (`directories_scanned`, `entries_scanned`, `inodes_visited`,
272/// `anomalies_found` … via the C ABI helpers) are still populated.
273///
274/// Phase emission contract:
275/// - `Superblock` once at start (0/1 → 1/1) — superblock validity
276/// was already checked at mount.
277/// - `Directory` per directory popped (`done` = directories scanned
278/// so far, `total` = scanned + queue depth).
279/// - `Inodes` once around the link-count comparison pass (0/1 → 1/1).
280/// - `Finalize` once just before return (0/1 → 1/1).
281///
282/// `Journal` is **not** emitted here — the FFI shim drives journal
283/// replay before calling this function and emits the phase from
284/// there.
285pub fn audit_with_callbacks<P, F>(
286 fs: &Filesystem,
287 max_dirs_visited: u32,
288 max_entries_per_dir: u32,
289 mut on_progress: P,
290 mut on_finding: F,
291) -> Result<AuditReport>
292where
293 P: FnMut(FsckPhase, u64, u64),
294 F: FnMut(&Anomaly),
295{
296 let mut report = AuditReport::default();
297 on_progress(FsckPhase::Superblock, 0, 1);
298 on_progress(FsckPhase::Superblock, 1, 1);
299
300 audit_inner(
301 fs,
302 max_dirs_visited,
303 max_entries_per_dir,
304 &mut on_progress,
305 &mut on_finding,
306 &mut report,
307 )?;
308
309 Ok(report)
310}
311
312/// Core walk shared by [`audit`] and [`audit_with_callbacks`].
313///
314/// Findings are emitted through `on_finding`; nothing is pushed onto
315/// `report.anomalies` from here. Callers that want the legacy
316/// "collect into a vec" behaviour wrap `on_finding` accordingly.
317/// The audits compare block bitmaps and descriptor free counts with the
318/// blocks they observe. Under BIGALLOC both count clusters, so every group
319/// would be reported inconsistent (#75).
320pub(crate) fn refuse_bigalloc(fs: &Filesystem) -> Result<()> {
321 if fs.sb.feature_ro_compat & crate::features::RoCompat::BIGALLOC.bits() != 0 {
322 return Err(crate::error::Error::Unsupported(
323 "bigalloc: bitmaps and group free counts are in clusters, which the audit does not model",
324 ));
325 }
326 Ok(())
327}
328
329fn audit_inner(
330 fs: &Filesystem,
331 max_dirs_visited: u32,
332 max_entries_per_dir: u32,
333 on_progress: &mut dyn FnMut(FsckPhase, u64, u64),
334 on_finding: &mut dyn FnMut(&Anomaly),
335 report: &mut AuditReport,
336) -> Result<()> {
337 refuse_bigalloc(fs)?;
338 // Observed: ino → reference-count.
339 let mut observed: HashMap<u32, u32> = HashMap::new();
340 // What each directory's ".." entry CLAIMS the parent is (read off
341 // disk). Compared post-walk against `actual_parent` to flag
342 // WrongDotDot.
343 let mut parent_claim: HashMap<u32, u32> = HashMap::new();
344 // The directory that ACTUALLY enqueued each child during the walk
345 // — this is the source of truth for "who is your parent?". Built
346 // up as we pop work items. Root maps to itself by convention so a
347 // corrupted root ".." still flags WrongDotDot.
348 let mut actual_parent: HashMap<u32, u32> = HashMap::new();
349 // Directories we couldn't fully walk (parse failure, bound cap). Any link-count anomalies that could
350 // have been explained by their missing entries are suppressed below.
351 let mut incomplete_dirs: std::collections::HashSet<u32> = std::collections::HashSet::new();
352 // Directories reported as `UnreadableDirectory`, so the link-count
353 // pass does not report the same unreadable inode a second time as a
354 // dangling entry.
355 let mut unreadable_dirs: std::collections::HashSet<u32> = std::collections::HashSet::new();
356
357 // ino → list of (parent_ino, name_bytes) that reference it. Skips
358 // "." / ".." (those are self-references, not aliases). Kept as
359 // bytes so non-UTF-8 names (legal on ext4) round-trip; the
360 // `DuplicateDirentForDirInode` variant lossy-converts to String at
361 // emission time only, since the user-visible report can tolerate
362 // U+FFFD where the disk has a non-UTF-8 byte.
363 let mut dirent_index: HashMap<u32, Vec<(u32, Vec<u8>)>> = HashMap::new();
364
365 // (ino, parent_ino, dirent_name) — `dirent_name` is the name in
366 // `parent_ino` whose dirent points at `ino`. Carried so a
367 // `BogusEntry` finding can disambiguate when the parent has
368 // multiple hardlinks to the same inode. Empty for the root
369 // self-seed (root never triggers BogusEntry — its inode is
370 // always a directory).
371 let mut work: Vec<(u32, u32, Vec<u8>)> = Vec::new();
372 work.push((
373 crate::path::EXT4_ROOT_INODE,
374 crate::path::EXT4_ROOT_INODE,
375 Vec::new(),
376 ));
377 let mut visited: std::collections::HashSet<u32> = std::collections::HashSet::new();
378
379 let has_filetype = fs.sb.feature_incompat & features::Incompat::FILETYPE.bits() != 0;
380 let block_size = fs.sb.block_size();
381
382 // Initial directory progress pulse: 0 of (just root).
383 on_progress(FsckPhase::Directory, 0, work.len() as u64);
384
385 while let Some((dir_ino, parent_ino, dirent_name)) = work.pop() {
386 if report.directories_scanned >= max_dirs_visited {
387 incomplete_dirs.insert(dir_ino);
388 break;
389 }
390 // Only a confirmed directory is marked visited (below, after
391 // the type check): a second Directory-typed dirent naming the
392 // same regular file must reach the check too and be its own
393 // BogusEntry, or repair needs two runs to converge (#325).
394 if visited.contains(&dir_ino) {
395 continue;
396 }
397 // Record who really enqueued us. `parent_ino` is the directory
398 // we were popped under; for the root self-seed it's root
399 // itself. First-seen wins on the rare case a buggy filesystem
400 // has the same inode reachable from two different parents
401 // (the duplicate-dirent class is detected separately).
402 actual_parent.entry(dir_ino).or_insert(parent_ino);
403 report.directories_scanned += 1;
404
405 let (inode, raw) = match fs.read_inode_verified(dir_ino) {
406 Ok(p) => p,
407 Err(e) => {
408 incomplete_dirs.insert(dir_ino);
409 unreadable_dirs.insert(dir_ino);
410 on_finding(&Anomaly::UnreadableDirectory {
411 ino: dir_ino,
412 reason: e.to_string(),
413 });
414 report.anomalies_count += 1;
415 emit_dir_progress(on_progress, report.directories_scanned, work.len());
416 continue;
417 }
418 };
419 if !inode.is_dir() {
420 // Parent claimed this child was a directory (file_type
421 // byte in the dirent) but the inode's mode bits disagree.
422 // Carry both inodes plus the dirent name so a repair pass
423 // can either rewrite the dirent's file_type byte (precise
424 // (parent, name) match avoids hardlink ambiguity) or
425 // unlink the dirent.
426 let a = Anomaly::BogusEntry {
427 parent_ino,
428 child_ino: dir_ino,
429 name: dirent_name.clone(),
430 };
431 on_finding(&a);
432 report.anomalies_count += 1;
433 emit_dir_progress(on_progress, report.directories_scanned, work.len());
434 continue;
435 }
436 visited.insert(dir_ino);
437
438 let mut bad_checksums: Vec<(u64, bool)> = Vec::new();
439 let collected = collect_dir_entries_checked(
440 fs,
441 dir_ino,
442 &inode,
443 &raw,
444 has_filetype,
445 block_size,
446 Some(&mut |logical, htree| bad_checksums.push((logical, htree))),
447 );
448 for (logical_block, htree) in bad_checksums {
449 let a = Anomaly::DirBlockChecksumMismatch {
450 dir_ino,
451 logical_block,
452 htree,
453 };
454 on_finding(&a);
455 report.anomalies_count += 1;
456 }
457 let entries = match collected {
458 Ok(e) => e,
459 Err(e) => {
460 incomplete_dirs.insert(dir_ino);
461 unreadable_dirs.insert(dir_ino);
462 on_finding(&Anomaly::UnreadableDirectory {
463 ino: dir_ino,
464 reason: e.to_string(),
465 });
466 report.anomalies_count += 1;
467 emit_dir_progress(on_progress, report.directories_scanned, work.len());
468 continue;
469 }
470 };
471
472 let mut truncated = false;
473 for (n_scanned, entry) in (0u32..).zip(entries) {
474 if n_scanned >= max_entries_per_dir {
475 truncated = true;
476 break;
477 }
478 report.entries_scanned += 1;
479
480 if entry.name == b"." {
481 *observed.entry(dir_ino).or_insert(0) += 1;
482 continue;
483 }
484 if entry.name == b".." {
485 parent_claim.insert(dir_ino, entry.inode);
486 *observed.entry(entry.inode).or_insert(0) += 1;
487 continue;
488 }
489
490 *observed.entry(entry.inode).or_insert(0) += 1;
491 // Track every real (parent, name) edge so the post-walk
492 // pass can flag inodes referenced by more than one dirent.
493 dirent_index
494 .entry(entry.inode)
495 .or_default()
496 .push((dir_ino, entry.name.clone()));
497
498 if matches!(entry.file_type, DirEntryType::Directory) {
499 work.push((entry.inode, dir_ino, entry.name.clone()));
500 }
501 }
502 if truncated {
503 incomplete_dirs.insert(dir_ino);
504 }
505 emit_dir_progress(on_progress, report.directories_scanned, work.len());
506 }
507
508 report.inodes_visited = observed.len() as u32;
509
510 // Inode link-count compare phase.
511 let inodes_total = observed.len() as u64;
512 on_progress(FsckPhase::Inodes, 0, inodes_total.max(1));
513
514 // Compare observed vs stored. When an inode's reference came from a
515 // directory we couldn't fully enumerate, we suppress TooHigh (we
516 // under-counted) but still report TooLow (we already saw more than
517 // the stored value — the image is genuinely wrong).
518 let have_incomplete = !incomplete_dirs.is_empty();
519 let dir_nlink = fs.sb.feature_ro_compat & crate::features::RoCompat::DIR_NLINK.bits() != 0;
520 let mut inodes_done: u64 = 0;
521 let mut last_tick = Stopwatch::start();
522 let tick = Duration::from_millis(500);
523 for (&ino, &count) in observed.iter() {
524 match fs.read_inode_verified(ino) {
525 Ok((inode, _)) => {
526 let stored = inode.links_count;
527 if stored == 0 {
528 let a = Anomaly::DanglingEntry {
529 parent_ino: 0,
530 child_ino: ino,
531 observed: count,
532 };
533 on_finding(&a);
534 report.anomalies_count += 1;
535 continue;
536 }
537 // On a DIR_NLINK volume a directory count of 1 means "not
538 // known" (the format documentation's `i_links_count` entry:
539 // a count past MAX_LINKS is stored as 1, and removing a
540 // subdirectory never moves it), not a count that is too
541 // low.
542 let uncounted = stored == 1 && inode.is_dir() && dir_nlink;
543 if (stored as u32) < count && !uncounted {
544 let a = Anomaly::LinkCountTooLow {
545 ino,
546 stored,
547 observed: count,
548 };
549 on_finding(&a);
550 report.anomalies_count += 1;
551 }
552 if (stored as u32) > count && !have_incomplete {
553 let a = Anomaly::LinkCountTooHigh {
554 ino,
555 stored,
556 observed: count,
557 };
558 on_finding(&a);
559 report.anomalies_count += 1;
560 }
561 }
562 Err(_) if unreadable_dirs.contains(&ino) => {
563 // Already reported by the walk as an unreadable directory.
564 }
565 Err(_) => {
566 // Unreadable inode that somebody linked to. Surface
567 // observed = 0 as a sentinel meaning "rescue not safe"
568 // — repair_link_count refuses observed == 0 already,
569 // so the rescue path correctly leaves this case alone.
570 let a = Anomaly::DanglingEntry {
571 parent_ino: 0,
572 child_ino: ino,
573 observed: 0,
574 };
575 on_finding(&a);
576 report.anomalies_count += 1;
577 }
578 }
579 inodes_done += 1;
580 if last_tick.elapsed() >= tick {
581 on_progress(FsckPhase::Inodes, inodes_done, inodes_total.max(1));
582 last_tick = Stopwatch::start();
583 }
584 }
585
586 // Surface inodes referenced by more than one dirent that are
587 // themselves directories. Multi-link is fine for files (POSIX
588 // hardlinks) but illegal for dirs; the canonical case here is the
589 // pre-fix `apply_mkdir` allocator bug which left N siblings all
590 // pointing at the same inode. Detection runs over the index built
591 // during the walk; emission order is sorted so repair has a
592 // deterministic "keep first" choice.
593 let mut dup_keys: Vec<u32> = dirent_index
594 .iter()
595 .filter_map(|(ino, refs)| if refs.len() > 1 { Some(*ino) } else { None })
596 .collect();
597 dup_keys.sort_unstable();
598 for ino in dup_keys {
599 // Only directories trip the alias rule. Files with multiple
600 // dirents are normal hardlinks and already covered by the
601 // link-count comparison above.
602 let is_dir = match fs.read_inode_verified(ino) {
603 Ok((inode, _)) => inode.is_dir(),
604 // Unreadable inodes were already reported as DanglingEntry;
605 // skip here rather than double-count.
606 Err(_) => continue,
607 };
608 if !is_dir {
609 continue;
610 }
611 let mut refs = dirent_index.get(&ino).cloned().unwrap_or_default();
612 refs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
613 let dirents: Vec<(u32, String)> = refs
614 .into_iter()
615 .map(|(p, n)| (p, String::from_utf8_lossy(&n).into_owned()))
616 .collect();
617 let a = Anomaly::DuplicateDirentForDirInode { ino, dirents };
618 on_finding(&a);
619 report.anomalies_count += 1;
620 }
621
622 // Check `..` claims against the actual enqueueing parent. Root
623 // is special-cased to compare against itself (ext4 convention:
624 // root's ".." points at root). For every other directory we
625 // compare against `actual_parent` — the directory that enqueued
626 // it during the walk. A directory we never reached has no
627 // entry in `actual_parent`; we skip those (any anomaly inside an
628 // unreachable subtree is invisible to the audit by definition).
629 for (&dir_ino, &claimed) in parent_claim.iter() {
630 let truth = if dir_ino == crate::path::EXT4_ROOT_INODE {
631 crate::path::EXT4_ROOT_INODE
632 } else {
633 match actual_parent.get(&dir_ino) {
634 Some(&p) => p,
635 None => continue,
636 }
637 };
638 if claimed != truth {
639 let a = Anomaly::WrongDotDot {
640 dir_ino,
641 claims: claimed,
642 actual_parent: truth,
643 };
644 on_finding(&a);
645 report.anomalies_count += 1;
646 }
647 }
648
649 on_progress(FsckPhase::Inodes, inodes_total.max(1), inodes_total.max(1));
650
651 // Free-count drift scan. Walks every group's block + inode bitmaps,
652 // counts the free bits, compares against the descriptors and the
653 // superblock. Emits per-group + per-superblock drift findings as
654 // it goes. Folded under the existing Inodes phase rather than its
655 // own phase so the C ABI's locked phase enum doesn't need
656 // extending.
657 audit_free_counts(fs, on_finding, report)?;
658
659 on_progress(FsckPhase::Finalize, 0, 1);
660 on_progress(FsckPhase::Finalize, 1, 1);
661
662 Ok(())
663}
664
665/// Walk every block group's bitmaps, count free bits, and emit
666/// drift findings against the on-disk descriptors and superblock.
667/// Detection only — repair is wired through `audit_with_repair`.
668///
669/// The bits past a group's actual block/inode range are reserved as
670/// "always allocated" (set to 1) on a healthy ext4 image; we only
671/// count zero bits within `[0, group_size_bits)` to mirror that
672/// convention so a partial last group doesn't get double-counted.
673fn audit_free_counts(
674 fs: &Filesystem,
675 on_finding: &mut dyn FnMut(&Anomaly),
676 report: &mut AuditReport,
677) -> Result<()> {
678 let bpg = fs.sb.blocks_per_group as u64;
679 let ipg = fs.sb.inodes_per_group as u64;
680 let total_blocks = fs.sb.blocks_count;
681 let first_data = fs.sb.first_data_block as u64;
682
683 // Re-read BGDs and SB from disk for each scan rather than
684 // trusting `fs.groups` / `fs.sb` (mount-time snapshots, never
685 // mutated). The post-repair re-scan needs the patched values to
686 // avoid re-emitting drift findings the repair pass just fixed.
687 // Every read here, the bitmaps and these descriptors alike, goes
688 // through `fs.dev`, which the mount wrapped in a `CachedDevice`
689 // (`Filesystem::mount_inner`); that cache holds the pinned
690 // post-commit, pre-checkpoint bytes, so both halves of the compare
691 // see the same point in time.
692 let live_groups = bgd::read_all(fs.dev.as_ref(), &fs.sb, &fs.csum)?;
693 let live_sb = Superblock::read(fs.dev.as_ref())?;
694
695 let mut sum_free_blocks: u64 = 0;
696 let mut sum_free_inodes: u64 = 0;
697
698 for (gi, bg) in live_groups.iter().enumerate() {
699 // Block bitmap: count zero bits across the bytes covering the
700 // bits that actually correspond to this group's blocks. The
701 // last group may be partial. The post-repair re-scan sees the
702 // pinned post-commit bytes here because the cache is in the
703 // device: `fs.read_block` is `fs.dev.read_at` with the offset
704 // worked out, and neither can skip the `CachedDevice`. Seeing the
705 // stale on-disk image would mean reaching past it deliberately,
706 // and nothing here does (#152).
707 let group_first_block = first_data + gi as u64 * bpg;
708 let group_block_count = std::cmp::min(bpg, total_blocks.saturating_sub(group_first_block));
709 //
710 // A BLOCK_UNINIT group's bitmap block is unspecified — mke2fs
711 // never writes it — so it is rebuilt from the group's metadata,
712 // exactly as the allocator reads it, rather than counted (#391).
713 let block_bitmap = if bg.flags().contains(bgd::BgdFlags::BLOCK_UNINIT) {
714 crate::alloc::uninit_block_bitmap(&fs.sb, &live_groups, gi as u32)
715 } else {
716 fs.read_block(bg.block_bitmap)?
717 };
718 let observed_blocks = count_zero_bits_le(&block_bitmap, group_block_count as u32);
719
720 // Inode bitmap: every group has exactly inodes_per_group
721 // inodes (the ext4 layout doesn't leave a partial last group
722 // for inodes; the trailing bits are reserved-as-1). An
723 // INODE_UNINIT group has every inode free, whatever its
724 // unwritten bitmap block holds.
725 let observed_inodes = if bg.flags().contains(bgd::BgdFlags::INODE_UNINIT) {
726 ipg as u32
727 } else {
728 let inode_bitmap = fs.read_block(bg.inode_bitmap)?;
729 count_zero_bits_le(&inode_bitmap, ipg as u32)
730 };
731
732 sum_free_blocks += observed_blocks as u64;
733 sum_free_inodes += observed_inodes as u64;
734
735 if observed_blocks != bg.free_blocks_count || observed_inodes != bg.free_inodes_count {
736 let a = Anomaly::BlockGroupFreeCountDrift {
737 group_index: gi as u32,
738 stored_blocks: bg.free_blocks_count,
739 observed_blocks,
740 stored_inodes: bg.free_inodes_count,
741 observed_inodes,
742 };
743 on_finding(&a);
744 report.anomalies_count += 1;
745 }
746 }
747
748 // SB totals. Compare against the BITMAP-derived sum, not the
749 // descriptor-derived sum — a torn SB write can leave the SB
750 // disagreeing with descriptors that themselves agree with the
751 // bitmaps. We want the truth.
752 if sum_free_blocks != live_sb.free_blocks_count
753 || (sum_free_inodes as u32) != live_sb.free_inodes_count
754 {
755 let a = Anomaly::SuperblockFreeCountDrift {
756 stored_blocks: live_sb.free_blocks_count,
757 observed_blocks: sum_free_blocks,
758 stored_inodes: live_sb.free_inodes_count,
759 observed_inodes: sum_free_inodes as u32,
760 };
761 on_finding(&a);
762 report.anomalies_count += 1;
763 }
764 Ok(())
765}
766
767/// Count zero (= free) bits inside the first `total_bits` bits of a
768/// little-endian bitmap. Bits beyond `total_bits` are treated as
769/// "reserved/allocated" and are NOT counted, even when the bitmap
770/// happens to have them at zero — matches the ext4 convention for
771/// trailing reserved bits in a partial group.
772fn count_zero_bits_le(buf: &[u8], total_bits: u32) -> u32 {
773 let full_bytes = (total_bits / 8) as usize;
774 let mut free: u32 = 0;
775 for i in 0..full_bytes {
776 if i >= buf.len() {
777 break;
778 }
779 free += buf[i].count_zeros();
780 }
781 let leftover_bits = total_bits % 8;
782 if leftover_bits > 0 && full_bytes < buf.len() {
783 let last = buf[full_bytes];
784 let mask = (1u8 << leftover_bits) - 1;
785 let ones_in_used_bits = (last & mask).count_ones();
786 free += leftover_bits - ones_in_used_bits;
787 }
788 free
789}
790
791fn emit_dir_progress(
792 on_progress: &mut dyn FnMut(FsckPhase, u64, u64),
793 scanned: u32,
794 queue_len: usize,
795) {
796 let done = scanned as u64;
797 let total = done + queue_len as u64;
798 on_progress(FsckPhase::Directory, done, total);
799}
800
801fn collect_dir_entries(
802 fs: &Filesystem,
803 ino: u32,
804 inode: &Inode,
805 inode_raw: &[u8],
806 has_filetype: bool,
807 block_size: u32,
808) -> Result<Vec<crate::dir::DirEntry>> {
809 collect_dir_entries_checked(fs, ino, inode, inode_raw, has_filetype, block_size, None)
810}
811
812/// What a directory block's checksum says about it.
813#[derive(Debug, Clone, Copy, PartialEq, Eq)]
814enum DirBlockChecksum {
815 /// Nothing to check: no `metadata_csum`, or no tail in this block.
816 Unchecked,
817 /// The stored checksum matches the block.
818 Matches,
819 /// The stored checksum does not match; `htree` when the block is an
820 /// htree index and the checksum is its `dx_tail`'s.
821 Mismatch { htree: bool },
822}
823
824/// Check logical block `logical` of directory `dir_ino` against its
825/// checksum, as e2fsck's pass 2 does.
826///
827/// An htree index block is checked as one: block 0 of an indexed directory
828/// is the dx_root and a block whose first record is an empty entry spanning
829/// it is a dx_node (`Filesystem::is_htree_index_block`, the format's rule).
830/// Its checksum is the `dx_tail`'s, and only a count/limit pair that leaves
831/// exactly room for one says there is a tail to check: the format
832/// documentation places `struct dx_tail` right after the `limit` entries a
833/// block holds. A kernel-grown dx_root ends
834/// in bytes that look like a dirent tail (#233), which is why the index
835/// test comes first.
836fn dir_block_checksum(
837 fs: &Filesystem,
838 dir_ino: u32,
839 dir: &Inode,
840 logical: u64,
841 block: &[u8],
842) -> DirBlockChecksum {
843 if !fs.csum.enabled {
844 return DirBlockChecksum::Unchecked;
845 }
846 if Filesystem::is_htree_index_block(dir, logical, block) {
847 let count_offset = if logical == 0 {
848 // dx_root_info's length byte; the format fixes it at 8.
849 if block.get(29) != Some(&8) {
850 return DirBlockChecksum::Unchecked;
851 }
852 32
853 } else {
854 8
855 };
856 let Some(pair) = block.get(count_offset..count_offset + 2) else {
857 return DirBlockChecksum::Unchecked;
858 };
859 let limit = u16::from_le_bytes([pair[0], pair[1]]) as usize;
860 if count_offset + limit * 8 + 8 != block.len() {
861 return DirBlockChecksum::Unchecked;
862 }
863 return match fs
864 .csum
865 .verify_dx_tail(dir_ino, dir.generation, block, count_offset)
866 {
867 Some(true) => DirBlockChecksum::Matches,
868 Some(false) => DirBlockChecksum::Mismatch { htree: true },
869 None => DirBlockChecksum::Unchecked,
870 };
871 }
872 if !dir::has_csum_tail(block) {
873 return DirBlockChecksum::Unchecked;
874 }
875 if fs
876 .csum
877 .verify_dir_entry_tail(dir_ino, dir.generation, block)
878 {
879 DirBlockChecksum::Matches
880 } else {
881 DirBlockChecksum::Mismatch { htree: false }
882 }
883}
884
885/// Whether a repair may edit this directory block and restamp it.
886///
887/// Only a block whose checksum matches, or that has none to check. A block
888/// whose checksum fails has been reported as a `DirBlockChecksumMismatch`,
889/// and the repair pass fixes those before any other directory edit, so a
890/// block that still fails here is one that repair declined: restamping it
891/// would bless whatever it holds with a valid checksum and destroy the
892/// evidence (#344, the fsck twin of #161 and #322).
893fn may_restamp(fs: &Filesystem, dir_ino: u32, dir: &Inode, logical: u64, block: &[u8]) -> bool {
894 !matches!(
895 dir_block_checksum(fs, dir_ino, dir, logical, block),
896 DirBlockChecksum::Mismatch { .. }
897 )
898}
899
900/// Whether a linear directory block with a dirent tail passes the
901/// structural checks e2fsck makes before it will only fix the checksum:
902/// every record's `rec_len` is at least 8, a multiple of 4 and inside the
903/// block, its name fits in it, and the chain ends exactly at the tail.
904fn dir_block_passes_checks(block: &[u8], has_filetype: bool) -> bool {
905 let Some(end) = block.len().checked_sub(12) else {
906 return false;
907 };
908 let mut off = 0usize;
909 while off < end {
910 if off + 8 > end {
911 return false;
912 }
913 let rec_len = u16::from_le_bytes([block[off + 4], block[off + 5]]) as usize;
914 if rec_len < 8 || !rec_len.is_multiple_of(4) || off + rec_len > end {
915 return false;
916 }
917 let name_len = if has_filetype {
918 block[off + 6] as usize
919 } else {
920 ((block[off + 7] as usize) << 8) | block[off + 6] as usize
921 };
922 if 8 + name_len > rec_len {
923 return false;
924 }
925 off += rec_len;
926 }
927 off == end
928}
929
930/// [`collect_dir_entries`], reporting every block whose checksum does not
931/// match through `on_bad_checksum(logical, htree)` when one is given.
932/// `inode_raw` is the directory's on-disk inode, for the `system.data`
933/// xattr an inline directory's entries continue into.
934fn collect_dir_entries_checked(
935 fs: &Filesystem,
936 dir_ino: u32,
937 inode: &Inode,
938 inode_raw: &[u8],
939 has_filetype: bool,
940 block_size: u32,
941 mut on_bad_checksum: Option<&mut dyn FnMut(u64, bool)>,
942) -> Result<Vec<crate::dir::DirEntry>> {
943 if inode.has_inline_data() {
944 // `.` and `..` synthesised, entries from byte 4 and then from the
945 // `system.data` continuation (#427).
946 return crate::inline_data::read_dir(
947 fs.dev.as_ref(),
948 dir_ino,
949 inode,
950 inode_raw,
951 fs.sb.inode_size,
952 block_size,
953 has_filetype,
954 );
955 }
956 let mut entries = Vec::new();
957 // Extent-mapped or block-mapped (ext2, ext3): both are read. The walk
958 // used to refuse a block-mapped directory, so on those volumes it
959 // examined nothing below the root (#445).
960 let total_blocks = inode.size.div_ceil(block_size as u64);
961 let mut buf = vec![0u8; block_size as usize];
962 for logical in 0..total_blocks {
963 let Some(phys) = crate::indirect::map_logical_any(
964 &inode.block,
965 inode.flags,
966 fs.dev.as_ref(),
967 block_size,
968 logical,
969 )?
970 else {
971 continue;
972 };
973 let offset = phys
974 .checked_mul(block_size as u64)
975 .ok_or(Error::Corrupt("audit: dir block offset overflow"))?;
976 fs.dev.read_at(offset, &mut buf)?;
977 if let Some(on_bad_checksum) = on_bad_checksum.as_mut() {
978 if let DirBlockChecksum::Mismatch { htree } =
979 dir_block_checksum(fs, dir_ino, inode, logical, &buf)
980 {
981 on_bad_checksum(logical, htree);
982 }
983 }
984 for entry in DirBlockIter::new(&buf, has_filetype) {
985 // Ignore parse errors on dx_root first block of indexed dirs.
986 // `break`, not `continue`: the iterator does not advance past
987 // a record it refused, so `continue` asked it for the same
988 // record forever and the audit never returned.
989 match entry {
990 Ok(e) => entries.push(e),
991 Err(_) if logical == 0 => break,
992 Err(e) => return Err(e),
993 }
994 }
995 }
996 Ok(entries)
997}
998
999/// Audit, then optionally repair the subset of anomalies the repair
1000/// pass knows how to fix. When `repair == false` this is the read+write
1001/// equivalent of [`audit_with_callbacks`] — same findings, no disk
1002/// mutation. When `repair == true`, after the read pass completes the
1003/// function walks `report.anomalies` and commits a fix per repairable
1004/// finding through the journal writer.
1005///
1006/// Repairable today:
1007/// - [`Anomaly::DuplicateDirentForDirInode`]: keeps `dirents[0]`, removes
1008/// the rest from their respective parent directories, then recomputes
1009/// the surviving directory's `i_links_count` from a fresh count of its
1010/// subdirectories.
1011/// - [`Anomaly::LinkCountTooLow`] / [`Anomaly::LinkCountTooHigh`]: writes
1012/// the observed count back into `i_links_count`.
1013/// - [`Anomaly::DirBlockChecksumMismatch`] on a linear block that passes
1014/// the structural checks: recomputes its checksum. No repair restamps a
1015/// directory block whose checksum fails otherwise (#344).
1016///
1017/// Repairs run in e2fsck's order: directory-block checksums, then dirent
1018/// edits, then counts, re-counted after the edits when any landed.
1019///
1020/// Each repair commit is its own [`BlockBuffer`] transaction. Crash
1021/// mid-pass: the surviving on-disk state is the union of fixes that
1022/// committed up to that point; subsequent fsck runs continue from
1023/// there. `report.repaired_count` reflects how many fixes actually
1024/// landed (0 if `repair == false`).
1025///
1026/// The `findings` collected via `on_finding` and the `report.anomalies`
1027/// vec follow the same population contract as [`audit`] /
1028/// [`audit_with_callbacks`]: whichever caller wires up the closure
1029/// gets the streaming events; the returned report counts are always
1030/// authoritative.
1031pub fn audit_with_repair<P, F>(
1032 fs: &Filesystem,
1033 max_dirs_visited: u32,
1034 max_entries_per_dir: u32,
1035 mut on_progress: P,
1036 mut on_finding: F,
1037 repair: bool,
1038) -> Result<AuditReport>
1039where
1040 P: FnMut(FsckPhase, u64, u64),
1041 F: FnMut(&Anomaly),
1042{
1043 // Refuse repair before any scanning. The full walk is expensive, and
1044 // a refused repair shouldn't look like a partially-successful audit
1045 // to the caller.
1046 //
1047 // THE SAME GUARD AS EVERY OTHER WRITE. This used to check only that
1048 // the device was writable, so a volume with a feature this driver
1049 // does not maintain -- which refuses a create -- was repaired, and
1050 // unjournaled, because the mount opens no journal writer on such a
1051 // volume (#119). `refuse_write` still answers a read-only device with
1052 // `ReadOnly`.
1053 if repair {
1054 fs.refuse_write()?;
1055 }
1056
1057 let mut report = AuditReport::default();
1058 on_progress(FsckPhase::Superblock, 0, 1);
1059 on_progress(FsckPhase::Superblock, 1, 1);
1060
1061 // Buffer findings locally so the repair pass can iterate them
1062 // without re-walking. The caller's closure still sees each finding
1063 // streamed as it's discovered — we tee through `on_finding`.
1064 let mut collected: Vec<Anomaly> = Vec::new();
1065 audit_inner(
1066 fs,
1067 max_dirs_visited,
1068 max_entries_per_dir,
1069 &mut on_progress,
1070 &mut |a| {
1071 on_finding(a);
1072 collected.push(a.clone());
1073 },
1074 &mut report,
1075 )?;
1076
1077 // Snapshot the pre-repair count before anything mutates state.
1078 // Even non-repair runs get this for symmetry — both fields will
1079 // hold the same number on a `repair = false` call.
1080 report.initial_anomalies_count = report.anomalies_count;
1081
1082 if !repair {
1083 report.anomalies = collected;
1084 return Ok(report);
1085 }
1086
1087 // For directories that ALSO appear in a DuplicateDirentForDirInode
1088 // finding, the captured `actual_parent` is the parent the walker
1089 // saw first — which may not be the one that survives dedup
1090 // (`repair_duplicate_dir_inode` keeps `dirents[0]`). Override the
1091 // WrongDotDot target with the post-dedup surviving parent so we
1092 // don't rewrite `..` to point at the alias we just removed.
1093 let surviving_parent_after_dedup: HashMap<u32, u32> = collected
1094 .iter()
1095 .filter_map(|a| match a {
1096 Anomaly::DuplicateDirentForDirInode { ino, dirents } if !dirents.is_empty() => {
1097 Some((*ino, dirents[0].0))
1098 }
1099 _ => None,
1100 })
1101 .collect();
1102
1103 // In e2fsck's order. Directory blocks first (pass 2): their checksums
1104 // before anything edits them, so every later edit meets a block that
1105 // verifies or one repair declined and must leave alone (#344). Then
1106 // the dirent edits (passes 2 and 3). Then the counts (passes 4 and 5),
1107 // judged against the tree those edits left: a wrong `..` moves one
1108 // reference from the directory it claims to the true parent, and
1109 // counts taken before it is fixed would be "repaired" to match the
1110 // mistake.
1111 for finding in &collected {
1112 if let Anomaly::DirBlockChecksumMismatch {
1113 dir_ino,
1114 logical_block,
1115 htree,
1116 } = finding
1117 {
1118 repair_dir_block_checksum(fs, *dir_ino, *logical_block, *htree, &mut report)?;
1119 }
1120 }
1121 let before_dirent_edits = report.repaired_count;
1122 for finding in &collected {
1123 match finding {
1124 Anomaly::DuplicateDirentForDirInode { ino, dirents } => {
1125 repair_duplicate_dir_inode(fs, *ino, dirents, &mut report)?;
1126 }
1127 Anomaly::WrongDotDot {
1128 dir_ino,
1129 claims: _,
1130 actual_parent,
1131 } => {
1132 let target_parent = surviving_parent_after_dedup
1133 .get(dir_ino)
1134 .copied()
1135 .unwrap_or(*actual_parent);
1136 repair_wrong_dotdot(fs, *dir_ino, target_parent, &mut report)?;
1137 }
1138 Anomaly::BogusEntry {
1139 parent_ino,
1140 child_ino,
1141 name,
1142 } => {
1143 repair_bogus_entry(fs, *parent_ino, *child_ino, name, &mut report)?;
1144 }
1145 _ => {}
1146 }
1147 }
1148 // The counts as the edited tree has them. Unchanged when no dirent was
1149 // edited, so the common case walks once.
1150 let counts_from: Vec<Anomaly> = if report.repaired_count == before_dirent_edits {
1151 collected.clone()
1152 } else {
1153 let mut recount = Vec::new();
1154 audit_inner(
1155 fs,
1156 max_dirs_visited,
1157 max_entries_per_dir,
1158 &mut on_progress,
1159 &mut |a| recount.push(a.clone()),
1160 &mut AuditReport::default(),
1161 )?;
1162 recount
1163 };
1164 for finding in &counts_from {
1165 match finding {
1166 Anomaly::LinkCountTooLow {
1167 ino,
1168 stored: _,
1169 observed,
1170 }
1171 | Anomaly::LinkCountTooHigh {
1172 ino,
1173 stored: _,
1174 observed,
1175 } => {
1176 repair_link_count(fs, *ino, *observed, &mut report)?;
1177 }
1178 Anomaly::DanglingEntry {
1179 parent_ino: _,
1180 child_ino,
1181 observed,
1182 } => {
1183 // Rescue: write observed into the inode's links_count
1184 // when the inode is readable. The unreadable case
1185 // arrives with observed = 0 and repair_link_count
1186 // refuses that, leaving the anomaly for a future
1187 // orphan-list / lost+found path.
1188 repair_link_count(fs, *child_ino, *observed, &mut report)?;
1189 }
1190 Anomaly::BlockGroupFreeCountDrift {
1191 group_index,
1192 stored_blocks,
1193 observed_blocks,
1194 stored_inodes,
1195 observed_inodes,
1196 } => {
1197 repair_block_group_free_counts(
1198 fs,
1199 *group_index,
1200 *stored_blocks,
1201 *observed_blocks,
1202 *stored_inodes,
1203 *observed_inodes,
1204 &mut report,
1205 )?;
1206 }
1207 Anomaly::SuperblockFreeCountDrift {
1208 stored_blocks,
1209 observed_blocks,
1210 stored_inodes,
1211 observed_inodes,
1212 } => {
1213 repair_superblock_free_counts(
1214 fs,
1215 *stored_blocks,
1216 *observed_blocks,
1217 *stored_inodes,
1218 *observed_inodes,
1219 &mut report,
1220 )?;
1221 }
1222 Anomaly::DirBlockChecksumMismatch { .. }
1223 | Anomaly::UnreadableDirectory { .. }
1224 | Anomaly::DuplicateDirentForDirInode { .. }
1225 | Anomaly::WrongDotDot { .. }
1226 | Anomaly::BogusEntry { .. } => {}
1227 }
1228 }
1229
1230 // Post-repair re-scan. Walking the tree again is expensive, but
1231 // it's the only way to give the caller a TRUTHFUL "what's still
1232 // wrong" count. If our repair logic accidentally introduced new
1233 // anomalies (or didn't actually fix the ones we thought we fixed),
1234 // this re-scan surfaces it as a count mismatch:
1235 // expected_remaining = initial_anomalies_count - repaired_count
1236 // actual_remaining = anomalies_count (from this re-scan)
1237 // The caller's `on_progress` is reused so the host UI keeps
1238 // rendering progress for the second walk. `on_finding` is a
1239 // no-op closure here because the pre-repair stream already gave
1240 // the caller the per-finding detail and re-emitting would
1241 // double-count in the UI — but we DO buffer remaining findings
1242 // locally so `report.anomalies` reflects the post-repair truth
1243 // (consistent with `report.anomalies_count`).
1244 let mut post_report = AuditReport::default();
1245 let mut remaining: Vec<Anomaly> = Vec::new();
1246 audit_inner(
1247 fs,
1248 max_dirs_visited,
1249 max_entries_per_dir,
1250 &mut on_progress,
1251 &mut |a| {
1252 remaining.push(a.clone());
1253 },
1254 &mut post_report,
1255 )?;
1256
1257 // Replace the live count + anomalies list with the post-repair
1258 // numbers. Keep `initial_anomalies_count` unchanged so the
1259 // caller can see the before-vs-after delta.
1260 report.anomalies = remaining;
1261 report.anomalies_count = post_report.anomalies_count;
1262
1263 Ok(report)
1264}
1265
1266/// Repair a `DuplicateDirentForDirInode` finding.
1267///
1268/// `dirents` is sorted (parent_ino asc, name asc). We keep
1269/// `dirents[0]` as the canonical edge and remove every entry in
1270/// `dirents[1..]` from its parent block. Each removal is a separate
1271/// journal commit so a crash mid-loop leaves a deterministic
1272/// partial-fix state (some duplicates gone, the rest still pending —
1273/// fsck on next mount finishes the job).
1274///
1275/// After the duplicates are gone we recompute the kept directory's
1276/// `i_links_count` from a fresh subdir count: ext4 link count for a
1277/// dir is `2 + (number of child subdirectories)` (2 = self via "." +
1278/// parent's dirent). Stale link counts caused by the multi-parent
1279/// state are corrected here so a subsequent audit returns clean.
1280fn repair_duplicate_dir_inode(
1281 fs: &Filesystem,
1282 ino: u32,
1283 dirents: &[(u32, String)],
1284 report: &mut AuditReport,
1285) -> Result<()> {
1286 if dirents.len() < 2 {
1287 // Defensive — detection only emits this variant when len > 1.
1288 return Ok(());
1289 }
1290
1291 let has_ft = fs.sb.feature_incompat & features::Incompat::FILETYPE.bits() != 0;
1292 let bs = fs.sb.block_size();
1293
1294 // Drop every duplicate edge except the first. Each iteration reads
1295 // the parent's dir blocks fresh so a previous removal in the same
1296 // parent (rare — needs two duplicates with the same parent) is
1297 // visible. `repaired_count` advances once per finding, not once
1298 // per alias removed — the caller's
1299 // `initial_anomalies_count - repaired_count` reconciliation
1300 // counts findings, so a 3-alias finding still represents one
1301 // repaired anomaly.
1302 let mut any_removed = false;
1303 for (parent_ino, name) in dirents.iter().skip(1) {
1304 let (parent_inode, _parent_raw) = fs.read_inode_verified(*parent_ino)?;
1305 if !parent_inode.is_dir() {
1306 // The parent itself isn't a directory anymore — bail on
1307 // this duplicate and let a later pass clean up.
1308 continue;
1309 }
1310 let mut buf = BlockBuffer::new(bs);
1311 let parent_blocks = parent_inode.size.div_ceil(bs as u64);
1312 let mut removed = false;
1313 for logical in 0..parent_blocks {
1314 let Some(phys) = fs.map_inode_logical(&parent_inode, logical)? else {
1315 continue;
1316 };
1317 let block = buf.get_mut(fs, phys)?;
1318 if !may_restamp(fs, *parent_ino, &parent_inode, logical, block) {
1319 // A block repair declined to fix. If the alias is in it,
1320 // leave the alias too rather than restamp the block.
1321 let here = DirBlockIter::new(block, has_ft)
1322 .map_while(|e| e.ok())
1323 .any(|e| e.name == name.as_bytes());
1324 if here {
1325 break;
1326 }
1327 continue;
1328 }
1329 // dir_entry_tail occupies the last 12 bytes when
1330 // metadata_csum is on. Mirror apply_unlink's reservation
1331 // so removal doesn't scribble the tail.
1332 let reserved_tail = if fs.csum.enabled && dir::has_csum_tail(block) {
1333 12
1334 } else {
1335 0
1336 };
1337 if dir::remove_entry_from_block(block, name.as_bytes(), has_ft, reserved_tail)? {
1338 if fs.csum.enabled && reserved_tail == 12 {
1339 fs.csum
1340 .patch_dir_entry_tail(*parent_ino, parent_inode.generation, block);
1341 }
1342 removed = true;
1343 break;
1344 }
1345 }
1346 if !removed {
1347 // Detection saw the dirent but the on-disk parent doesn't
1348 // contain it now — racy concurrent mutation, or audit was
1349 // run with a partial cache. Skip rather than fail the
1350 // whole pass; the next audit will resurface or clear it.
1351 continue;
1352 }
1353 fs.commit_block_buffer(buf)?;
1354 any_removed = true;
1355 }
1356 if any_removed {
1357 report.repaired_count += 1;
1358 }
1359
1360 // Recompute i_links_count for the surviving directory. Walking
1361 // children counts only proper subdirectories (not "." / "..") —
1362 // the canonical formula for a directory's nlink in ext4.
1363 let (kept_inode, mut kept_raw) = fs.read_inode_verified(ino)?;
1364 if !kept_inode.is_dir() {
1365 return Ok(());
1366 }
1367 let subdir_count = count_subdirs(fs, ino, &kept_inode, &kept_raw, has_ft, bs)?;
1368 let new_nlink: u16 = 2u16.saturating_add(subdir_count.min(u16::MAX as u32 - 2) as u16);
1369 kept_raw[0x1A..0x1C].copy_from_slice(&new_nlink.to_le_bytes());
1370 finalize_and_commit_inode(fs, ino, kept_inode.generation, &mut kept_raw)?;
1371 Ok(())
1372}
1373
1374/// Walk `dir_inode`'s data blocks and count entries whose file_type is
1375/// Directory, excluding "." and "..". Used by repair to recompute
1376/// `i_links_count` from scratch after removing duplicate dirents.
1377fn count_subdirs(
1378 fs: &Filesystem,
1379 dir_ino: u32,
1380 dir_inode: &Inode,
1381 dir_raw: &[u8],
1382 has_filetype: bool,
1383 block_size: u32,
1384) -> Result<u32> {
1385 let entries = collect_dir_entries(fs, dir_ino, dir_inode, dir_raw, has_filetype, block_size)?;
1386 let mut n = 0u32;
1387 for e in entries {
1388 if e.name == b"." || e.name == b".." {
1389 continue;
1390 }
1391 // Don't trust the dirent's `file_type` byte — that's exactly
1392 // what `BogusEntry` exists to detect. Verify the child
1393 // inode's mode bits before counting. Unreadable children and
1394 // mode mismatches are silently skipped: repair_duplicate_dir
1395 // _inode just needs the truthful current count, and a
1396 // BogusEntry repair (if one runs in the same pass) will fix
1397 // the dirent byte separately.
1398 if matches!(e.file_type, DirEntryType::Directory) {
1399 if let Ok((child_inode, _)) = fs.read_inode_verified(e.inode) {
1400 if child_inode.is_dir() {
1401 n = n.saturating_add(1);
1402 }
1403 }
1404 }
1405 }
1406 Ok(n)
1407}
1408
1409/// Repair a link-count mismatch by writing `observed` into
1410/// `i_links_count`. Stays narrow on purpose — anything that needs more
1411/// surgery (e.g. observed == 0 should trigger the dead-inode reaping
1412/// path, not a 0 link count) is left as a TODO and the audit still
1413/// reports the underlying anomaly.
1414fn repair_link_count(
1415 fs: &Filesystem,
1416 ino: u32,
1417 observed: u32,
1418 report: &mut AuditReport,
1419) -> Result<()> {
1420 // Safety net: don't write 0 into i_links_count. A 0 nlink is the
1421 // contract for "this inode is unreachable, reaper will dispose of
1422 // it" — the right fix in that case is unlink+free, not a count
1423 // patch. Leave the anomaly as-is and let a future repair pass
1424 // (with orphan-relink wired up) handle it.
1425 if observed == 0 || observed > u16::MAX as u32 {
1426 // TODO: hook into orphan recovery for observed==0; for now,
1427 // surface the mismatch unchanged.
1428 return Ok(());
1429 }
1430 let (inode, mut raw) = fs.read_inode_verified(ino)?;
1431 // A directory past MAX_LINKS is written as the DIR_NLINK "too many
1432 // to count" value, as e2fsck pass 4 does; without DIR_NLINK there is
1433 // no count to write.
1434 let count = if inode.is_dir() && observed > crate::fs::MAX_LINKS as u32 {
1435 if fs.sb.feature_ro_compat & crate::features::RoCompat::DIR_NLINK.bits() == 0 {
1436 return Ok(());
1437 }
1438 1u16
1439 } else {
1440 observed as u16
1441 };
1442 raw[0x1A..0x1C].copy_from_slice(&count.to_le_bytes());
1443 finalize_and_commit_inode(fs, ino, inode.generation, &mut raw)?;
1444 report.repaired_count += 1;
1445 Ok(())
1446}
1447
1448/// Repair a `WrongDotDot` finding by rewriting the directory's ".."
1449/// dirent to point at `actual_parent`.
1450///
1451/// In ext4, a non-empty directory's ".." entry always lives in the
1452/// first data block (logical block 0) — the kernel writes it there at
1453/// directory creation time, immediately after the "." entry, and
1454/// nothing ever moves it. So we read just block 0, find the entry
1455/// with name "..", overwrite its 4-byte inode field, recompute the
1456/// per-block CRC tail (when metadata_csum is on), and commit through
1457/// the journal.
1458///
1459/// Out-of-scope cases that bail without bumping `repaired_count`:
1460/// - `dir_ino` is no longer a directory (raced delete during audit).
1461/// - Block 0 is unallocated (empty directory — ".." can't exist
1462/// without "."; treat as nothing-to-fix).
1463/// - The ".." entry isn't found in block 0 (corruption broader than
1464/// what this repair handles).
1465fn repair_wrong_dotdot(
1466 fs: &Filesystem,
1467 dir_ino: u32,
1468 actual_parent: u32,
1469 report: &mut AuditReport,
1470) -> Result<()> {
1471 let (dir_inode, _raw) = fs.read_inode_verified(dir_ino)?;
1472 if !dir_inode.is_dir() {
1473 return Ok(());
1474 }
1475 let bs = fs.sb.block_size();
1476 let Some(phys) = fs.map_inode_logical(&dir_inode, 0)? else {
1477 return Ok(());
1478 };
1479 let mut buf = BlockBuffer::new(bs);
1480 let block = buf.get_mut(fs, phys)?;
1481 if !may_restamp(fs, dir_ino, &dir_inode, 0, block) {
1482 // Its checksum fails and repair declined to fix it: restamping it
1483 // here would launder whatever it holds (#344).
1484 return Ok(());
1485 }
1486 // An indexed directory's block 0 is its dx_root, whose checksum is the
1487 // dx_tail's and covers `..`. A kernel-grown root also ends in bytes
1488 // that look like a dirent tail (#233); stamping one there would
1489 // overwrite the index's own tail.
1490 let index = Filesystem::is_htree_index_block(&dir_inode, 0, block);
1491 if index && block.get(29) != Some(&8) {
1492 return Ok(());
1493 }
1494 let reserved_tail = if !index && fs.csum.enabled && dir::has_csum_tail(block) {
1495 12
1496 } else {
1497 0
1498 };
1499 let usable_end = block.len().saturating_sub(reserved_tail);
1500
1501 // Walk the dirent records in block 0. Same shape as the audit
1502 // walker uses, just inline so we can mutate in place.
1503 let mut off = 0usize;
1504 let mut found = false;
1505 while off + 8 <= usable_end {
1506 let rec_len = u16::from_le_bytes([block[off + 4], block[off + 5]]) as usize;
1507 if rec_len == 0 || off + rec_len > usable_end {
1508 break;
1509 }
1510 let name_len = block[off + 6] as usize;
1511 let name_start = off + 8;
1512 let name_end = name_start + name_len;
1513 if name_end <= off + rec_len && &block[name_start..name_end] == b".." {
1514 block[off..off + 4].copy_from_slice(&actual_parent.to_le_bytes());
1515 found = true;
1516 break;
1517 }
1518 off += rec_len;
1519 }
1520 if !found {
1521 return Ok(());
1522 }
1523
1524 // Recompute the block's checksum: the index's for a dx_root, else the
1525 // dirent tail's when metadata_csum reserved one.
1526 if index {
1527 fs.csum
1528 .patch_dx_tail(dir_ino, dir_inode.generation, block, 32);
1529 } else if fs.csum.enabled && reserved_tail == 12 {
1530 fs.csum
1531 .patch_dir_entry_tail(dir_ino, dir_inode.generation, block);
1532 }
1533
1534 fs.commit_block_buffer(buf)?;
1535 report.repaired_count += 1;
1536 Ok(())
1537}
1538
1539/// Repair a `BogusEntry` finding by rewriting the parent dirent's
1540/// `file_type` byte to match the child inode's actual mode bits.
1541///
1542/// The audit emits this when a parent's dirent claims its child is a
1543/// directory (`file_type == 2`) but the child's inode mode bits say
1544/// otherwise. The on-disk fix in the common case is one byte: change
1545/// the dirent's `file_type` to whatever the child actually is (regular
1546/// file, symlink, etc.). Same dir-block CRC recompute pattern as
1547/// `repair_wrong_dotdot`.
1548///
1549/// Out-of-scope cases that bail without bumping `repaired_count`
1550/// (audit will resurface on next pass):
1551/// - The FILETYPE incompat feature is off (the byte at offset 7 is
1552/// the high half of `name_len` rather than `file_type`; rewriting
1553/// would be silent corruption).
1554/// - The parent isn't a directory anymore (raced with rmdir).
1555/// - The child reads back AS a directory (raced with the audit; not
1556/// bogus anymore).
1557/// - The child is unreadable (proper handling needs unlink + orphan
1558/// accounting; left for a follow-up).
1559/// - The child's mode bits are zero or otherwise nonsensical.
1560fn repair_bogus_entry(
1561 fs: &Filesystem,
1562 parent_ino: u32,
1563 child_ino: u32,
1564 name: &[u8],
1565 report: &mut AuditReport,
1566) -> Result<()> {
1567 let has_ft = fs.sb.feature_incompat & features::Incompat::FILETYPE.bits() != 0;
1568 if !has_ft {
1569 return Ok(());
1570 }
1571 if name.is_empty() {
1572 // Defensive: detection always populates `name` for non-root
1573 // BogusEntry findings. An empty name would mean "first match
1574 // by inode," which is exactly the hardlink-ambiguity bug we
1575 // moved away from. Bail rather than risk patching the wrong
1576 // dirent.
1577 return Ok(());
1578 }
1579
1580 let (parent_inode, _parent_raw) = fs.read_inode_verified(parent_ino)?;
1581 if !parent_inode.is_dir() {
1582 return Ok(());
1583 }
1584
1585 // Read the child to determine its actual file_type. If it's
1586 // genuinely unreadable or genuinely a directory, bail.
1587 let child_filetype: DirEntryType = match fs.read_inode_verified(child_ino) {
1588 Ok((child_inode, _)) => match DirEntryType::from_mode(child_inode.mode) {
1589 DirEntryType::Directory | DirEntryType::Unknown => return Ok(()),
1590 t => t,
1591 },
1592 Err(_) => return Ok(()),
1593 };
1594
1595 // Walk parent dir blocks to find the dirent matching BOTH
1596 // (inode == child_ino) AND (name == this finding's name). The
1597 // (parent, name) pair is the unique key for a dirent — matching
1598 // by inode alone misfires when the parent has multiple hardlinks
1599 // to the same inode.
1600 let bs = fs.sb.block_size();
1601 let parent_blocks = parent_inode.size.div_ceil(bs as u64);
1602 let mut buf = BlockBuffer::new(bs);
1603 let mut found = false;
1604 for logical in 0..parent_blocks {
1605 let Some(phys) = fs.map_inode_logical(&parent_inode, logical)? else {
1606 continue;
1607 };
1608 let block = buf.get_mut(fs, phys)?;
1609 let reserved_tail = if fs.csum.enabled && dir::has_csum_tail(block) {
1610 12
1611 } else {
1612 0
1613 };
1614 let usable_end = block.len().saturating_sub(reserved_tail);
1615 let mut off = 0usize;
1616 let mut hit_off: Option<usize> = None;
1617 while off + 8 <= usable_end {
1618 let cur_inode =
1619 u32::from_le_bytes([block[off], block[off + 1], block[off + 2], block[off + 3]]);
1620 let rec_len = u16::from_le_bytes([block[off + 4], block[off + 5]]) as usize;
1621 if rec_len == 0 || off + rec_len > usable_end {
1622 break;
1623 }
1624 // FILETYPE feature is on (we bailed otherwise above), so
1625 // byte 6 is the full name_len; byte 7 is file_type.
1626 // Bound name comparison to the current record (8 +
1627 // name_len <= rec_len) so a malformed dirent with
1628 // name_len > rec_len can't read into the next record and
1629 // patch the wrong file_type byte.
1630 let name_len = block[off + 6] as usize;
1631 if cur_inode == child_ino
1632 && 8 + name_len <= rec_len
1633 && off + 8 + name_len <= usable_end
1634 && &block[off + 8..off + 8 + name_len] == name
1635 {
1636 hit_off = Some(off);
1637 break;
1638 }
1639 off += rec_len;
1640 }
1641 if let Some(off) = hit_off {
1642 if !may_restamp(fs, parent_ino, &parent_inode, logical, block) {
1643 // Its checksum fails and repair declined to fix it (#344).
1644 return Ok(());
1645 }
1646 block[off + 7] = child_filetype as u8;
1647 if fs.csum.enabled && reserved_tail == 12 {
1648 fs.csum
1649 .patch_dir_entry_tail(parent_ino, parent_inode.generation, block);
1650 }
1651 found = true;
1652 break;
1653 }
1654 }
1655
1656 if found {
1657 fs.commit_block_buffer(buf)?;
1658 report.repaired_count += 1;
1659 }
1660 Ok(())
1661}
1662
1663/// Repair a `DirBlockChecksumMismatch` finding, as e2fsck does for a block
1664/// that "passes checks but fails checksum": recompute the dirent tail's
1665/// checksum over the block as it stands.
1666///
1667/// Only a linear block that passes the structural checks. A block that
1668/// does not is one e2fsck salvages entry by entry, and an htree index is
1669/// one it rebuilds; neither is done here, so both stay reported, and every
1670/// other repair refuses to restamp them (`may_restamp`).
1671fn repair_dir_block_checksum(
1672 fs: &Filesystem,
1673 dir_ino: u32,
1674 logical: u64,
1675 htree: bool,
1676 report: &mut AuditReport,
1677) -> Result<()> {
1678 if htree {
1679 return Ok(());
1680 }
1681 let (dir_inode, _raw) = fs.read_inode_verified(dir_ino)?;
1682 if !dir_inode.is_dir() {
1683 return Ok(());
1684 }
1685 let Some(phys) = fs.map_inode_logical(&dir_inode, logical)? else {
1686 return Ok(());
1687 };
1688 let has_ft = fs.sb.feature_incompat & features::Incompat::FILETYPE.bits() != 0;
1689 let mut buf = BlockBuffer::new(fs.sb.block_size());
1690 let block = buf.get_mut(fs, phys)?;
1691 if dir_block_checksum(fs, dir_ino, &dir_inode, logical, block)
1692 != (DirBlockChecksum::Mismatch { htree: false })
1693 || !dir_block_passes_checks(block, has_ft)
1694 {
1695 return Ok(());
1696 }
1697 fs.csum
1698 .patch_dir_entry_tail(dir_ino, dir_inode.generation, block);
1699 fs.commit_block_buffer(buf)?;
1700 report.repaired_count += 1;
1701 Ok(())
1702}
1703
1704/// Repair a `BlockGroupFreeCountDrift` finding by patching the
1705/// descriptor's free-block / free-inode counters to match the bitmap
1706/// reality. Reuses `Filesystem::patch_bgd_counters` (the same path the
1707/// allocator already uses for live counter updates), which handles
1708/// the lo+hi 64-bit fields and recomputes the GD checksum when
1709/// `metadata_csum` is on.
1710fn repair_block_group_free_counts(
1711 fs: &Filesystem,
1712 group_index: u32,
1713 stored_blocks: u32,
1714 observed_blocks: u32,
1715 stored_inodes: u32,
1716 observed_inodes: u32,
1717 report: &mut AuditReport,
1718) -> Result<()> {
1719 let block_delta = (observed_blocks as i64) - (stored_blocks as i64);
1720 let inode_delta = (observed_inodes as i64) - (stored_inodes as i64);
1721 if block_delta == 0 && inode_delta == 0 {
1722 // Nothing to do; raced with another writer or audit was wrong.
1723 return Ok(());
1724 }
1725 if block_delta < i32::MIN as i64
1726 || block_delta > i32::MAX as i64
1727 || inode_delta < i32::MIN as i64
1728 || inode_delta > i32::MAX as i64
1729 {
1730 // Drift larger than i32 in a single group is implausible (a
1731 // group's blocks_per_group is bounded by 8 * block_size, which
1732 // tops out around 32k for 4 KiB blocks). Bail rather than
1733 // truncate.
1734 return Ok(());
1735 }
1736 fs.patch_bgd_counters(
1737 group_index as usize,
1738 block_delta as i32,
1739 inode_delta as i32,
1740 0,
1741 )?;
1742 report.repaired_count += 1;
1743 Ok(())
1744}
1745
1746/// Repair a `SuperblockFreeCountDrift` finding by patching the
1747/// superblock totals. Reuses `Filesystem::patch_sb_counters` so the SB
1748/// checksum is recomputed on metadata_csum images.
1749fn repair_superblock_free_counts(
1750 fs: &Filesystem,
1751 stored_blocks: u64,
1752 observed_blocks: u64,
1753 stored_inodes: u32,
1754 observed_inodes: u32,
1755 report: &mut AuditReport,
1756) -> Result<()> {
1757 let block_delta = (observed_blocks as i64) - (stored_blocks as i64);
1758 let inode_delta = (observed_inodes as i64) - (stored_inodes as i64);
1759 if block_delta == 0 && inode_delta == 0 {
1760 return Ok(());
1761 }
1762 if inode_delta < i32::MIN as i64 || inode_delta > i32::MAX as i64 {
1763 return Ok(());
1764 }
1765 fs.patch_sb_counters(block_delta, inode_delta as i32)?;
1766 report.repaired_count += 1;
1767 Ok(())
1768}
1769
1770/// Recompute the inode's CRC32C (when enabled) and commit the inode
1771/// back through the journal writer. Inode-only mutations get a single
1772/// journal txn — matches how `commit_inode_write` does chmod / chown.
1773fn finalize_and_commit_inode(
1774 fs: &Filesystem,
1775 ino: u32,
1776 generation: u32,
1777 raw: &mut [u8],
1778) -> Result<()> {
1779 if fs.csum.enabled {
1780 fs.csum.patch_inode_checksum(ino, generation, raw);
1781 }
1782 let mut buf = BlockBuffer::new(fs.sb.block_size());
1783 fs.buffer_write_inode(&mut buf, ino, raw)?;
1784 fs.commit_block_buffer(buf)
1785}
1786
1787impl Filesystem {
1788 /// Run an ext4 audit tool-style read-only audit.
1789 ///
1790 /// Walks from root, counts how many directory entries reference
1791 /// each inode, and compares that against each inode's
1792 /// `i_links_count`. Returns an [`AuditReport`] — empty
1793 /// `anomalies` means every invariant we check held.
1794 ///
1795 /// The pass is bounded: never visits more than
1796 /// `max_dirs_visited` directories and never scans more than
1797 /// `max_entries_per_dir` entries within a single directory.
1798 /// Pass `u32::MAX` for an unbounded pass.
1799 pub fn audit(&self, max_dirs_visited: u32, max_entries_per_dir: u32) -> Result<AuditReport> {
1800 audit(self, max_dirs_visited, max_entries_per_dir)
1801 }
1802
1803 /// Audit + repair convenience wrapper. See [`audit_with_repair`]
1804 /// for semantics. No-op on read-only mounts when `repair == true`
1805 /// (returns `Error::ReadOnly`).
1806 pub fn audit_repair(
1807 &self,
1808 max_dirs_visited: u32,
1809 max_entries_per_dir: u32,
1810 repair: bool,
1811 ) -> Result<AuditReport> {
1812 audit_with_repair(
1813 self,
1814 max_dirs_visited,
1815 max_entries_per_dir,
1816 |_, _, _| {},
1817 |_| {},
1818 repair,
1819 )
1820 }
1821}