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