Skip to main content

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}