Skip to main content

fs_ext4/
journal_writer.rs

1//! JBD2 live-write side: serialize a transaction, write it to the journal
2//! inode, then apply the writes to their final fs locations and mark the
3//! journal clean.
4//!
5//! The replay-side dual is [`crate::journal_apply`] (which the mount path
6//! calls when the journal is dirty). The serialization dual is
7//! [`crate::transaction::Transaction`] (which builds the journal-format
8//! bytes). This module is the glue: it owns the journal inode's logical→
9//! physical map, the JBD2 superblock cursor (`start` + `sequence`), and
10//! the four-fence sequencing that makes a write crash-safe.
11//!
12//! ## Crash-safety contract — "journal then immediate checkpoint"
13//!
14//! Each [`JournalWriter::commit`] call performs five fenced steps:
15//!
16//! ```text
17//!   1. Write transaction blocks (descriptor + data + commit) to journal
18//!      logical blocks [1, 1+N), set `needs_recovery` in the ext4
19//!      superblock → flush.
20//!   2. Set jsb.start = 1 (mark journal dirty), keep jsb.sequence as-is →
21//!      flush.
22//!   3. Write each fs block in the transaction to its final on-disk
23//!      location → flush.
24//!   4. Set jsb.start = 0 (clean), bump jsb.sequence, clear
25//!      `needs_recovery` → flush.
26//! ```
27//!
28//! `needs_recovery` (`INCOMPAT_RECOVER`) is what Linux replays on: a
29//! filesystem without it has its journal wiped at mount, whatever `s_start`
30//! says (#228). So it reaches the disk before the journal is marked dirty,
31//! and a transaction that journals the superblock block carries it set,
32//! or step 3 would clear it while the journal is still live. Clearing it
33//! in the same flush as the clean journal superblock is safe either way
34//! round: by then step 3 is on disk, so a wiped journal loses nothing.
35//!
36//! Crash analysis:
37//!
38//! - Crash before step 2: jsb.start unchanged → walker yields empty plan
39//!   → the partially-written journal is ignored.
40//! - Crash between 2 and 3: jsb.start = 1 → walker reads one transaction,
41//!   replay applies the writes (idempotent with what step 3 would have
42//!   written).
43//! - Crash between 3 and 4: same — replay re-applies; final-location
44//!   bytes are already what they would be after replay, so it's a no-op.
45//! - Crash after 4: clean state, walker yields empty plan.
46//!
47//! ## Limitations (deferred to later Phase 5 sub-items)
48//!
49//! - Transactions are bounded by `max_len - 2` blocks (one for sb, one for
50//!   commit). Large file writes that exceed the journal capacity must be
51//!   split by the caller; this module errors on overflow.
52//! - No batching across calls. Every commit checkpoints immediately, so
53//!   the journal only ever holds one transaction at rest. This is
54//!   correctness-first; ring-style batching is a Phase 8 perf concern.
55//! - The JBD2 superblock checksum (`s_checksum` at 0xFC) is recomputed by
56//!   `write_jsb` whenever the journal is checksummed (CSUM_V2/V3), because we
57//!   mutate `s_start` + `s_sequence` on every commit. A real Linux kernel AND
58//!   `e2fsck` REFUSE a checksummed journal whose superblock checksum is stale
59//!   ("Journal superblock is corrupt"), so leaving it unrecomputed bricks the
60//!   fs on the next mount. v1 journals have no such field and are untouched.
61
62use crate::block_io::BlockDevice;
63use crate::error::{Error, Result};
64use crate::fs::Filesystem;
65use crate::inode::Inode;
66use crate::jbd2::{self, JournalSuperblock, JBD2_MAGIC_NUMBER};
67use crate::transaction::Transaction;
68
69/// Owns the live-write side of the JBD2 journal. Built once at mount time
70/// (when the FS has a journal); reused across every mutating capi call.
71///
72/// Cheap to construct (one inode read + extent walk). Not thread-safe; the
73/// outer Filesystem lock must serialize mutating ops anyway.
74pub struct JournalWriter {
75    healthy: bool,
76    /// Leave `needs_recovery` set after a checkpoint rather than clearing
77    /// it: a checked mount (`Filesystem::mount_recovering`) clears it only
78    /// at `finish`, so the volume reads as in recovery while it is owned.
79    hold_needs_recovery: bool,
80    /// `physical_map[logical]` is the fs physical block backing journal
81    /// logical block `logical`. Length = `jsb.max_len`. Block 0 is the
82    /// JBD2 superblock; blocks 1.. carry transactions.
83    physical_map: Vec<u64>,
84    /// Block size of the underlying device (matches `jsb.block_size`).
85    block_size: u32,
86    /// Cached JBD2 superblock — mutated in memory + flushed to disk on
87    /// every commit. The on-disk truth always matches this between calls.
88    jsb: JournalSuperblock,
89    /// How many blocks the filesystem has, so a write can be checked
90    /// against it after the mount has gone out of scope.
91    blocks_count: u64,
92}
93
94/// Whether a journal of `max_len` blocks fits both its inode and the device.
95fn journal_len_fits(
96    max_len: u32,
97    inode_size: u64,
98    block_size: u32,
99    device_bytes: u64,
100) -> Result<()> {
101    let block_size = u64::from(block_size.max(1));
102    if u64::from(max_len) > inode_size / block_size {
103        return Err(Error::Corrupt(
104            "journal declares more blocks than its inode holds",
105        ));
106    }
107    if u64::from(max_len) > device_bytes / block_size {
108        return Err(Error::Corrupt(
109            "journal declares more blocks than the device holds",
110        ));
111    }
112    Ok(())
113}
114
115impl JournalWriter {
116    /// Open the writer for a mounted filesystem.
117    ///
118    /// Returns `Ok(None)` when the FS has no journal (`s_journal_inum == 0`)
119    /// — callers should fall back to unjournaled writes in that case.
120    /// Returns `Err` when the journal is misconfigured (e.g. the journal
121    /// inode is missing or unparsable).
122    ///
123    /// Flavor-aware: walks the journal inode's block tree via
124    /// [`crate::indirect::map_logical_any`], so ext3 journals (legacy
125    /// indirect block pointers) work the same as ext4 ones (extent tree).
126    pub fn open(fs: &Filesystem) -> Result<Option<Self>> {
127        let Some(jsb) = jbd2::read_superblock(fs)? else {
128            return Ok(None);
129        };
130        let raw = fs.read_inode_raw(fs.sb.journal_inode)?;
131        let jinode = Inode::parse(&raw)?;
132
133        // Build the full physical map up-front. For typical 32 MiB journals
134        // at 4 KiB blocks that's 8192 entries — tiny. Allocates once at
135        // mount; every commit then does a constant-time index.
136        let bs = fs.sb.block_size();
137        // A JOURNAL IS INSIDE THE FILESYSTEM IT JOURNALS.
138        //
139        // `s_maxlen` is a raw `u32` and nothing bounds it: 0xFFFFFFFF
140        // reserved 34 GB here before a block was mapped, which
141        // `handle_alloc_error` answers by aborting -- past
142        // `ffi_guard`'s `catch_unwind`, killing the host extension.
143        // The journal's blocks are filesystem blocks, so there cannot
144        // be more of them than the filesystem has.
145        if u64::from(jsb.max_len) > fs.sb.blocks_count {
146            return Err(Error::Corrupt(
147                "journal declares more blocks than the filesystem holds",
148            ));
149        }
150        // `s_blocks_count` is a raw field too, so the bound above is only as
151        // good as the image (#182). Two that are not the superblock's to
152        // choose: the journal inode's own length, which is the check the
153        // kernel makes ("journal file too short"), and the device, which a
154        // journal on it cannot outgrow. `i_size` is raw as well; the device
155        // bound is the one that caps the reservation below.
156        journal_len_fits(jsb.max_len, jinode.size, bs, fs.dev.size_bytes())?;
157
158        // NOR OVER THE BLOCKS EVERY MOUNT READS FIRST (#183). A commit
159        // writes wherever this map says, and `journal_apply::byte_offset_in`
160        // refuses only a block past the filesystem or the device -- block 0
161        // passes, and a journal inode mapping a logical block there put the
162        // first commit's descriptor over the primary superblock. So a
163        // mapping onto block 0, the superblock, group 0's descriptor table
164        // or its reserved growth, or past the end, is refused here, once,
165        // rather than trusted at every write.
166        let descriptor_bytes = fs.sb.block_group_count() * u64::from(fs.sb.desc_size);
167        let group0_metadata_end = u64::from(fs.sb.first_data_block)
168            + 1
169            + descriptor_bytes.div_ceil(u64::from(bs))
170            + u64::from(fs.sb.reserved_gdt_blocks);
171        let mut physical_map = Vec::with_capacity(jsb.max_len as usize);
172        for logical in 0..jsb.max_len as u64 {
173            let phys = crate::indirect::map_logical_any(
174                &jinode.block,
175                jinode.flags,
176                fs.dev.as_ref(),
177                bs,
178                logical,
179            )?
180            .ok_or(Error::Corrupt(
181                "journal_writer: journal inode has unmapped logical block",
182            ))?;
183            if phys < group0_metadata_end || phys >= fs.sb.blocks_count {
184                return Err(Error::Corrupt(
185                    "journal_writer: journal inode maps a block onto the superblock, group 0's \
186                     descriptor table, or past the end of the filesystem",
187                ));
188            }
189            physical_map.push(phys);
190        }
191
192        Ok(Some(Self {
193            healthy: true,
194            hold_needs_recovery: false,
195            blocks_count: fs.sb.blocks_count,
196            physical_map,
197            block_size: bs,
198            jsb,
199        }))
200    }
201
202    /// Begin a new transaction with the next sequence number. Caller adds
203    /// writes, then calls [`Self::commit`] to publish.
204    pub fn begin(&self) -> Transaction {
205        Transaction::begin(
206            self.jsb.sequence,
207            self.block_size,
208            self.jsb.uses_64bit(),
209            // CSUM_V3 ALONE. `Transaction::begin`'s last parameter is
210            // "does this journal use v3 tags", and it was handed "does
211            // this journal use checksums at all" -- so on a CSUM_V2
212            // journal the encoder laid 16-byte v3 tags while both this
213            // crate's reader (`journal.rs`, keyed on CSUM_V3) and the
214            // kernel parse classical ones. Tag 0 aliases
215            // correctly, which hid it on single-block transactions;
216            // from tag 1 on, the reader takes the writer's zeroed
217            // checksum slot as `t_blocknr` -- fs block 0 -- so a
218            // recovery replays a data block onto the ext4 superblock.
219            // Every mkdir, create and unlink is multi-block.
220            self.jsb.uses_csum_v3(),
221        )
222    }
223
224    /// The capacity (in fs blocks) the caller can fit into one transaction.
225    /// One block is reserved for the JBD2 superblock; the remaining
226    /// `max_len - 1` carry [descriptor, data..., (revoke), commit].
227    pub fn max_blocks_per_transaction(&self) -> usize {
228        // -1 for sb. Caller's writes also need a descriptor + commit slot,
229        // so payload-only capacity is roughly `max_len - 3` data blocks
230        // when the descriptor's tag table fits in one block. We expose the
231        // raw upper bound here; the commit path enforces the real limit.
232        (self.jsb.max_len as usize).saturating_sub(1)
233    }
234
235    /// Publish a transaction crash-safely (see module docs for the four-
236    /// fence ordering). On success, the in-memory JSB is up-to-date and
237    /// the on-disk JSB has been written back twice (dirty marker + clean
238    /// marker).
239    pub fn commit(&mut self, dev: &dyn BlockDevice, tx: &Transaction) -> Result<()> {
240        if !self.healthy {
241            return Err(Error::Corrupt("journal writer failed; reopen for recovery"));
242        }
243        self.commit_inner(dev, tx)
244    }
245
246    /// This writer, keeping `needs_recovery` set after every checkpoint.
247    pub(crate) fn holding_needs_recovery(mut self) -> Self {
248        self.hold_needs_recovery = true;
249        self
250    }
251
252    /// False once a commit failed after its first device write: the log and
253    /// the final locations are then in a state only a fresh replay can judge,
254    /// so this writer refuses every later commit. A refusal before any write
255    /// (read-only device, stale sequence, oversized transaction) leaves the
256    /// device untouched and the writer usable.
257    pub fn is_healthy(&self) -> bool {
258        self.healthy
259    }
260
261    fn commit_inner(&mut self, dev: &dyn BlockDevice, tx: &Transaction) -> Result<()> {
262        if !dev.is_writable() {
263            return Err(Error::ReadOnly);
264        }
265
266        // Sanity: the transaction's seq must match what we handed out from
267        // begin(). A mismatch means the caller built it with a stale
268        // writer or skipped begin() — either is a programming error that
269        // could corrupt the journal.
270        if tx.sequence != self.jsb.sequence {
271            return Err(Error::Corrupt(
272                "journal_writer: transaction sequence does not match writer state",
273            ));
274        }
275
276        // EVERY WRITE IS ONE BLOCK OF THIS VOLUME (#327). `writes` and
277        // `block_size` are public, so `add_write`'s length check can be
278        // bypassed: a 2x-block entry overwrote the block after its target,
279        // and a short superblock entry panicked below. Checked before any
280        // I/O, so a refusal leaves the device untouched.
281        if tx.block_size != self.block_size {
282            return Err(Error::InvalidArgument(
283                "journal_writer: transaction block size does not match the volume",
284            ));
285        }
286        if tx
287            .writes
288            .iter()
289            .any(|w| w.bytes.len() != self.block_size as usize)
290        {
291            return Err(Error::InvalidArgument(
292                "journal_writer: a journaled write is not exactly one block",
293            ));
294        }
295
296        // The superblock block, if journaled, keeps `needs_recovery` set
297        // until step 4 clears it (#228) -- in every write to it, since a
298        // transaction may carry more than one and step 3 applies them all
299        // in order.
300        let sb_block = self.sb_block();
301        let patched;
302        let tx = if tx.writes.iter().any(|w| w.fs_block == sb_block) {
303            let mut copy = tx.clone();
304            let off = self.sb_offset_in_block();
305            for w in copy.writes.iter_mut().filter(|w| w.fs_block == sb_block) {
306                crate::journal_apply::set_needs_recovery_in(&mut w.bytes[off..off + 1024], true);
307            }
308            patched = copy;
309            &patched
310        } else {
311            tx
312        };
313
314        // With every checksum the journal declares: a zero there is a
315        // transaction Linux's recovery stops at (#80).
316        let blocks = tx.commit_for(&self.jsb)?;
317        if blocks.is_empty() {
318            return Ok(()); // empty transaction is a no-op
319        }
320        if blocks.len() > self.max_blocks_per_transaction() {
321            return Err(Error::Corrupt(
322                "journal_writer: transaction too large for journal",
323            ));
324        }
325
326        // From the first write on, a failure leaves the device in a state
327        // only a fresh replay can judge; cleared again once step 4 is done.
328        self.healthy = false;
329
330        // -- Step 1: write transaction blocks to journal at logical [1..1+N).
331        //    Block 0 is the JBD2 superblock; we never overwrite it here.
332        let txn_first_jblock = 1usize;
333        for (i, block) in blocks.iter().enumerate() {
334            let jblock_idx = txn_first_jblock + i;
335            let phys = self.physical_map[jblock_idx];
336            // The journal inode's extent tree says where its blocks
337            // are, and it is metadata off the disk like any other. The
338            // replay side checks this (`journal_apply::byte_offset_of`);
339            // the writer did not, so a journal inode mapping logical
340            // block 1 to fs block 0 put the first journalled operation's
341            // descriptor over the ext4 superblock.
342            let at = crate::journal_apply::byte_offset_in(
343                phys,
344                self.blocks_count,
345                self.block_size,
346                dev.size_bytes(),
347            )?;
348            dev.write_at(at, block)?;
349        }
350        crate::journal_apply::write_needs_recovery(dev, true)?;
351        dev.flush()?;
352
353        // -- Step 2: mark journal dirty. start = first txn block; sequence
354        //    unchanged so the walker matches our header_sequence.
355        self.jsb.start = txn_first_jblock as u32;
356        self.write_jsb(dev)?;
357        dev.flush()?;
358
359        // -- Step 3: apply writes to final-location fs blocks.
360        for w in &tx.writes {
361            let at = crate::journal_apply::byte_offset_in(
362                w.fs_block,
363                self.blocks_count,
364                self.block_size,
365                dev.size_bytes(),
366            )?;
367            dev.write_at(at, &w.bytes)?;
368        }
369        dev.flush()?;
370
371        // -- Step 4: mark journal clean + advance sequence.
372        self.jsb.start = 0;
373        self.jsb.sequence = self.jsb.sequence.wrapping_add(1);
374        self.write_jsb(dev)?;
375        if !self.hold_needs_recovery {
376            crate::journal_apply::write_needs_recovery(dev, false)?;
377        }
378        dev.flush()?;
379
380        self.healthy = true;
381        Ok(())
382    }
383
384    /// The fs block holding the primary superblock: 0, or 1 at 1 KiB.
385    fn sb_block(&self) -> u64 {
386        crate::superblock::SUPERBLOCK_OFFSET / u64::from(self.block_size)
387    }
388
389    fn sb_offset_in_block(&self) -> usize {
390        (crate::superblock::SUPERBLOCK_OFFSET % u64::from(self.block_size)) as usize
391    }
392
393    /// Re-emit the JBD2 superblock at journal logical block 0 from the
394    /// in-memory `self.jsb`. Patches only the fields we mutate
395    /// (`s_start`, `s_sequence`, `s_header.h_sequence`); leaves all other
396    /// bytes (including the v2 checksum trailer) intact by reading the
397    /// existing block first.
398    ///
399    /// Big-endian on disk per JBD2 convention.
400    fn write_jsb(&self, dev: &dyn BlockDevice) -> Result<()> {
401        let bs_u64 = self.block_size as u64;
402        let phys = self.physical_map[0];
403        let mut buf = vec![0u8; self.block_size as usize];
404        dev.read_at(phys * bs_u64, &mut buf)?;
405
406        // Verify what we read still looks like our journal sb. A bit-flip
407        // here would silently brick the journal; better to refuse.
408        let magic = u32::from_be_bytes(buf[0x00..0x04].try_into().unwrap());
409        if magic != JBD2_MAGIC_NUMBER {
410            return Err(Error::Corrupt(
411                "journal_writer: jsb block lost its magic between mount and commit",
412            ));
413        }
414        let block_type = u32::from_be_bytes(buf[0x04..0x08].try_into().unwrap());
415        if block_type != self.jsb.block_type {
416            return Err(Error::Corrupt(
417                "journal_writer: jsb block_type changed since mount",
418            ));
419        }
420
421        // h_sequence (header) at 0x08, s_sequence at 0x18, s_start at 0x1C.
422        // Kernel keeps h_sequence == s_sequence on the sb; we mirror that.
423        buf[0x08..0x0C].copy_from_slice(&self.jsb.sequence.to_be_bytes());
424        buf[0x18..0x1C].copy_from_slice(&self.jsb.sequence.to_be_bytes());
425        buf[0x1C..0x20].copy_from_slice(&self.jsb.start.to_be_bytes());
426
427        // Recompute the JBD2 superblock checksum (s_checksum at 0xFC) when the
428        // journal is checksummed (CSUM_V2/V3). It covers the whole 1024-byte
429        // journal_superblock_t with s_checksum zeroed, seeded ~0 — the journal
430        // is self-contained, so this is NOT the fs metadata_csum seed. Leaving
431        // it stale after mutating s_start/s_sequence makes a real kernel and
432        // e2fsck refuse the journal ("Journal superblock is corrupt"). v1
433        // journals carry no such field; their bytes are left intact.
434        if self.jsb.uses_csum_v2_or_v3() {
435            const JSB_LEN: usize = 1024; // sizeof(journal_superblock_t)
436            buf[0xFC..0x100].copy_from_slice(&0u32.to_be_bytes());
437            let csum = crate::checksum::linux_crc32c(!0, &buf[..JSB_LEN]);
438            buf[0xFC..0x100].copy_from_slice(&csum.to_be_bytes());
439        }
440
441        dev.write_at(phys * bs_u64, &buf)?;
442        Ok(())
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn a_journal_must_fit_its_inode_and_the_device() {
452        // 8 blocks in the inode, 16 on the device.
453        assert!(journal_len_fits(8, 8 * 4096, 4096, 16 * 4096).is_ok());
454        assert!(journal_len_fits(9, 8 * 4096, 4096, 16 * 4096).is_err());
455        // An inode claiming more than the device holds does not raise the
456        // device's ceiling.
457        assert!(journal_len_fits(17, u64::MAX, 4096, 16 * 4096).is_err());
458        assert!(journal_len_fits(16, u64::MAX, 4096, 16 * 4096).is_ok());
459        assert!(journal_len_fits(u32::MAX, u64::MAX, 4096, 64 << 20).is_err());
460    }
461
462    /// Whether a volume has a journal decides what `open` returns, and
463    /// the answer is checked in both directions on volumes this crate
464    /// formats itself (#265).
465    ///
466    /// The test this replaces copied `ext4-no-csum.img`, called `open`,
467    /// and threw the answer away — `Some` and `None` both passed, and
468    /// the comment above it said the image "is built without a journal
469    /// *in some configs*", which nothing checked either. So the one
470    /// behaviour its name claimed was untested: a `JournalWriter` handed
471    /// back for a volume with no journal would have kept it green while
472    /// every write through it went to blocks that are not a journal.
473    ///
474    /// Formatting here rather than reading a fixture is what makes the
475    /// case certain, and it runs everywhere — no fixtures, no VM, no
476    /// tools.
477    mod journal_presence {
478        use super::*;
479        use crate::features::{Compat, FsFlavor};
480        use std::sync::{Arc, Mutex};
481
482        const BS: u32 = 1024;
483        /// Room for the 1024-block journal an ext3 format lays down,
484        /// plus its metadata.
485        const VOL: u64 = 8 * 1024 * 1024;
486
487        struct MemDev {
488            bytes: Mutex<Vec<u8>>,
489        }
490
491        impl MemDev {
492            fn new() -> Arc<Self> {
493                Arc::new(Self {
494                    bytes: Mutex::new(vec![0u8; VOL as usize]),
495                })
496            }
497        }
498
499        impl crate::block_io::BlockDevice for MemDev {
500            fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
501                let b = self.bytes.lock().unwrap();
502                let (start, end) = (offset as usize, offset as usize + buf.len());
503                if end > b.len() {
504                    return Err(Error::Corrupt("MemDev: read past end"));
505                }
506                buf.copy_from_slice(&b[start..end]);
507                Ok(())
508            }
509            fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()> {
510                let mut b = self.bytes.lock().unwrap();
511                let (start, end) = (offset as usize, offset as usize + buf.len());
512                if end > b.len() {
513                    return Err(Error::Corrupt("MemDev: write past end"));
514                }
515                b[start..end].copy_from_slice(buf);
516                Ok(())
517            }
518            fn size_bytes(&self) -> u64 {
519                VOL
520            }
521            fn flush(&self) -> Result<()> {
522                Ok(())
523            }
524            fn is_writable(&self) -> bool {
525                true
526            }
527        }
528
529        fn formatted(flavor: FsFlavor) -> Filesystem {
530            let dev = MemDev::new();
531            crate::mkfs::format_filesystem_with_flavor(dev.as_ref(), None, None, VOL, BS, flavor)
532                .expect("format");
533            Filesystem::mount(dev).expect("mount")
534        }
535
536        /// A volume with no journal has no journal writer.
537        #[test]
538        fn open_returns_none_when_the_volume_has_no_journal() {
539            let fs = formatted(FsFlavor::Ext4);
540            // THE FIXTURE'S OWN SHAPE FIRST. A test that asserts `None`
541            // against a volume that turned out to have a journal is
542            // asserting nothing about the case it names.
543            assert_eq!(
544                fs.sb.feature_compat & Compat::HAS_JOURNAL.bits(),
545                0,
546                "this volume was formatted without a journal and says it has one"
547            );
548            assert_eq!(fs.sb.journal_inode, 0, "and names no journal inode");
549
550            let writer = JournalWriter::open(&fs).expect("open");
551            assert!(
552                writer.is_none(),
553                "a volume with no journal handed back a journal writer, so every write \
554                 through it would go to blocks that are not a journal"
555            );
556        }
557
558        /// And one with a journal has one — or the test above would pass
559        /// on an `open` that never returns anything.
560        #[test]
561        fn open_returns_a_writer_when_the_volume_has_one() {
562            let fs = formatted(FsFlavor::Ext3);
563            assert_ne!(
564                fs.sb.feature_compat & Compat::HAS_JOURNAL.bits(),
565                0,
566                "this volume was formatted with a journal and says it has none"
567            );
568            assert_ne!(fs.sb.journal_inode, 0, "and it names the journal inode");
569
570            let writer = JournalWriter::open(&fs).expect("open");
571            assert!(
572                writer.is_some(),
573                "a volume with a journal handed back nothing, so a caller would fall \
574                 back to unjournaled writes on a filesystem that has a log"
575            );
576        }
577
578        /// A transaction refused before any journal block is written leaves
579        /// nothing on the device to recover from, so it must not retire the
580        /// writer: only a commit whose I/O began and failed does.
581        #[test]
582        fn a_refusal_before_any_write_leaves_the_writer_usable() {
583            let fs = formatted(FsFlavor::Ext3);
584            let mut writer = JournalWriter::open(&fs).expect("open").expect("writer");
585            let block = fs.sb.blocks_count - 1;
586            let mut too_large = writer.begin();
587            for i in 0..=writer.max_blocks_per_transaction() as u64 {
588                too_large
589                    .add_write(block - i, vec![0u8; BS as usize])
590                    .expect("add_write");
591            }
592            assert!(writer.commit(fs.dev.as_ref(), &too_large).is_err());
593            assert!(
594                writer.is_healthy(),
595                "a transaction refused before it wrote anything retired the writer"
596            );
597            let mut small = writer.begin();
598            small
599                .add_write(block, vec![0u8; BS as usize])
600                .expect("add_write");
601            writer
602                .commit(fs.dev.as_ref(), &small)
603                .expect("the next transaction commits");
604        }
605
606        /// A journal writer on a freshly formatted ext3 volume, with the
607        /// device kept so its bytes can be compared.
608        fn writer_on_ext3() -> (Arc<MemDev>, Filesystem, JournalWriter) {
609            let dev = MemDev::new();
610            crate::mkfs::format_filesystem_with_flavor(
611                dev.as_ref(),
612                None,
613                None,
614                VOL,
615                BS,
616                FsFlavor::Ext3,
617            )
618            .expect("format");
619            let fs = Filesystem::mount(dev.clone()).expect("mount");
620            let writer = JournalWriter::open(&fs).expect("open").expect("writer");
621            (dev, fs, writer)
622        }
623
624        /// Commits `tx` and asserts it is refused with the device untouched
625        /// and the writer still usable.
626        fn assert_refused_untouched(tx: &Transaction, what: &str) {
627            let (dev, fs, mut writer) = writer_on_ext3();
628            let mut tx = tx.clone();
629            tx.sequence = writer.begin().sequence;
630            let before = dev.bytes.lock().unwrap().clone();
631            let got = writer.commit(fs.dev.as_ref(), &tx);
632            assert!(got.is_err(), "{what}: committed ({got:?})");
633            assert!(
634                *dev.bytes.lock().unwrap() == before,
635                "{what}: the refused commit wrote to the device"
636            );
637            assert!(
638                writer.is_healthy(),
639                "{what}: the refusal retired the writer"
640            );
641        }
642
643        /// `writes` is public, so an entry can bypass `add_write`'s length
644        /// check; a 2x-block entry overwrote the block after its target
645        /// (#327).
646        #[test]
647        fn a_write_longer_than_a_block_is_refused_before_any_write() {
648            let mut tx = Transaction::begin(0, BS, false, false);
649            tx.writes.push(crate::transaction::JournaledBlock {
650                fs_block: 100,
651                bytes: vec![0xA5; 2 * BS as usize],
652            });
653            assert_refused_untouched(&tx, "a 2x-block entry");
654        }
655
656        /// A superblock entry shorter than a block panicked slicing
657        /// `bytes[off..off + 1024]` (#327).
658        #[test]
659        fn a_short_superblock_write_is_refused_not_a_panic() {
660            let mut tx = Transaction::begin(0, BS, false, false);
661            tx.writes.push(crate::transaction::JournaledBlock {
662                fs_block: 1, // the superblock at 1 KiB
663                bytes: vec![0u8; 16],
664            });
665            assert_refused_untouched(&tx, "a 16-byte superblock entry");
666        }
667
668        /// A transaction begun for another block size is refused whole
669        /// (#327).
670        #[test]
671        fn a_transaction_for_another_block_size_is_refused() {
672            let mut tx = Transaction::begin(0, 2 * BS, false, false);
673            tx.add_write(100, vec![0xA5; 2 * BS as usize])
674                .expect("add_write at the transaction's own size");
675            assert_refused_untouched(&tx, "a 2 KiB transaction on a 1 KiB volume");
676        }
677
678        /// Drops every write after the first `budget`: a power cut.
679        struct CutDevice {
680            inner: Arc<MemDev>,
681            budget: Mutex<usize>,
682        }
683
684        impl crate::block_io::BlockDevice for CutDevice {
685            fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
686                self.inner.read_at(offset, buf)
687            }
688            fn write_at(&self, offset: u64, buf: &[u8]) -> Result<()> {
689                let mut left = self.budget.lock().unwrap();
690                if *left == 0 {
691                    return Ok(());
692                }
693                *left -= 1;
694                self.inner.write_at(offset, buf)
695            }
696            fn size_bytes(&self) -> u64 {
697                VOL
698            }
699            fn flush(&self) -> Result<()> {
700                Ok(())
701            }
702            fn is_writable(&self) -> bool {
703                true
704            }
705        }
706
707        /// A data block that begins with the journal magic goes into the log
708        /// escaped, and this crate's replay puts the magic back (#292).
709        ///
710        /// The commit is cut once the journal is marked dirty, so the target
711        /// holds only what replay gives it.
712        #[test]
713        fn a_block_starting_with_the_magic_is_logged_escaped_and_replayed_whole() {
714            let dev = MemDev::new();
715            crate::mkfs::format_filesystem_with_flavor(
716                dev.as_ref(),
717                None,
718                None,
719                VOL,
720                BS,
721                FsFlavor::Ext3,
722            )
723            .expect("format");
724            let fs = Filesystem::mount(dev.clone()).expect("mount");
725            let mut writer = JournalWriter::open(&fs).expect("open").expect("writer");
726            let target = fs.sb.blocks_count - 1;
727            let mut payload: Vec<u8> = (0..BS as usize).map(|j| 0x5A ^ (j % 251) as u8).collect();
728            payload[0..4].copy_from_slice(&JBD2_MAGIC_NUMBER.to_be_bytes());
729
730            let mut tx = writer.begin();
731            tx.add_write(target, payload.clone()).expect("add_write");
732            // Descriptor, data, commit; `needs_recovery`; the dirty journal
733            // superblock. The final-location write is lost.
734            let cut = CutDevice {
735                inner: dev.clone(),
736                budget: Mutex::new(3 + 2),
737            };
738            writer.commit(&cut, &tx).expect("commit");
739            let logged_at = writer.physical_map[2];
740            drop(fs);
741
742            let read = |block: u64| {
743                let mut b = vec![0u8; BS as usize];
744                dev.read_at(block * BS as u64, &mut b).unwrap();
745                b
746            };
747            let logged = read(logged_at);
748            assert_eq!(
749                &logged[0..4],
750                &[0u8; 4],
751                "the logged copy starts with the journal magic, unescaped"
752            );
753            assert!(logged[4..] == payload[4..]);
754            assert_eq!(read(target), vec![0u8; BS as usize], "cut too late");
755
756            // A writable mount replays a dirty journal (`replay_if_dirty`).
757            let fs = Filesystem::mount(dev.clone()).expect("remount");
758            assert!(
759                crate::jbd2::read_superblock(&fs)
760                    .expect("journal superblock")
761                    .expect("a journal")
762                    .is_clean(),
763                "the remount did not replay the journal"
764            );
765            drop(fs);
766            assert!(
767                read(target) == payload,
768                "replay did not restore the block, magic and all"
769            );
770        }
771    }
772
773    /// Tests that copy a fixture from `test-disks/` (`chore fixtures`).
774    mod needs_host {
775        use super::*;
776        use crate::block_io::FileDevice;
777        use std::fs;
778        use std::sync::Arc;
779
780        fn copy_to_tmp(name: &str, tag: &str) -> String {
781            use std::sync::atomic::{AtomicU32, Ordering};
782            static COUNTER: AtomicU32 = AtomicU32::new(0);
783            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
784            let src = fs_ext4_test_support::fixture(env!("CARGO_MANIFEST_DIR"), name);
785            let dst =
786                fs_ext4_test_support::temp_path!("fs_ext4_jw_{}_{tag}_{n}.img", std::process::id());
787            fs::copy(&src, &dst).unwrap_or_else(|e| panic!("copy {src} to {dst}: {e}"));
788            dst
789        }
790
791        #[test]
792        fn empty_transaction_is_no_op() {
793            let path = copy_to_tmp("ext4-basic.img", "empty_tx");
794            let dev = FileDevice::open_rw(&path).expect("open rw");
795            let fs = Filesystem::mount(Arc::new(dev)).expect("mount");
796            let mut jw = JournalWriter::open(&fs).expect("open writer").expect(
797                "ext4-basic.img is built with has_journal (test-disks/guest-build-images.sh)",
798            );
799            let initial_seq = jw.jsb.sequence;
800            let tx = jw.begin();
801            // commit() short-circuits on tx.commit() returning a single commit
802            // block — actually tx.commit() always returns at least the commit
803            // block, so an empty tx still goes through the protocol but writes
804            // only one block. Verify it advances sequence by 1.
805            jw.commit(fs.dev.as_ref(), &tx).expect("commit");
806            assert_eq!(
807                jw.jsb.sequence,
808                initial_seq.wrapping_add(1),
809                "sequence should advance even for a no-write commit"
810            );
811            assert_eq!(jw.jsb.start, 0, "should be clean after commit");
812            fs::remove_file(path).ok();
813        }
814    }
815}