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}