Skip to main content

fs_ext4/
journal.rs

1//! JBD2 transaction walker — read-only replay-plan producer.
2//!
3//! Walks the on-disk journal starting at `jsb.start`, decodes descriptor /
4//! commit / revoke blocks, and yields a `ReplayPlan` describing what WOULD
5//! be written during replay. This module only reads; the plan goes to
6//! [`crate::journal_apply::apply`], which writes each block to its final
7//! location when a writable mount replays a dirty journal.
8//!
9//! All JBD2 on-disk data is **big-endian**. Journal block numbers are
10//! relative to the journal file (logical block 0 = superblock); the caller
11//! resolves them to physical fs blocks via [`jbd2::journal_block_to_physical`].
12//!
13//! Tag layout per the "Descriptor Block" section of the kernel.org journal
14//! format documentation, confirmed against journals the kernel and
15//! e2fsprogs write:
16//!
17//! - **Classical tag** (JBD1 / no CSUM_V3): at least 8 bytes
18//!   - `__be32 t_blocknr`         (low 32 bits of fs block)
19//!   - `__be16 t_checksum`        (per-block crc, valid with CSUM_V2 only)
20//!   - `__be16 t_flags`
21//!   - `__be32 t_blocknr_high`    (iff INCOMPAT_BIT64)
22//!   - If `SAME_UUID` flag is not set, a 16-byte UUID follows inline.
23//!
24//!   - The tag is 12 bytes with BIT64 and 8 without, plus 2 with CSUM_V2.
25//!
26//! - **CSUM_V3 tag** (JBD2_FEATURE_INCOMPAT_CSUM_V3): 16 bytes
27//!   - `__be32 t_blocknr`
28//!   - `__be32 t_flags`
29//!   - `__be32 t_blocknr_high`    (iff INCOMPAT_BIT64; zero and still present otherwise)
30//!   - `__be32 t_checksum`        (crc32c of sequence+block)
31//!   - If `SAME_UUID` is not set, a 16-byte UUID follows, as in the classical layout.
32//!
33//! Tag flags:
34//!   - `0x1` ESCAPED   — block begins with JBD magic; first 4 bytes were zeroed during write
35//!   - `0x2` SAME_UUID — skip the inline UUID (same as previous tag's)
36//!   - `0x4` DELETED   — historical; not used in modern JBD2
37//!   - `0x8` LAST_TAG  — final tag in this descriptor block
38//!
39//! Commit block: a `journal_header_t`, with `h_chksum[0]` at offset 16 on a
40//! checksummed journal.
41//! Revoke block: `journal_header_t` + `__be32 r_count` + array of `__be32`/`__be64`
42//! fs block numbers (depending on INCOMPAT_BIT64); records blocks whose
43//! contents in earlier transactions should NOT be replayed.
44//!
45//! ### What is believed (#81)
46//!
47//! A transaction's writes and revokes join the plan only once its commit
48//! block has been read and, on a CSUM_V2/V3 journal, has passed its
49//! checksum. A commit block failing its checksum ends the log there: the
50//! format documentation says a transaction with no commit record, or one
51//! whose checksums do not match, is discarded during replay, and that is
52//! what a crash mid-commit leaves. A
53//! committed transaction -- one whose commit checks out -- with a
54//! descriptor, data or revoke block failing its checksum is corruption
55//! rather than a crash, and the walk refuses with [`Error::BadChecksum`]
56//! before anything is replayed. A journal declaring
57//! an incompat feature outside [`jbd2::SUPPORTED_JBD_INCOMPAT`], a checksum
58//! declaration JBD2 refuses, or a block size other than the filesystem's, is
59//! refused outright.
60
61use crate::error::{Error, Result};
62use crate::fs::Filesystem;
63use crate::inode::Inode;
64use crate::jbd2::{
65    self, JournalSuperblock, JBD2_COMMIT_BLOCK, JBD2_DESCRIPTOR_BLOCK, JBD2_MAGIC_NUMBER,
66    JBD2_REVOKE_BLOCK,
67};
68
69/// Tag flag bits.
70pub const TAG_ESCAPED: u32 = 0x1;
71pub const TAG_SAME_UUID: u32 = 0x2;
72pub const TAG_DELETED: u32 = 0x4;
73pub const TAG_LAST: u32 = 0x8;
74
75/// One entry in the replay plan: "during replay, fs_block should receive the
76/// contents of journal_block". Callers wanting the actual bytes still need to
77/// read them from the journal — we only produce the plan.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ReplayEntry {
80    /// Sequence number of the containing transaction.
81    pub transaction: u32,
82    /// Target fs block to overwrite.
83    pub fs_block: u64,
84    /// Source journal block holding the new contents.
85    pub journal_block: u64,
86    /// Tag flags (ESCAPED/SAME_UUID/etc). ESCAPED means the reader must
87    /// restore the first 4 bytes to the JBD2 magic before writing.
88    pub flags: u32,
89}
90
91/// A revoke record: transaction N tells replay "do NOT overwrite this fs block
92/// from any transaction with sequence <= N".
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct RevokeEntry {
95    pub transaction: u32,
96    pub fs_block: u64,
97}
98
99/// Ordered list of writes + revocations produced by walking the log.
100#[derive(Debug, Default, Clone)]
101pub struct ReplayPlan {
102    pub writes: Vec<ReplayEntry>,
103    pub revokes: Vec<RevokeEntry>,
104    /// Last committed transaction sequence seen.
105    pub last_commit: u32,
106    /// Sequence to allocate after the last complete transaction (None if none).
107    pub next_sequence: Option<u32>,
108    /// Number of journal blocks walked (for sanity / tests).
109    pub blocks_walked: u64,
110}
111
112impl ReplayPlan {
113    /// Apply revoke records to the write list: drop any write whose fs_block
114    /// was revoked by a later-or-equal transaction: the rule the format
115    /// documentation gives for revoke blocks ("Revocation Block").
116    pub fn filter_revoked(&mut self) {
117        if self.revokes.is_empty() {
118            return;
119        }
120        // For each fs_block, track the highest transaction seen in a revoke.
121        let mut highest: std::collections::HashMap<u64, u32> = std::collections::HashMap::new();
122        for r in &self.revokes {
123            let e = highest.entry(r.fs_block).or_insert(r.transaction);
124            if sequence_after(r.transaction, *e) {
125                *e = r.transaction;
126            }
127        }
128        self.writes.retain(|w| match highest.get(&w.fs_block) {
129            Some(&t) => sequence_after(w.transaction, t),
130            None => true,
131        });
132    }
133}
134
135/// JBD2 transaction IDs wrap at u32, so they are compared in wrapping
136/// serial-number order (RFC 1982): by the sign of their wrapping difference.
137fn sequence_after(a: u32, b: u32) -> bool {
138    (a.wrapping_sub(b) as i32) > 0
139}
140
141/// Walk the journal from `jsb.start` and build a [`ReplayPlan`].
142///
143/// Returns `Ok(ReplayPlan::default())` when the journal is clean (no writes,
144/// no revokes). Stops at the first block whose header is not a valid
145/// JBD2 block, or once a transaction's commit block has been processed and
146/// the sequence number rolls past what we expected.
147pub fn walk(fs: &Filesystem, jsb: &JournalSuperblock) -> Result<ReplayPlan> {
148    let mut plan = ReplayPlan::default();
149    if jsb.is_clean() {
150        return Ok(plan);
151    }
152
153    if jsb.unsupported_incompat() != 0 {
154        return Err(Error::Unsupported(
155            "journal declares an incompat feature this driver cannot replay",
156        ));
157    }
158    if let Some(why) = jsb.checksum_declaration_error() {
159        return Err(Error::Corrupt(why));
160    }
161    if jsb.block_size != fs.sb.block_size() {
162        return Err(Error::Corrupt(
163            "journal block size differs from the filesystem's",
164        ));
165    }
166    if jsb.first == 0
167        || jsb.first >= jsb.max_len
168        || jsb.start < jsb.first
169        || jsb.start >= jsb.max_len
170    {
171        return Err(Error::Corrupt("invalid journal geometry"));
172    }
173
174    let raw = fs.read_inode_raw(fs.sb.journal_inode)?;
175    let jinode = Inode::parse(&raw)?;
176    let block_size = jsb.block_size as u64;
177    let mut cur = jsb.start as u64;
178    let mut expect_seq = jsb.sequence;
179    let seed = jsb.uses_csum_v2_or_v3().then(|| jsb.csum_seed());
180    // The open transaction's writes and revokes, moved into the plan only
181    // when its commit block checks out. A data or revoke block failing its
182    // checksum is judged at the commit: in a transaction the crash tore it
183    // is only the end of the log; in a committed one it is corruption.
184    let mut pending = ReplayPlan::default();
185    let mut pending_corrupt: Option<&'static str> = None;
186
187    // Upper bound: never scan more than the whole ring once, counting the
188    // descriptor payload blocks as well as the metadata blocks. A real
189    // replay follows a circular log; here we stop when a block header does
190    // not match the expected pattern or when we hit the sb block (0).
191    let limit = u64::from(jsb.max_len - jsb.first);
192
193    while plan.blocks_walked < limit {
194        let block_buf = read_journal_block(fs, &jinode, cur, block_size)?;
195        plan.blocks_walked += 1;
196
197        // Every JBD2 metadata block begins with a 12-byte header whose magic
198        // MUST be JBD2_MAGIC_NUMBER. A block without the magic is a data
199        // block (referenced by a preceding descriptor tag) — we stop here.
200        let Some(hdr) = try_parse_header(&block_buf) else {
201            break;
202        };
203
204        // If the header's sequence is not what we expected, we've walked off
205        // the end of valid transactions.
206        if hdr.sequence != expect_seq {
207            break;
208        }
209
210        match hdr.block_type {
211            JBD2_DESCRIPTOR_BLOCK => {
212                // A descriptor failing its checksum is judged at the commit,
213                // as the kernel's journal recovery judges it: with no
214                // valid commit after it, a crash tore the transaction and the
215                // log ends; with one, the log is corrupt. Its tags still say
216                // how many data blocks to step over, if they parse at all.
217                let torn = seed.is_some_and(|seed| !tail_checksum_matches(seed, &block_buf));
218                let tags = match parse_descriptor_tags(&block_buf, &mut cur, jsb, expect_seq) {
219                    Ok(tags) => tags,
220                    Err(_) if torn => break,
221                    Err(e) => return Err(e),
222                };
223                if torn {
224                    pending_corrupt = Some("journal descriptor block of a committed transaction");
225                }
226                plan.blocks_walked += tags.len() as u64;
227                if plan.blocks_walked > limit {
228                    return Err(Error::Corrupt("journal transaction exceeds ring"));
229                }
230                for (entry, stored) in tags {
231                    if let Some(seed) = seed {
232                        let data =
233                            read_journal_block(fs, &jinode, entry.journal_block, block_size)?;
234                        let computed = jbd2::tag_checksum(seed, expect_seq, &data);
235                        let computed = if jsb.uses_csum_v3() {
236                            computed
237                        } else {
238                            computed & 0xFFFF
239                        };
240                        if computed != stored {
241                            pending_corrupt = Some("journal data block of a committed transaction");
242                        }
243                    }
244                    pending.writes.push(entry);
245                }
246            }
247            JBD2_COMMIT_BLOCK => {
248                if let Some(seed) = seed {
249                    let stored = u32::from_be_bytes(
250                        block_buf[jbd2::COMMIT_CHECKSUM_AT..jbd2::COMMIT_CHECKSUM_AT + 4]
251                            .try_into()
252                            .unwrap(),
253                    );
254                    if jbd2::commit_block_checksum(seed, &block_buf) != stored {
255                        break;
256                    }
257                }
258                if let Some(what) = pending_corrupt {
259                    return Err(Error::BadChecksum { what });
260                }
261                plan.writes.append(&mut pending.writes);
262                plan.revokes.append(&mut pending.revokes);
263                plan.last_commit = expect_seq;
264                expect_seq = expect_seq.wrapping_add(1);
265                plan.next_sequence = Some(expect_seq);
266            }
267            JBD2_REVOKE_BLOCK => {
268                if seed.is_some_and(|seed| !tail_checksum_matches(seed, &block_buf)) {
269                    pending_corrupt = Some("journal revoke block of a committed transaction");
270                }
271                let revokes = parse_revoke_block(&block_buf, jsb, expect_seq)?;
272                pending.revokes.extend(revokes);
273            }
274            _ => break,
275        }
276
277        cur = advance(cur, 1, jsb);
278    }
279
280    plan.filter_revoked();
281    Ok(plan)
282}
283
284/// Advance a journal cursor by `n` blocks, wrapping around the circular log.
285///
286/// The log lives in `[jsb.first .. jsb.first + jsb.max_len - 1]` (block 0 is
287/// the superblock, so `first` is typically 1). When the cursor reaches the
288/// end it wraps back to `first`.
289fn advance(cur: u64, n: u64, jsb: &JournalSuperblock) -> u64 {
290    let first = jsb.first as u64;
291    let capacity = (jsb.max_len as u64).saturating_sub(first);
292    if capacity == 0 {
293        return cur; // degenerate journal
294    }
295    let offset = cur - first;
296    let new_off = (offset + n) % capacity;
297    first + new_off
298}
299
300struct JournalHeader {
301    block_type: u32,
302    sequence: u32,
303}
304
305/// Whether a descriptor or revoke block's four-byte tail holds its checksum.
306fn tail_checksum_matches(seed: u32, block: &[u8]) -> bool {
307    let at = block.len() - jbd2::BLOCK_TAIL_BYTES;
308    let stored = u32::from_be_bytes(block[at..].try_into().unwrap());
309    jbd2::block_tail_checksum(seed, block) == stored
310}
311
312fn try_parse_header(block: &[u8]) -> Option<JournalHeader> {
313    if block.len() < 12 {
314        return None;
315    }
316    let magic = u32::from_be_bytes(block[0..4].try_into().ok()?);
317    if magic != JBD2_MAGIC_NUMBER {
318        return None;
319    }
320    Some(JournalHeader {
321        block_type: u32::from_be_bytes(block[4..8].try_into().ok()?),
322        sequence: u32::from_be_bytes(block[8..12].try_into().ok()?),
323    })
324}
325
326/// Read one journal block (journal-relative block number) by resolving it
327/// through the journal inode's extent tree.
328fn read_journal_block(
329    fs: &Filesystem,
330    jinode: &Inode,
331    journal_block: u64,
332    block_size: u64,
333) -> Result<Vec<u8>> {
334    let phys = jbd2::journal_block_to_physical(fs, jinode, journal_block)?
335        .ok_or(Error::Corrupt("journal block unmapped"))?;
336    let mut buf = vec![0u8; block_size as usize];
337    fs.dev.read_at(phys * block_size, &mut buf)?;
338    Ok(buf)
339}
340
341/// Parse the tag array that follows a descriptor header. Each tag names one
342/// data block that will appear next in the journal. Advances `cur` past those
343/// data blocks (they are consumed, not walked for JBD2 headers). Each entry
344/// comes with the tag's stored checksum: all 32 bits of a CSUM_V3 tag, the
345/// 16 of a classical one.
346fn parse_descriptor_tags(
347    block: &[u8],
348    cur: &mut u64,
349    jsb: &JournalSuperblock,
350    transaction: u32,
351) -> Result<Vec<(ReplayEntry, u32)>> {
352    let mut out = Vec::new();
353    let uses_64bit = jsb.uses_64bit();
354    let uses_v3 = jsb.uses_csum_v3();
355    let tag_size = jsb.tag_bytes();
356    // A checksummed journal keeps the last four bytes for the block's tail.
357    let usable = if jsb.uses_csum_v2_or_v3() {
358        block.len() - jbd2::BLOCK_TAIL_BYTES
359    } else {
360        block.len()
361    };
362
363    let mut pos = 12usize; // skip header
364    loop {
365        if pos + tag_size > usable {
366            return Err(Error::Corrupt("descriptor tag overruns block"));
367        }
368
369        let blocknr_lo = u32::from_be_bytes(block[pos..pos + 4].try_into().unwrap());
370
371        let (flags, checksum) = if uses_v3 {
372            (
373                u32::from_be_bytes(block[pos + 4..pos + 8].try_into().unwrap()),
374                u32::from_be_bytes(block[pos + 12..pos + 16].try_into().unwrap()),
375            )
376        } else {
377            // Classic: __be16 t_checksum + __be16 t_flags at offset 4
378            (
379                u16::from_be_bytes(block[pos + 6..pos + 8].try_into().unwrap()) as u32,
380                u16::from_be_bytes(block[pos + 4..pos + 6].try_into().unwrap()) as u32,
381            )
382        };
383        // Every layout carries the UUID inline unless the tag says it is the
384        // previous one's.
385        let uuid_follows_size = if flags & TAG_SAME_UUID != 0 { 0 } else { 16 };
386
387        let blocknr = if uses_64bit {
388            let hi = u32::from_be_bytes(block[pos + 8..pos + 12].try_into().unwrap());
389            ((hi as u64) << 32) | (blocknr_lo as u64)
390        } else {
391            blocknr_lo as u64
392        };
393
394        // Advance the journal cursor for the data block that accompanies this tag.
395        *cur = advance(*cur, 1, jsb);
396        let data_journal_block = *cur;
397
398        out.push((
399            ReplayEntry {
400                transaction,
401                fs_block: blocknr,
402                journal_block: data_journal_block,
403                flags,
404            },
405            checksum,
406        ));
407
408        pos += tag_size + uuid_follows_size;
409
410        if flags & TAG_LAST != 0 {
411            break;
412        }
413        if pos + tag_size > usable {
414            // Ran off the end without a LAST_TAG — descriptor is full.
415            break;
416        }
417    }
418    Ok(out)
419}
420
421fn parse_revoke_block(
422    block: &[u8],
423    jsb: &JournalSuperblock,
424    transaction: u32,
425) -> Result<Vec<RevokeEntry>> {
426    // Layout: header (12) + __be32 r_count (total bytes incl. header) + records.
427    if block.len() < 16 {
428        return Err(Error::Corrupt("revoke block too small"));
429    }
430    let r_count = u32::from_be_bytes(block[12..16].try_into().unwrap()) as usize;
431    let record_size = if jsb.uses_64bit() { 8 } else { 4 };
432    // `r_count` is bounded by the block less its `r_checksum` tail, never
433    // clamped to it: a larger count is corrupt (e2fsck refuses it too), and
434    // reading on would take the checksum for a record (#301).
435    let usable = if jsb.uses_csum_v2_or_v3() {
436        block.len() - jbd2::BLOCK_TAIL_BYTES
437    } else {
438        block.len()
439    };
440    if r_count > usable {
441        return Err(Error::Corrupt(
442            "journal revoke block count exceeds the block",
443        ));
444    }
445    let mut out = Vec::new();
446    let mut pos = 16usize;
447    let end = r_count;
448    while pos + record_size <= end {
449        let fs_block = if jsb.uses_64bit() {
450            u64::from_be_bytes(block[pos..pos + 8].try_into().unwrap())
451        } else {
452            u32::from_be_bytes(block[pos..pos + 4].try_into().unwrap()) as u64
453        };
454        out.push(RevokeEntry {
455            transaction,
456            fs_block,
457        });
458        pos += record_size;
459    }
460    Ok(out)
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::jbd2::{JbdIncompat, JBD2_SUPERBLOCK_V2};
467
468    fn mk_jsb(incompat: u32, max_len: u32) -> JournalSuperblock {
469        JournalSuperblock {
470            block_type: JBD2_SUPERBLOCK_V2,
471            header_sequence: 1,
472            block_size: 4096,
473            max_len,
474            first: 1,
475            sequence: 10,
476            start: 1,
477            errno: 0,
478            feature_compat: 0,
479            feature_incompat: incompat,
480            feature_ro_compat: 0,
481            uuid: [0; 16],
482            nr_users: 1,
483            checksum_type: 4,
484            num_fc_blocks: 0,
485            checksum: 0,
486        }
487    }
488
489    fn header(buf: &mut [u8], block_type: u32, seq: u32) {
490        buf[0..4].copy_from_slice(&JBD2_MAGIC_NUMBER.to_be_bytes());
491        buf[4..8].copy_from_slice(&block_type.to_be_bytes());
492        buf[8..12].copy_from_slice(&seq.to_be_bytes());
493    }
494
495    #[test]
496    fn revoke_order_wraps_like_jbd2_transaction_ids() {
497        let mut plan = ReplayPlan {
498            writes: vec![
499                ReplayEntry {
500                    transaction: u32::MAX,
501                    fs_block: 3,
502                    journal_block: 1,
503                    flags: 0,
504                },
505                ReplayEntry {
506                    transaction: 1,
507                    fs_block: 3,
508                    journal_block: 2,
509                    flags: 0,
510                },
511            ],
512            revokes: vec![
513                RevokeEntry {
514                    transaction: u32::MAX - 1,
515                    fs_block: 3,
516                },
517                RevokeEntry {
518                    transaction: 0,
519                    fs_block: 3,
520                },
521            ],
522            ..Default::default()
523        };
524        plan.filter_revoked();
525        assert_eq!(plan.writes.len(), 1);
526        assert_eq!(plan.writes[0].transaction, 1);
527    }
528
529    #[test]
530    fn revoke_records_scale_with_r_count() {
531        let jsb = mk_jsb(JbdIncompat::REVOKE.bits(), 128);
532        let mut blk = vec![0u8; 4096];
533        header(&mut blk, JBD2_REVOKE_BLOCK, 10);
534        // r_count = 16 (header) + 3*4 (three 32-bit records) = 28
535        blk[12..16].copy_from_slice(&28u32.to_be_bytes());
536        blk[16..20].copy_from_slice(&100u32.to_be_bytes());
537        blk[20..24].copy_from_slice(&200u32.to_be_bytes());
538        blk[24..28].copy_from_slice(&300u32.to_be_bytes());
539        let out = parse_revoke_block(&blk, &jsb, 10).unwrap();
540        assert_eq!(out.len(), 3);
541        assert_eq!(out[0].fs_block, 100);
542        assert_eq!(out[2].fs_block, 300);
543    }
544
545    /// A revoke block claiming more bytes than it may hold, for a journal
546    /// with `incompat` and a planned write to `fs_block` at `tail_at`.
547    fn revoke_block_overrunning_its_records(
548        r_count: u32,
549        tail_at: usize,
550        fs_block: u32,
551    ) -> Vec<u8> {
552        let mut blk = vec![0u8; 4096];
553        header(&mut blk, JBD2_REVOKE_BLOCK, 10);
554        blk[12..16].copy_from_slice(&r_count.to_be_bytes());
555        blk[16..20].copy_from_slice(&100u32.to_be_bytes());
556        blk[tail_at..tail_at + 4].copy_from_slice(&fs_block.to_be_bytes());
557        blk
558    }
559
560    /// On a CSUM_V3 journal the last four bytes are `r_checksum`, not a
561    /// record: an `r_count` reaching into them is refused, as e2fsck refuses
562    /// it, rather than letting the checksum
563    /// revoke a committed write (#301).
564    #[test]
565    fn a_csum_v3_revoke_count_reaching_the_tail_is_refused() {
566        let jsb = mk_jsb(
567            JbdIncompat::REVOKE.bits() | JbdIncompat::CSUM_V3.bits(),
568            128,
569        );
570        let blk = revoke_block_overrunning_its_records(4096, 4096 - jbd2::BLOCK_TAIL_BYTES, 9000);
571        let parsed = parse_revoke_block(&blk, &jsb, 10);
572        let mut plan = ReplayPlan {
573            writes: vec![ReplayEntry {
574                transaction: 10,
575                fs_block: 9000,
576                journal_block: 2,
577                flags: 0,
578            }],
579            revokes: parsed.as_ref().map(Vec::clone).unwrap_or_default(),
580            ..Default::default()
581        };
582        plan.filter_revoked();
583        assert!(
584            matches!(parsed, Err(Error::Corrupt(_))),
585            "r_count covering the checksum tail parsed as {} records; writes left in the plan: {}",
586            parsed.map(|r| r.len()).unwrap_or_default(),
587            plan.writes.len()
588        );
589    }
590
591    /// The same bound with the tail counted: `r_count` up to the tail is
592    /// every record the block holds.
593    #[test]
594    fn a_csum_v3_revoke_count_up_to_the_tail_is_read() {
595        let jsb = mk_jsb(
596            JbdIncompat::REVOKE.bits() | JbdIncompat::CSUM_V3.bits(),
597            128,
598        );
599        let usable = 4096 - jbd2::BLOCK_TAIL_BYTES;
600        let blk = revoke_block_overrunning_its_records(usable as u32, usable - 4, 9000);
601        let out = parse_revoke_block(&blk, &jsb, 10).unwrap();
602        assert_eq!(out.len(), (usable - 16) / 4);
603        assert_eq!(out[0].fs_block, 100);
604        assert_eq!(out.last().unwrap().fs_block, 9000);
605    }
606
607    /// Without checksums a count past the block is refused, not clamped.
608    #[test]
609    fn a_revoke_count_past_the_block_is_refused() {
610        let jsb = mk_jsb(JbdIncompat::REVOKE.bits(), 128);
611        let blk = revoke_block_overrunning_its_records(4097, 4092, 9000);
612        let parsed = parse_revoke_block(&blk, &jsb, 10);
613        assert!(
614            matches!(parsed, Err(Error::Corrupt(_))),
615            "r_count past the block parsed as {} records",
616            parsed.map(|r| r.len()).unwrap_or_default()
617        );
618        // A count exactly the block is every record in it.
619        let blk = revoke_block_overrunning_its_records(4096, 4092, 9000);
620        let out = parse_revoke_block(&blk, &jsb, 10).unwrap();
621        assert_eq!(out.len(), (4096 - 16) / 4);
622        assert_eq!(out.last().unwrap().fs_block, 9000);
623    }
624
625    #[test]
626    fn filter_revoked_drops_older_writes() {
627        let mut plan = ReplayPlan::default();
628        plan.writes.push(ReplayEntry {
629            transaction: 5,
630            fs_block: 100,
631            journal_block: 1,
632            flags: 0,
633        });
634        plan.writes.push(ReplayEntry {
635            transaction: 5,
636            fs_block: 200,
637            journal_block: 2,
638            flags: 0,
639        });
640        plan.writes.push(ReplayEntry {
641            transaction: 7,
642            fs_block: 100,
643            journal_block: 3,
644            flags: 0,
645        });
646        plan.revokes.push(RevokeEntry {
647            transaction: 6,
648            fs_block: 100,
649        });
650        plan.filter_revoked();
651        // fs_block 100 revoked at txn 6; txn 5 write dropped, txn 7 kept.
652        assert_eq!(plan.writes.len(), 2);
653        assert!(plan.writes.iter().any(|w| w.fs_block == 200));
654        assert!(plan
655            .writes
656            .iter()
657            .any(|w| w.fs_block == 100 && w.transaction == 7));
658    }
659
660    #[test]
661    fn parse_v2_tags_without_bit64() {
662        // classical tag (CSUM_V2 or none), no BIT64 → 8-byte tag + 16-byte UUID
663        // on the first one; SAME_UUID skips uuid on the second.
664        let jsb = mk_jsb(0, 128);
665        let mut blk = vec![0u8; 4096];
666        header(&mut blk, JBD2_DESCRIPTOR_BLOCK, 10);
667        // tag 1: blocknr=1000, flags=0 (UUID present)
668        blk[12..16].copy_from_slice(&1000u32.to_be_bytes());
669        blk[16..18].copy_from_slice(&0u16.to_be_bytes()); // checksum
670        blk[18..20].copy_from_slice(&0u16.to_be_bytes()); // flags = 0 → uuid present
671                                                          // 16-byte uuid at 20..36
672                                                          // tag 2: blocknr=2000, flags = SAME_UUID | LAST (0xA)
673        blk[36..40].copy_from_slice(&2000u32.to_be_bytes());
674        blk[40..42].copy_from_slice(&0u16.to_be_bytes());
675        blk[42..44].copy_from_slice(&((TAG_SAME_UUID | TAG_LAST) as u16).to_be_bytes());
676
677        let mut cur = 1u64;
678        let out = parse_descriptor_tags(&blk, &mut cur, &jsb, 10).unwrap();
679        assert_eq!(out.len(), 2);
680        assert_eq!(out[0].0.fs_block, 1000);
681        assert_eq!(out[1].0.fs_block, 2000);
682        assert_eq!(out[1].0.flags & TAG_LAST, TAG_LAST);
683    }
684
685    /// A CSUM_V3 tag is 16 bytes without BIT64 too, and a UUID follows one
686    /// without SAME_UUID -- `debugfs` writes exactly that first tag.
687    #[test]
688    fn parse_v3_tags_without_bit64_skip_the_uuid() {
689        let jsb = mk_jsb(JbdIncompat::CSUM_V3.bits(), 128);
690        let mut blk = vec![0u8; 4096];
691        header(&mut blk, JBD2_DESCRIPTOR_BLOCK, 10);
692        blk[12..16].copy_from_slice(&1000u32.to_be_bytes());
693        blk[24..28].copy_from_slice(&0xAABB_CCDDu32.to_be_bytes()); // t_checksum
694        blk[28..44].fill(0xEE); // uuid
695        blk[44..48].copy_from_slice(&2000u32.to_be_bytes());
696        blk[48..52].copy_from_slice(&(TAG_SAME_UUID | TAG_LAST).to_be_bytes());
697
698        let mut cur = 1u64;
699        let out = parse_descriptor_tags(&blk, &mut cur, &jsb, 10).unwrap();
700        assert_eq!(out.len(), 2);
701        assert_eq!(
702            out[0],
703            (
704                ReplayEntry {
705                    transaction: 10,
706                    fs_block: 1000,
707                    journal_block: 2,
708                    flags: 0
709                },
710                0xAABB_CCDD
711            )
712        );
713        assert_eq!(out[1].0.fs_block, 2000);
714        assert_eq!(out[1].0.journal_block, 3);
715    }
716
717    #[test]
718    fn try_parse_header_rejects_bad_magic() {
719        let mut blk = vec![0u8; 12];
720        blk[0] = 1;
721        assert!(try_parse_header(&blk).is_none());
722    }
723
724    #[test]
725    fn try_parse_header_accepts_valid() {
726        let mut blk = vec![0u8; 12];
727        header(&mut blk, JBD2_COMMIT_BLOCK, 42);
728        let h = try_parse_header(&blk).unwrap();
729        assert_eq!(h.block_type, JBD2_COMMIT_BLOCK);
730        assert_eq!(h.sequence, 42);
731    }
732
733    #[test]
734    fn advance_wraps_around_the_log() {
735        let jsb = mk_jsb(0, 8); // first=1, capacity=7 (blocks 1..7)
736        assert_eq!(advance(1, 1, &jsb), 2);
737        assert_eq!(advance(7, 1, &jsb), 1); // wrap
738        assert_eq!(advance(6, 5, &jsb), 4); // 6+5 = 11, offset 10 % 7 = 3, first+3=4
739    }
740
741    #[test]
742    fn clean_journal_produces_empty_plan() {
743        // We can exercise walk() without real I/O by checking the clean shortcut.
744        let mut jsb = mk_jsb(0, 128);
745        jsb.start = 0; // clean
746                       // walk() requires a full Filesystem which is heavier to fake; just
747                       // verify the guard triggers by reading the public code path — the
748                       // is_clean branch returns before any I/O.
749        assert!(jsb.is_clean());
750        let plan = ReplayPlan::default();
751        assert!(plan.writes.is_empty());
752        assert_eq!(plan.last_commit, 0);
753    }
754}