Skip to main content

fs_mac/
hfsplus_journal.rs

1//! HFS+ journal: on-disk parse, replay, and a transactional write recorder.
2//!
3//! The journal is a physical-block redo log living inside the volume (the
4//! common case — `kJIJournalInFSMask`) or, rarely, on an external device
5//! (`kJIJournalOnOtherDeviceMask`, which we refuse). Its on-disk layout is
6//! documented in Apple TN1150 ("Journaled HFS Plus") and implemented in the
7//! Apple `hfs` source tree:
8//!
9//!   * `core/hfs_journal.{c,h}` — the kernel journal (write + replay)
10//!   * `lib_fsck_hfs/fsck_journal.c` — the userland replay used by `fsck_hfs`
11//!   * `core/hfs_format.h` — `struct JournalInfoBlock` + the `kJI*` flags
12//!
13//! The structures and the checksum follow the on-disk format TN1150 "Journal"
14//! documents (checked against those sources) so that transactions we write are
15//! replayable by macOS and that we can replay transactions macOS wrote. See
16//! `docs/hfsplus_enhancements.md` Phase 9 for the staged plan.
17//!
18//! Endianness: the journal is written in the *native* byte order of the
19//! machine that created it (big-endian on PPC, little-endian on Intel/ARM),
20//! disambiguated by the `endian` magic in the journal header. The
21//! `JournalInfoBlock`, by contrast, is an HFS+ on-disk structure and is
22//! therefore always big-endian like the volume header.
23
24use byteorder::{BigEndian, ByteOrder, LittleEndian};
25use std::collections::BTreeMap;
26use std::io::{Read, Seek, SeekFrom, Write};
27
28/// `JOURNAL_HEADER_MAGIC` — `'JNLx'`.
29pub const JOURNAL_HEADER_MAGIC: u32 = 0x4a4e_4c78;
30/// `OLD_JOURNAL_HEADER_MAGIC` — `'JHDR'`, still accepted on read.
31pub const OLD_JOURNAL_HEADER_MAGIC: u32 = 0x4a48_4452;
32/// `ENDIAN_MAGIC` — the `endian` field reads as this in the journal's own
33/// byte order, which is how we detect that order.
34pub const ENDIAN_MAGIC: u32 = 0x1234_5678;
35
36/// Bytes of the journal header covered by its checksum, with the `checksum`
37/// field itself zeroed: `offsetof(struct journal_header, sequence_num)`.
38const JOURNAL_HEADER_CKSUM_SIZE: usize = 44;
39/// Bytes of a `block_list_header` covered by its checksum: the 16-byte
40/// fixed header plus `binfo[0]` (16 bytes). `BLHDR_CHECKSUM_SIZE` in xnu.
41const BLHDR_CHECKSUM_SIZE: usize = 32;
42/// On-disk size of one `block_info` entry: `bnum(8) + bsize(4) + next(4)`.
43const BLOCK_INFO_SIZE: usize = 16;
44/// On-disk size of the fixed `block_list_header` prefix (before `binfo[]`).
45const BLOCK_LIST_HEADER_PREFIX: usize = 16;
46
47/// `block_list_header.flags`: per-block data checksums present in `binfo[i].next`.
48/// We tolerate it on read but never set it on our own writes.
49#[allow(dead_code)]
50const BLHDR_CHECK_CHECKSUMS: u32 = 0x0001;
51/// `block_list_header.flags`: this is the first header of a transaction.
52const BLHDR_FIRST_HEADER: u32 = 0x0002;
53
54/// `JournalInfoBlock.flags`: journal lives inside this filesystem (common).
55pub const KJI_JOURNAL_IN_FS_MASK: u32 = 0x0000_0001;
56/// `JournalInfoBlock.flags`: journal lives on a separate device (we refuse).
57pub const KJI_JOURNAL_ON_OTHER_DEVICE_MASK: u32 = 0x0000_0002;
58/// `JournalInfoBlock.flags`: journal needs initialization.
59pub const KJI_JOURNAL_NEED_INIT_MASK: u32 = 0x0000_0004;
60
61/// Sentinel `bnum` for a "killed" block that should be skipped during replay.
62const KILLED_BLOCK: u64 = u64::MAX;
63
64/// Errors from journal parsing / replay / commit.
65#[derive(Debug)]
66pub enum JournalError {
67    /// The volume header's journaled bit is clear — there is no journal.
68    NotJournaled,
69    /// The journal lives on an external device, which is out of scope.
70    ExternalJournal,
71    /// The journal header magic did not match either accepted value.
72    BadMagic(u32),
73    /// The endian field matched neither byte order.
74    BadEndian(u32),
75    /// A structure was shorter than required, or a field pointed out of range.
76    OutOfRange(String),
77    /// A transaction's block-list-header checksum did not verify.
78    CorruptTransaction { sequence_num: u32 },
79    /// Underlying I/O failure.
80    Io(std::io::Error),
81}
82
83impl std::fmt::Display for JournalError {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            JournalError::NotJournaled => write!(f, "volume is not journaled"),
87            JournalError::ExternalJournal => {
88                write!(f, "journal lives on an external device (unsupported)")
89            }
90            JournalError::BadMagic(m) => write!(f, "bad journal header magic 0x{m:08x}"),
91            JournalError::BadEndian(e) => write!(f, "bad journal endian magic 0x{e:08x}"),
92            JournalError::OutOfRange(msg) => write!(f, "journal field out of range: {msg}"),
93            JournalError::CorruptTransaction { sequence_num } => {
94                write!(f, "corrupt journal transaction (sequence {sequence_num})")
95            }
96            JournalError::Io(e) => write!(f, "journal I/O error: {e}"),
97        }
98    }
99}
100
101impl std::error::Error for JournalError {}
102
103impl From<std::io::Error> for JournalError {
104    fn from(e: std::io::Error) -> Self {
105        JournalError::Io(e)
106    }
107}
108
109type Result<T> = std::result::Result<T, JournalError>;
110
111/// Byte order of the journal, selected from the header's `endian` field.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum JournalEndian {
114    Big,
115    Little,
116}
117
118impl JournalEndian {
119    fn read_u16(self, b: &[u8]) -> u16 {
120        match self {
121            JournalEndian::Big => BigEndian::read_u16(b),
122            JournalEndian::Little => LittleEndian::read_u16(b),
123        }
124    }
125    fn read_u32(self, b: &[u8]) -> u32 {
126        match self {
127            JournalEndian::Big => BigEndian::read_u32(b),
128            JournalEndian::Little => LittleEndian::read_u32(b),
129        }
130    }
131    fn read_u64(self, b: &[u8]) -> u64 {
132        match self {
133            JournalEndian::Big => BigEndian::read_u64(b),
134            JournalEndian::Little => LittleEndian::read_u64(b),
135        }
136    }
137    fn write_u16(self, b: &mut [u8], v: u16) {
138        match self {
139            JournalEndian::Big => BigEndian::write_u16(b, v),
140            JournalEndian::Little => LittleEndian::write_u16(b, v),
141        }
142    }
143    fn write_u32(self, b: &mut [u8], v: u32) {
144        match self {
145            JournalEndian::Big => BigEndian::write_u32(b, v),
146            JournalEndian::Little => LittleEndian::write_u32(b, v),
147        }
148    }
149    fn write_u64(self, b: &mut [u8], v: u64) {
150        match self {
151            JournalEndian::Big => BigEndian::write_u64(b, v),
152            JournalEndian::Little => LittleEndian::write_u64(b, v),
153        }
154    }
155}
156
157/// The xnu journal checksum: a byte-at-a-time accumulator, bit-inverted at
158/// the end, as TN1150 "Journal" specifies it (xnu's `calc_checksum`).
159pub fn calc_checksum(data: &[u8]) -> u32 {
160    let mut cksum: u32 = 0;
161    for &byte in data {
162        cksum = cksum.wrapping_shl(8) ^ cksum.wrapping_add(byte as u32);
163    }
164    !cksum
165}
166
167/// The relevant fields of a `JournalInfoBlock` (TN1150 / `hfs_format.h`).
168/// Parsed big-endian. The device signature, UUID, and serial number are not
169/// needed for in-FS journals and are skipped.
170#[derive(Debug, Clone)]
171pub struct JournalInfoBlock {
172    pub flags: u32,
173    /// Byte offset of the journal header, relative to the partition start.
174    pub offset: u64,
175    /// Total journal size in bytes (header block + circular buffer).
176    pub size: u64,
177}
178
179impl JournalInfoBlock {
180    /// Parse from the 512+ byte block the volume header's `journalInfoBlock`
181    /// points at. Layout: `flags@0`, `device_signature[8]@4`, `offset@36`,
182    /// `size@44` — all big-endian.
183    pub fn parse(data: &[u8]) -> Result<Self> {
184        if data.len() < 52 {
185            return Err(JournalError::OutOfRange(
186                "journal info block shorter than 52 bytes".into(),
187            ));
188        }
189        Ok(JournalInfoBlock {
190            flags: BigEndian::read_u32(&data[0..4]),
191            offset: BigEndian::read_u64(&data[36..44]),
192            size: BigEndian::read_u64(&data[44..52]),
193        })
194    }
195
196    /// True when the journal lives inside this filesystem (the case we handle).
197    pub fn is_in_fs(&self) -> bool {
198        self.flags & KJI_JOURNAL_IN_FS_MASK != 0
199    }
200
201    /// True when the journal lives on a separate device (out of scope).
202    pub fn is_external(&self) -> bool {
203        self.flags & KJI_JOURNAL_ON_OTHER_DEVICE_MASK != 0
204    }
205}
206
207/// The journal header at `JournalInfoBlock.offset` (`struct journal_header`).
208/// All multi-byte fields use `endian`.
209#[derive(Debug, Clone)]
210pub struct JournalHeader {
211    pub endian: JournalEndian,
212    pub magic: u32,
213    /// Byte offset (relative to the journal device base = `jib.offset`) of the
214    /// first live transaction.
215    pub start: u64,
216    /// Byte offset of the first free byte. `start == end` means empty/clean.
217    pub end: u64,
218    /// Journal size in bytes (matches `JournalInfoBlock.size`).
219    pub size: u64,
220    /// Size of each `block_list_header` region (data follows it).
221    pub blhdr_size: u32,
222    pub checksum: u32,
223    /// Journal header block size, and the unit `binfo.bnum` is multiplied by.
224    pub jhdr_size: u32,
225    pub sequence_num: u32,
226}
227
228impl JournalHeader {
229    /// Parse the journal header, auto-detecting byte order from `endian`.
230    pub fn parse(data: &[u8]) -> Result<Self> {
231        if data.len() < JOURNAL_HEADER_CKSUM_SIZE + 4 {
232            return Err(JournalError::OutOfRange(
233                "journal header shorter than 48 bytes".into(),
234            ));
235        }
236        // The endian field reads as ENDIAN_MAGIC in the journal's own order.
237        let endian = if LittleEndian::read_u32(&data[4..8]) == ENDIAN_MAGIC {
238            JournalEndian::Little
239        } else if BigEndian::read_u32(&data[4..8]) == ENDIAN_MAGIC {
240            JournalEndian::Big
241        } else {
242            return Err(JournalError::BadEndian(BigEndian::read_u32(&data[4..8])));
243        };
244
245        let magic = endian.read_u32(&data[0..4]);
246        if magic != JOURNAL_HEADER_MAGIC && magic != OLD_JOURNAL_HEADER_MAGIC {
247            return Err(JournalError::BadMagic(magic));
248        }
249
250        let hdr = JournalHeader {
251            endian,
252            magic,
253            start: endian.read_u64(&data[8..16]),
254            end: endian.read_u64(&data[16..24]),
255            size: endian.read_u64(&data[24..32]),
256            blhdr_size: endian.read_u32(&data[32..36]),
257            checksum: endian.read_u32(&data[36..40]),
258            jhdr_size: endian.read_u32(&data[40..44]),
259            sequence_num: endian.read_u32(&data[44..48]),
260        };
261
262        // Verify the header checksum (computed over the first 44 bytes with
263        // the checksum field zeroed) for the modern magic. The old magic
264        // predates header checksums.
265        if magic == JOURNAL_HEADER_MAGIC {
266            let mut tmp = data[..JOURNAL_HEADER_CKSUM_SIZE].to_vec();
267            tmp[36..40].copy_from_slice(&[0u8; 4]);
268            let computed = calc_checksum(&tmp);
269            if computed != hdr.checksum {
270                return Err(JournalError::OutOfRange(format!(
271                    "journal header checksum mismatch (computed 0x{computed:08x} != stored 0x{:08x})",
272                    hdr.checksum
273                )));
274            }
275        }
276
277        hdr.validate()?;
278        Ok(hdr)
279    }
280
281    fn validate(&self) -> Result<()> {
282        if self.jhdr_size == 0 || (self.jhdr_size as u64) > self.size {
283            return Err(JournalError::OutOfRange(format!(
284                "jhdr_size {} invalid for journal size {}",
285                self.jhdr_size, self.size
286            )));
287        }
288        if self.blhdr_size < BLHDR_CHECKSUM_SIZE as u32 || (self.blhdr_size as u64) > self.size {
289            return Err(JournalError::OutOfRange(format!(
290                "blhdr_size {} invalid",
291                self.blhdr_size
292            )));
293        }
294        if self.start > self.size || self.end > self.size {
295            return Err(JournalError::OutOfRange(format!(
296                "start {} / end {} out of range for size {}",
297                self.start, self.end, self.size
298            )));
299        }
300        Ok(())
301    }
302
303    /// True when the journal has no pending transactions.
304    pub fn is_empty(&self) -> bool {
305        self.normalized_start() == self.normalized_end()
306    }
307
308    /// `start`, wrapped past the very end of the buffer to the first byte after
309    /// the header (matches the kernel's replay entry fixup).
310    fn normalized_start(&self) -> u64 {
311        if self.start == self.size {
312            self.jhdr_size as u64
313        } else {
314            self.start
315        }
316    }
317
318    fn normalized_end(&self) -> u64 {
319        if self.end == self.size {
320            self.jhdr_size as u64
321        } else {
322            self.end
323        }
324    }
325
326    /// Serialize the header back into a `jhdr_size`-byte block, recomputing the
327    /// checksum. The bytes past the fixed header are preserved from `original`
328    /// when supplied (so we don't clobber any vendor padding macOS wrote).
329    pub fn serialize(&self, original: Option<&[u8]>) -> Vec<u8> {
330        let len = self.jhdr_size as usize;
331        let mut out = vec![0u8; len];
332        if let Some(orig) = original {
333            let copy = orig.len().min(len);
334            out[..copy].copy_from_slice(&orig[..copy]);
335        }
336        let e = self.endian;
337        e.write_u32(&mut out[0..4], self.magic);
338        // endian magic is byte-order agnostic by construction.
339        e.write_u32(&mut out[4..8], ENDIAN_MAGIC);
340        e.write_u64(&mut out[8..16], self.start);
341        e.write_u64(&mut out[16..24], self.end);
342        e.write_u64(&mut out[24..32], self.size);
343        e.write_u32(&mut out[32..36], self.blhdr_size);
344        e.write_u32(&mut out[36..40], 0); // checksum placeholder
345        e.write_u32(&mut out[40..44], self.jhdr_size);
346        e.write_u32(&mut out[44..48], self.sequence_num);
347        let cksum = calc_checksum(&out[..JOURNAL_HEADER_CKSUM_SIZE]);
348        e.write_u32(&mut out[36..40], cksum);
349        out
350    }
351}
352
353/// One decoded `block_info` entry describing where a journaled block lands.
354#[derive(Debug, Clone)]
355pub struct BlockInfo {
356    /// Target block number on the filesystem device (multiply by `jhdr_size`
357    /// for a byte offset relative to the partition). `u64::MAX` = killed.
358    pub bnum: u64,
359    /// Size of this block in bytes.
360    pub bsize: u32,
361    /// For `binfo[0]`: the transaction sequence number. For data blocks: the
362    /// per-block checksum (only meaningful when `BLHDR_CHECK_CHECKSUMS`).
363    pub next: u32,
364}
365
366/// One transaction read from the journal: its decoded block list plus the
367/// block payloads, in order.
368#[derive(Debug, Clone)]
369pub struct TransactionView {
370    pub sequence_num: u32,
371    pub flags: u32,
372    pub bytes_used: u32,
373    /// `(target byte offset relative to partition, payload bytes)` for every
374    /// non-killed data block. Offset is `bnum * jhdr_size`.
375    pub blocks: Vec<(u64, Vec<u8>)>,
376    /// Total bytes across all retained payloads.
377    pub total_payload: u64,
378}
379
380/// A lightweight per-transaction summary for the history viewer / fsck.
381#[derive(Debug, Clone)]
382pub struct TransactionSummary {
383    pub sequence_num: u32,
384    pub block_count: u32,
385    pub total_bytes: u64,
386    /// `(min, max)` target byte offset across the transaction's blocks, or
387    /// `None` when every block was killed.
388    pub target_range: Option<(u64, u64)>,
389}
390
391/// Read-only state of a parsed journal — enough for the history viewer and the
392/// fsck journal phase.
393#[derive(Debug, Clone)]
394pub struct JournalState {
395    pub info: JournalInfoBlock,
396    pub header: JournalHeader,
397    pub transactions: Vec<TransactionSummary>,
398    /// True when the walk stopped early on a corrupt/short transaction.
399    pub partial: bool,
400    /// Sequence number of the transaction whose block-list-header checksum
401    /// failed (the walk stops there, like macOS replay), if any.
402    pub checksum_mismatch: Option<u32>,
403    /// `(from, to)` for each point where the sequence number did not advance
404    /// by one. macOS stops replay at the first such jump.
405    pub sequence_jumps: Vec<(u32, u32)>,
406}
407
408impl JournalState {
409    /// Total bytes of block payloads across all pending transactions.
410    pub fn pending_bytes(&self) -> u64 {
411        self.transactions.iter().map(|t| t.total_bytes).sum()
412    }
413}
414
415/// A named byte range of one volume metadata structure, used to classify which
416/// subsystem a journaled block targets. Offsets are relative to the partition.
417#[derive(Debug, Clone)]
418pub struct MetadataRegion {
419    pub name: &'static str,
420    pub start: u64,
421    pub end: u64,
422}
423
424/// Classify a target byte offset against a set of metadata regions, returning
425/// the region name or `"fork data"` when it falls outside all of them.
426pub fn classify_target(regions: &[MetadataRegion], target: u64) -> &'static str {
427    for r in regions {
428        if target >= r.start && target < r.end {
429            return r.name;
430        }
431    }
432    "fork data"
433}
434
435/// One journaled block, trimmed for display: where it lands, its size, and a
436/// short hex preview of its leading bytes.
437#[derive(Debug, Clone)]
438pub struct JournalBlockDetail {
439    /// Target byte offset relative to the partition (`bnum * jhdr_size`).
440    pub target: u64,
441    pub bsize: u32,
442    /// First up-to-64 payload bytes, for a hex preview in the viewer.
443    pub preview: Vec<u8>,
444}
445
446/// One transaction, fully decoded for the history viewer (block previews only,
447/// not full payloads, to bound memory on large journals).
448#[derive(Debug, Clone)]
449pub struct JournalTxnDetail {
450    pub sequence_num: u32,
451    pub flags: u32,
452    pub bytes_used: u32,
453    pub total_bytes: u64,
454    pub blocks: Vec<JournalBlockDetail>,
455}
456
457/// Everything the GUI journal viewer needs: the JIB + header, the decoded
458/// transactions (newest-last, as walked), the metadata regions used to
459/// classify block targets, and any consistency findings.
460#[derive(Debug, Clone)]
461pub struct JournalDetail {
462    pub info: JournalInfoBlock,
463    pub header: JournalHeader,
464    pub transactions: Vec<JournalTxnDetail>,
465    pub regions: Vec<MetadataRegion>,
466    pub checksum_mismatch: Option<u32>,
467    pub sequence_jumps: Vec<(u32, u32)>,
468    pub partial: bool,
469}
470
471/// Largest preview kept per block.
472const PREVIEW_BYTES: usize = 64;
473
474/// Walk a journal into a [`JournalDetail`], capturing bounded per-block
475/// previews. `regions` is supplied by the caller (it needs the volume header's
476/// fork extents). Used by the GUI history viewer (Step 29).
477pub fn read_journal_detail<R: Read + Seek>(
478    reader: &mut R,
479    partition_offset: u64,
480    jib: JournalInfoBlock,
481    header: JournalHeader,
482    regions: Vec<MetadataRegion>,
483) -> JournalDetail {
484    let mut transactions = Vec::new();
485    let mut checksum_mismatch = None;
486    let mut sequence_jumps = Vec::new();
487    let mut partial = false;
488    {
489        let mut walker = JournalWalker::new(reader, partition_offset, &jib, &header);
490        let mut prev_seq: Option<u32> = None;
491        while let Some(item) = walker.next() {
492            match item {
493                Ok(view) => {
494                    if let Some(prev) = prev_seq {
495                        if view.sequence_num != prev && view.sequence_num != prev.wrapping_add(1) {
496                            sequence_jumps.push((prev, view.sequence_num));
497                        }
498                    }
499                    prev_seq = Some(view.sequence_num);
500                    let blocks = view
501                        .blocks
502                        .iter()
503                        .map(|(target, data)| JournalBlockDetail {
504                            target: *target,
505                            bsize: data.len() as u32,
506                            preview: data[..data.len().min(PREVIEW_BYTES)].to_vec(),
507                        })
508                        .collect();
509                    transactions.push(JournalTxnDetail {
510                        sequence_num: view.sequence_num,
511                        flags: view.flags,
512                        bytes_used: view.bytes_used,
513                        total_bytes: view.total_payload,
514                        blocks,
515                    });
516                }
517                Err(JournalError::CorruptTransaction { sequence_num }) => {
518                    checksum_mismatch = Some(sequence_num);
519                    partial = true;
520                    break;
521                }
522                Err(_) => {
523                    partial = true;
524                    break;
525                }
526            }
527        }
528    }
529    JournalDetail {
530        info: jib,
531        header,
532        transactions,
533        regions,
534        checksum_mismatch,
535        sequence_jumps,
536        partial,
537    }
538}
539
540/// Walks the live transactions of a journal (`start -> end`, wrapping at
541/// `size`). Each `next` decodes one transaction. The walker reads straight
542/// from the disk so it works on any `Read + Seek` source.
543pub struct JournalWalker<'a, R: Read + Seek> {
544    reader: &'a mut R,
545    partition_offset: u64,
546    jib_offset: u64,
547    jhdr_size: u64,
548    blhdr_size: usize,
549    journal_size: u64,
550    endian: JournalEndian,
551    /// Current journal-relative read position; advances per transaction.
552    cursor: u64,
553    end: u64,
554    /// Once a corrupt/short transaction is hit we stop yielding.
555    done: bool,
556}
557
558impl<'a, R: Read + Seek> JournalWalker<'a, R> {
559    pub fn new(
560        reader: &'a mut R,
561        partition_offset: u64,
562        jib: &JournalInfoBlock,
563        jh: &JournalHeader,
564    ) -> Self {
565        JournalWalker {
566            reader,
567            partition_offset,
568            jib_offset: jib.offset,
569            jhdr_size: jh.jhdr_size as u64,
570            blhdr_size: jh.blhdr_size as usize,
571            journal_size: jh.size,
572            endian: jh.endian,
573            cursor: jh.normalized_start(),
574            end: jh.normalized_end(),
575            done: jh.is_empty(),
576        }
577    }
578
579    /// Wrap a journal-relative offset that has reached/passed the end of the
580    /// circular buffer back to the first byte after the header block.
581    fn wrap(&self, off: u64) -> u64 {
582        if off >= self.journal_size {
583            self.jhdr_size + (off - self.journal_size)
584        } else {
585            off
586        }
587    }
588
589    /// Read `len` bytes starting at journal-relative `rel`, wrapping at
590    /// `journal_size` back to `jhdr_size` (the circular buffer convention).
591    fn read_wrapped(&mut self, rel: u64, len: usize) -> Result<Vec<u8>> {
592        let mut out = Vec::with_capacity(len);
593        let mut pos = self.wrap(rel);
594        while out.len() < len {
595            let space = (self.journal_size - pos) as usize;
596            let chunk = (len - out.len()).min(space);
597            let abs = self.partition_offset + self.jib_offset + pos;
598            self.reader.seek(SeekFrom::Start(abs))?;
599            let mut buf = vec![0u8; chunk];
600            self.reader.read_exact(&mut buf)?;
601            out.extend_from_slice(&buf);
602            pos += chunk as u64;
603            if pos >= self.journal_size {
604                pos = self.jhdr_size;
605            }
606        }
607        Ok(out)
608    }
609
610    /// Decode the next transaction, or `None` at end / after a stop. On a
611    /// corrupt block-list-header checksum, yields `Some(Err(..))` once and then
612    /// stops.
613    #[allow(clippy::should_implement_trait)]
614    pub fn next(&mut self) -> Option<Result<TransactionView>> {
615        if self.done || self.cursor == self.end {
616            return None;
617        }
618        match self.read_transaction() {
619            Ok(view) => Some(Ok(view)),
620            Err(e) => {
621                self.done = true;
622                Some(Err(e))
623            }
624        }
625    }
626
627    fn read_transaction(&mut self) -> Result<TransactionView> {
628        let start = self.cursor;
629        // Read the block-list-header region.
630        let hdr = self.read_wrapped(start, self.blhdr_size)?;
631
632        let num_blocks = self.endian.read_u16(&hdr[2..4]) as usize;
633        let bytes_used = self.endian.read_u32(&hdr[4..8]);
634        let stored_cksum = self.endian.read_u32(&hdr[8..12]);
635        let flags = self.endian.read_u32(&hdr[12..16]);
636        let max_blocks = self.endian.read_u16(&hdr[0..2]) as usize;
637        let sequence_num = self.endian.read_u32(&hdr[28..32]); // binfo[0].next
638
639        // Verify the block-list-header checksum over the first 32 bytes with
640        // the checksum field (offset 8) zeroed.
641        let mut cksum_buf = hdr[..BLHDR_CHECKSUM_SIZE].to_vec();
642        cksum_buf[8..12].copy_from_slice(&[0u8; 4]);
643        if calc_checksum(&cksum_buf) != stored_cksum {
644            return Err(JournalError::CorruptTransaction { sequence_num });
645        }
646
647        if num_blocks == 0 || num_blocks > max_blocks {
648            return Err(JournalError::OutOfRange(format!(
649                "num_blocks {num_blocks} out of range (max {max_blocks})"
650            )));
651        }
652        if (bytes_used as usize) < self.blhdr_size || (bytes_used as u64) > self.journal_size {
653            return Err(JournalError::OutOfRange(format!(
654                "bytes_used {bytes_used} invalid"
655            )));
656        }
657
658        // Decode the binfo array (skip binfo[0]).
659        let mut infos: Vec<BlockInfo> = Vec::with_capacity(num_blocks.saturating_sub(1));
660        for i in 1..num_blocks {
661            let off = BLOCK_LIST_HEADER_PREFIX + i * BLOCK_INFO_SIZE;
662            if off + BLOCK_INFO_SIZE > hdr.len() {
663                return Err(JournalError::OutOfRange(
664                    "binfo entry beyond block list header".into(),
665                ));
666            }
667            infos.push(BlockInfo {
668                bnum: self.endian.read_u64(&hdr[off..off + 8]),
669                bsize: self.endian.read_u32(&hdr[off + 8..off + 12]),
670                next: self.endian.read_u32(&hdr[off + 12..off + 16]),
671            });
672        }
673
674        // Read the contiguous data region that follows the header.
675        let data_len = bytes_used as usize - self.blhdr_size;
676        let data = self.read_wrapped(self.wrap(start + self.blhdr_size as u64), data_len)?;
677
678        // Slice the data per block, honoring killed entries and the
679        // end-of-list sentinel.
680        let mut blocks = Vec::new();
681        let mut total_payload: u64 = 0;
682        let mut data_pos = 0usize;
683        for info in &infos {
684            let bsize = info.bsize as usize;
685            if data_pos + bsize > data.len() {
686                return Err(JournalError::OutOfRange(
687                    "block payload beyond transaction data".into(),
688                ));
689            }
690            if info.bnum != KILLED_BLOCK && info.bsize != 0 {
691                let target = info.bnum * self.jhdr_size;
692                blocks.push((target, data[data_pos..data_pos + bsize].to_vec()));
693                total_payload += bsize as u64;
694            }
695            data_pos += bsize;
696        }
697
698        // Advance to the next transaction.
699        self.cursor = self.wrap(start + bytes_used as u64);
700
701        Ok(TransactionView {
702            sequence_num,
703            flags,
704            bytes_used,
705            blocks,
706            total_payload,
707        })
708    }
709}
710
711impl TransactionView {
712    /// Reduce to a summary for display / statistics.
713    pub fn summary(&self) -> TransactionSummary {
714        let target_range = self.blocks.iter().fold(None, |acc, (off, data)| {
715            let lo = *off;
716            let hi = off + data.len() as u64;
717            match acc {
718                None => Some((lo, hi)),
719                Some((amin, amax)) => Some((amin.min(lo), amax.max(hi))),
720            }
721        });
722        TransactionSummary {
723            sequence_num: self.sequence_num,
724            block_count: self.blocks.len() as u32,
725            total_bytes: self.total_payload,
726            target_range,
727        }
728    }
729}
730
731/// Report from [`replay_journal`].
732#[derive(Debug, Clone, Default)]
733pub struct ReplayReport {
734    pub transactions_applied: u32,
735    pub blocks_written: u64,
736    pub bytes_written: u64,
737    pub last_sequence_num: u32,
738    /// True when replay stopped early at a corrupt transaction (data up to the
739    /// last good transaction was still applied — matches macOS behavior).
740    pub partial: bool,
741    pub summaries: Vec<TransactionSummary>,
742}
743
744/// Replay a dirty journal in place. For each transaction from `start -> end`:
745/// verify its checksum, write every non-killed block to its real location
746/// (`partition_offset + bnum * jhdr_size`), then advance `jh.start` past it and
747/// rewrite the header. Advancing the header per transaction makes replay
748/// idempotent — a crash mid-replay leaves the volume recoverable on retry.
749///
750/// Replay does **not** clear the journaled bit. The catalog is simply brought
751/// current; the volume stays journaled.
752pub fn replay_journal<RW: Read + Write + Seek>(
753    disk: &mut RW,
754    jib: &JournalInfoBlock,
755    jh: &mut JournalHeader,
756    partition_offset: u64,
757) -> Result<ReplayReport> {
758    if jib.is_external() {
759        return Err(JournalError::ExternalJournal);
760    }
761
762    let mut report = ReplayReport {
763        last_sequence_num: jh.sequence_num,
764        ..Default::default()
765    };
766
767    // Normalize the wrapped start/end once so the header we persist is sane.
768    jh.start = jh.normalized_start();
769    jh.end = jh.normalized_end();
770
771    loop {
772        if jh.start == jh.end {
773            break;
774        }
775
776        // Decode exactly one transaction at jh.start.
777        let view = {
778            let mut walker = JournalWalker::new(disk, partition_offset, jib, jh);
779            match walker.next() {
780                Some(Ok(view)) => {
781                    // Capture where the walker advanced to (the next start).
782                    let next_start = walker.cursor;
783                    (view, next_start)
784                }
785                Some(Err(JournalError::CorruptTransaction { .. })) | None => {
786                    report.partial = true;
787                    break;
788                }
789                Some(Err(e)) => return Err(e),
790            }
791        };
792        let (view, next_start) = view;
793
794        // Apply each block to its real on-disk location.
795        for (target, payload) in &view.blocks {
796            disk.seek(SeekFrom::Start(partition_offset + *target))?;
797            disk.write_all(payload)?;
798            report.blocks_written += 1;
799            report.bytes_written += payload.len() as u64;
800        }
801        disk.flush()?;
802
803        report.transactions_applied += 1;
804        report.last_sequence_num = view.sequence_num;
805        report.summaries.push(view.summary());
806
807        // Advance start past this transaction and persist the header so a
808        // crash here doesn't re-apply the same transaction.
809        jh.start = next_start;
810        jh.sequence_num = view.sequence_num;
811        write_journal_header(disk, jib, jh, partition_offset)?;
812    }
813
814    // Final: start == end (clean). Persist once more in case the loop body
815    // didn't (e.g. partial stop).
816    if jh.start != jh.end {
817        jh.end = jh.start;
818    }
819    write_journal_header(disk, jib, jh, partition_offset)?;
820    Ok(report)
821}
822
823/// Write the journal header block back to disk, recomputing its checksum.
824fn write_journal_header<RW: Read + Write + Seek>(
825    disk: &mut RW,
826    jib: &JournalInfoBlock,
827    jh: &JournalHeader,
828    partition_offset: u64,
829) -> Result<()> {
830    let abs = partition_offset + jib.offset;
831    // Preserve any vendor padding past the fixed header.
832    disk.seek(SeekFrom::Start(abs))?;
833    let mut orig = vec![0u8; jh.jhdr_size as usize];
834    disk.read_exact(&mut orig)?;
835    let bytes = jh.serialize(Some(&orig));
836    disk.seek(SeekFrom::Start(abs))?;
837    disk.write_all(&bytes)?;
838    disk.flush()?;
839    Ok(())
840}
841
842/// Buffers dirty 512-byte-aligned blocks during one high-level mutation and
843/// emits them as a single journal transaction at commit time. Mirrors the
844/// idempotent last-write-wins semantics of xnu's
845/// `journal_modify_block_start/end`.
846#[derive(Debug, Default)]
847pub struct TransactionBuilder {
848    /// `target byte offset (relative to partition) -> block bytes`. Keyed by
849    /// offset so repeat writes to the same block coalesce (last write wins).
850    dirty: BTreeMap<u64, Vec<u8>>,
851    block_size: u32,
852}
853
854impl TransactionBuilder {
855    /// `block_size` is the journal's `jhdr_size` — the unit `bnum` is scaled by
856    /// and the granularity at which blocks are recorded.
857    pub fn new(block_size: u32) -> Self {
858        TransactionBuilder {
859            dirty: BTreeMap::new(),
860            block_size: block_size.max(1),
861        }
862    }
863
864    pub fn is_empty(&self) -> bool {
865        self.dirty.is_empty()
866    }
867
868    pub fn block_count(&self) -> usize {
869        self.dirty.len()
870    }
871
872    /// Record one `block_size`-aligned block. `offset` must be a multiple of
873    /// `block_size`; `bytes` must be exactly `block_size` long.
874    pub fn record_block(&mut self, offset: u64, bytes: &[u8]) {
875        debug_assert_eq!(offset % self.block_size as u64, 0, "unaligned record_block");
876        debug_assert_eq!(bytes.len(), self.block_size as usize, "wrong block length");
877        self.dirty.insert(offset, bytes.to_vec());
878    }
879
880    /// Record an arbitrary byte range, splitting it into `block_size` chunks.
881    /// The range must be block-aligned in both offset and length.
882    pub fn record_range(&mut self, offset: u64, bytes: &[u8]) {
883        let bs = self.block_size as usize;
884        debug_assert_eq!(offset % self.block_size as u64, 0, "unaligned record_range");
885        debug_assert_eq!(bytes.len() % bs, 0, "record_range length not block-aligned");
886        for (i, chunk) in bytes.chunks(bs).enumerate() {
887            self.dirty.insert(offset + (i * bs) as u64, chunk.to_vec());
888        }
889    }
890
891    /// A preview of the blocks this builder would write, for the GUI.
892    pub fn summary(&self, sequence_num: u32) -> TransactionSummary {
893        let total: u64 = self.dirty.values().map(|v| v.len() as u64).sum();
894        let target_range = self.dirty.iter().fold(None, |acc, (off, data)| {
895            let hi = off + data.len() as u64;
896            match acc {
897                None => Some((*off, hi)),
898                Some((amin, amax)) => Some((amin.min(*off), amax.max(hi))),
899            }
900        });
901        TransactionSummary {
902            sequence_num,
903            block_count: self.dirty.len() as u32,
904            total_bytes: total,
905            target_range,
906        }
907    }
908
909    /// Commit the buffered blocks as one transaction. The six-phase ordering
910    /// matches xnu (TN1150): write the transaction body at `jh.end`, advance
911    /// `jh.end`/`sequence_num` and persist the header, apply the blocks to
912    /// their real locations, then advance `jh.start = jh.end` and persist
913    /// again. A crash between header writes is recoverable by [`replay_journal`].
914    pub fn commit<RW: Read + Write + Seek>(
915        self,
916        disk: &mut RW,
917        jib: &JournalInfoBlock,
918        jh: &mut JournalHeader,
919        partition_offset: u64,
920    ) -> Result<()> {
921        if jib.is_external() {
922            return Err(JournalError::ExternalJournal);
923        }
924        if self.dirty.is_empty() {
925            return Ok(());
926        }
927
928        // Phases 1-4: write the transaction body into the journal and advance
929        // `end`/`sequence_num`. This is the commit point — after the header is
930        // persisted the transaction is durable and replay will re-apply it.
931        self.write_to_journal(disk, jib, jh, partition_offset)?;
932
933        // Phase 5: apply blocks to their real locations.
934        for (offset, bytes) in &self.dirty {
935            disk.seek(SeekFrom::Start(partition_offset + *offset))?;
936            disk.write_all(bytes)?;
937        }
938        disk.flush()?;
939
940        // Phase 6: advance start to end — transaction fully applied, clean.
941        jh.start = jh.end;
942        write_journal_header(disk, jib, jh, partition_offset)?;
943        Ok(())
944    }
945
946    /// Phases 1-4 of [`commit`]: build the transaction body, write it at
947    /// `jh.end` (wrapping), advance `jh.end`/`sequence_num`, and persist the
948    /// header. Leaves the journal *dirty* (`start != end`) — the blocks have
949    /// not yet been applied to their real locations. [`replay_journal`] (or
950    /// the rest of [`commit`]) finishes the job. Exposed for tests that need a
951    /// dirty journal and for callers that batch the checkpoint separately.
952    pub fn write_to_journal<RW: Read + Write + Seek>(
953        &self,
954        disk: &mut RW,
955        jib: &JournalInfoBlock,
956        jh: &mut JournalHeader,
957        partition_offset: u64,
958    ) -> Result<()> {
959        if jib.is_external() {
960            return Err(JournalError::ExternalJournal);
961        }
962        if self.dirty.is_empty() {
963            return Ok(());
964        }
965
966        let blhdr_size = jh.blhdr_size as usize;
967        let endian = jh.endian;
968        let num_data = self.dirty.len();
969        let num_blocks = num_data + 1; // + binfo[0]
970        let max_blocks = blhdr_size / BLOCK_INFO_SIZE - 1;
971        if num_data > max_blocks {
972            return Err(JournalError::OutOfRange(format!(
973                "transaction has {num_data} blocks, exceeds journal max {max_blocks}"
974            )));
975        }
976
977        // Build the block-list-header region.
978        let mut hdr = vec![0u8; blhdr_size];
979        // Fill bytes past the checksum region with 0x5a like xnu does (purely
980        // cosmetic / for visibility in dumps).
981        for b in hdr.iter_mut().skip(BLHDR_CHECKSUM_SIZE) {
982            *b = 0x5a;
983        }
984
985        let bs = self.block_size;
986        let mut data_region: Vec<u8> = Vec::new();
987        // binfo[0] is the header entry, so data blocks start at index 1.
988        for (idx, (offset, bytes)) in (1usize..).zip(self.dirty.iter()) {
989            let bnum = offset / bs as u64;
990            let info_off = BLOCK_LIST_HEADER_PREFIX + idx * BLOCK_INFO_SIZE;
991            endian.write_u64(&mut hdr[info_off..info_off + 8], bnum);
992            endian.write_u32(&mut hdr[info_off + 8..info_off + 12], bytes.len() as u32);
993            endian.write_u32(&mut hdr[info_off + 12..info_off + 16], 0); // no per-block cksum
994            data_region.extend_from_slice(bytes);
995        }
996
997        let bytes_used = (blhdr_size + data_region.len()) as u32;
998
999        // Fixed header fields.
1000        endian.write_u16(&mut hdr[0..2], num_blocks as u16); // max_blocks
1001        endian.write_u16(&mut hdr[2..4], num_blocks as u16); // num_blocks
1002        endian.write_u32(&mut hdr[4..8], bytes_used);
1003        endian.write_u32(&mut hdr[8..12], 0); // checksum placeholder
1004        endian.write_u32(&mut hdr[12..16], BLHDR_FIRST_HEADER); // no CHECK_CHECKSUMS
1005                                                                // binfo[0]: bnum/bsize unused on replay; next = sequence number.
1006        let seq = jh.sequence_num.wrapping_add(1);
1007        endian.write_u64(&mut hdr[16..24], 0); // binfo[0].bnum
1008        endian.write_u32(&mut hdr[24..28], 0); // binfo[0].bsize
1009        endian.write_u32(&mut hdr[28..32], seq); // binfo[0].next = sequence_num
1010
1011        // Checksum over the first 32 bytes with the checksum field zeroed.
1012        let mut cksum_buf = hdr[..BLHDR_CHECKSUM_SIZE].to_vec();
1013        cksum_buf[8..12].copy_from_slice(&[0u8; 4]);
1014        let cksum = calc_checksum(&cksum_buf);
1015        endian.write_u32(&mut hdr[8..12], cksum);
1016
1017        // Assemble the full transaction body and write it at jh.end, wrapping.
1018        let mut body = hdr;
1019        body.extend_from_slice(&data_region);
1020        let write_start = if jh.end == jh.size {
1021            jh.jhdr_size as u64
1022        } else {
1023            jh.end
1024        };
1025        write_journal_wrapped(disk, jib, jh, partition_offset, write_start, &body)?;
1026
1027        // Advance end + sequence, persist header (commit point).
1028        let mut new_end = write_start + body.len() as u64;
1029        if new_end >= jh.size {
1030            new_end = jh.jhdr_size as u64 + (new_end - jh.size);
1031        }
1032        jh.end = new_end;
1033        jh.sequence_num = seq;
1034        write_journal_header(disk, jib, jh, partition_offset)?;
1035        Ok(())
1036    }
1037}
1038
1039/// Write `body` into the circular journal buffer starting at journal-relative
1040/// `rel`, wrapping at `jh.size` back to `jhdr_size`.
1041fn write_journal_wrapped<RW: Read + Write + Seek>(
1042    disk: &mut RW,
1043    jib: &JournalInfoBlock,
1044    jh: &JournalHeader,
1045    partition_offset: u64,
1046    rel: u64,
1047    body: &[u8],
1048) -> Result<()> {
1049    let mut pos = rel;
1050    let mut written = 0usize;
1051    while written < body.len() {
1052        let space = (jh.size - pos) as usize;
1053        let chunk = (body.len() - written).min(space);
1054        let abs = partition_offset + jib.offset + pos;
1055        disk.seek(SeekFrom::Start(abs))?;
1056        disk.write_all(&body[written..written + chunk])?;
1057        written += chunk;
1058        pos += chunk as u64;
1059        if pos >= jh.size {
1060            pos = jh.jhdr_size as u64;
1061        }
1062    }
1063    Ok(())
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069    use std::io::Cursor;
1070
1071    /// A minimal synthetic in-FS journal: a JIB at block 0, a journal header,
1072    /// and a circular buffer, all packed into one `Vec<u8>` "partition".
1073    struct SyntheticJournal {
1074        disk: Vec<u8>,
1075        partition_offset: u64,
1076        jib: JournalInfoBlock,
1077        header: JournalHeader,
1078    }
1079
1080    const JHDR_SIZE: u32 = 512;
1081    const BLHDR_SIZE: u32 = 512;
1082    const JOURNAL_SIZE: u64 = 64 * 1024;
1083    const JIB_OFFSET: u64 = 4096; // where the journal header lives
1084
1085    fn build_journal(endian: JournalEndian, part_off: u64) -> SyntheticJournal {
1086        let total = part_off + JIB_OFFSET + JOURNAL_SIZE + 4096;
1087        let disk = vec![0u8; total as usize];
1088        let jib = JournalInfoBlock {
1089            flags: KJI_JOURNAL_IN_FS_MASK,
1090            offset: JIB_OFFSET,
1091            size: JOURNAL_SIZE,
1092        };
1093        let header = JournalHeader {
1094            endian,
1095            magic: JOURNAL_HEADER_MAGIC,
1096            start: JHDR_SIZE as u64,
1097            end: JHDR_SIZE as u64,
1098            size: JOURNAL_SIZE,
1099            blhdr_size: BLHDR_SIZE,
1100            checksum: 0,
1101            jhdr_size: JHDR_SIZE,
1102            sequence_num: 0,
1103        };
1104        let mut s = SyntheticJournal {
1105            disk,
1106            partition_offset: part_off,
1107            jib,
1108            header,
1109        };
1110        s.write_header();
1111        s
1112    }
1113
1114    impl SyntheticJournal {
1115        fn write_header(&mut self) {
1116            let abs = (self.partition_offset + self.jib.offset) as usize;
1117            let bytes = self.header.serialize(None);
1118            self.disk[abs..abs + bytes.len()].copy_from_slice(&bytes);
1119        }
1120
1121        fn cursor(&mut self) -> Cursor<&mut Vec<u8>> {
1122            Cursor::new(&mut self.disk)
1123        }
1124
1125        /// Append one transaction writing `(target_byte_offset, payload)` pairs
1126        /// to the journal only (leaves the journal *dirty* — not yet applied),
1127        /// via the production `write_to_journal` path.
1128        fn dirty_blocks(&mut self, blocks: &[(u64, Vec<u8>)]) {
1129            let mut builder = TransactionBuilder::new(self.header.jhdr_size);
1130            for (off, data) in blocks {
1131                builder.record_block(*off, data);
1132            }
1133            let jib = self.jib.clone();
1134            let mut jh = self.header.clone();
1135            let part = self.partition_offset;
1136            {
1137                let mut cur = self.cursor();
1138                builder
1139                    .write_to_journal(&mut cur, &jib, &mut jh, part)
1140                    .unwrap();
1141            }
1142            self.header = jh;
1143        }
1144
1145        /// Full `commit` lifecycle (write + apply + checkpoint), leaving the
1146        /// journal clean.
1147        #[allow(dead_code)]
1148        fn commit_blocks(&mut self, blocks: &[(u64, Vec<u8>)]) {
1149            let mut builder = TransactionBuilder::new(self.header.jhdr_size);
1150            for (off, data) in blocks {
1151                builder.record_block(*off, data);
1152            }
1153            let jib = self.jib.clone();
1154            let mut jh = self.header.clone();
1155            let part = self.partition_offset;
1156            {
1157                let mut cur = self.cursor();
1158                builder.commit(&mut cur, &jib, &mut jh, part).unwrap();
1159            }
1160            self.header = jh;
1161        }
1162
1163        fn read_block_at(&self, target: u64, len: usize) -> Vec<u8> {
1164            let abs = (self.partition_offset + target) as usize;
1165            self.disk[abs..abs + len].to_vec()
1166        }
1167    }
1168
1169    #[test]
1170    fn header_roundtrip_both_endians() {
1171        for endian in [JournalEndian::Big, JournalEndian::Little] {
1172            let s = build_journal(endian, 0);
1173            let abs = (s.partition_offset + s.jib.offset) as usize;
1174            let parsed = JournalHeader::parse(&s.disk[abs..abs + JHDR_SIZE as usize]).unwrap();
1175            assert_eq!(parsed.endian, endian);
1176            assert_eq!(parsed.magic, JOURNAL_HEADER_MAGIC);
1177            assert_eq!(parsed.size, JOURNAL_SIZE);
1178            assert_eq!(parsed.jhdr_size, JHDR_SIZE);
1179        }
1180    }
1181
1182    #[test]
1183    fn walk_two_transactions_both_endians() {
1184        for endian in [JournalEndian::Big, JournalEndian::Little] {
1185            let mut s = build_journal(endian, 8192);
1186            // Two transactions, each one block, targeting different locations.
1187            let blk_a = vec![0xAA; JHDR_SIZE as usize];
1188            let blk_b = vec![0xBB; JHDR_SIZE as usize];
1189            s.dirty_blocks(&[(JIB_OFFSET + JOURNAL_SIZE + 1024, blk_a.clone())]);
1190            s.dirty_blocks(&[(JIB_OFFSET + JOURNAL_SIZE + 2048, blk_b.clone())]);
1191
1192            // Re-read the header from disk and walk.
1193            let abs = (s.partition_offset + s.jib.offset) as usize;
1194            let jh = JournalHeader::parse(&s.disk[abs..abs + JHDR_SIZE as usize]).unwrap();
1195            let jib = s.jib.clone();
1196            let part = s.partition_offset;
1197            let mut cur = s.cursor();
1198            let mut walker = JournalWalker::new(&mut cur, part, &jib, &jh);
1199
1200            let t1 = walker.next().unwrap().unwrap();
1201            assert_eq!(t1.sequence_num, 1);
1202            assert_eq!(t1.blocks.len(), 1);
1203            assert_eq!(t1.blocks[0].1, blk_a);
1204
1205            let t2 = walker.next().unwrap().unwrap();
1206            assert_eq!(t2.sequence_num, 2);
1207            assert_eq!(t2.blocks[0].1, blk_b);
1208
1209            assert!(walker.next().is_none());
1210        }
1211    }
1212
1213    #[test]
1214    fn bad_magic_rejected() {
1215        let mut s = build_journal(JournalEndian::Big, 0);
1216        let abs = (s.partition_offset + s.jib.offset) as usize;
1217        s.disk[abs] ^= 0xFF; // corrupt magic
1218        let err = JournalHeader::parse(&s.disk[abs..abs + JHDR_SIZE as usize]).unwrap_err();
1219        assert!(matches!(
1220            err,
1221            JournalError::BadMagic(_) | JournalError::OutOfRange(_) | JournalError::BadEndian(_)
1222        ));
1223    }
1224
1225    #[test]
1226    fn truncated_header_rejected() {
1227        let s = build_journal(JournalEndian::Big, 0);
1228        let abs = (s.partition_offset + s.jib.offset) as usize;
1229        let err = JournalHeader::parse(&s.disk[abs..abs + 10]).unwrap_err();
1230        assert!(matches!(err, JournalError::OutOfRange(_)));
1231    }
1232
1233    #[test]
1234    fn out_of_range_end_rejected() {
1235        let mut s = build_journal(JournalEndian::Big, 0);
1236        s.header.end = JOURNAL_SIZE + 4096; // past the buffer
1237        s.write_header();
1238        let abs = (s.partition_offset + s.jib.offset) as usize;
1239        let err = JournalHeader::parse(&s.disk[abs..abs + JHDR_SIZE as usize]).unwrap_err();
1240        assert!(matches!(err, JournalError::OutOfRange(_)));
1241    }
1242
1243    #[test]
1244    fn jib_parse_flags() {
1245        let mut block = vec![0u8; 512];
1246        BigEndian::write_u32(&mut block[0..4], KJI_JOURNAL_IN_FS_MASK);
1247        BigEndian::write_u64(&mut block[36..44], 4096);
1248        BigEndian::write_u64(&mut block[44..52], 64 * 1024);
1249        let jib = JournalInfoBlock::parse(&block).unwrap();
1250        assert!(jib.is_in_fs());
1251        assert!(!jib.is_external());
1252        assert_eq!(jib.offset, 4096);
1253        assert_eq!(jib.size, 64 * 1024);
1254    }
1255
1256    // ---- Step 25: replay -------------------------------------------------
1257
1258    #[test]
1259    fn replay_applies_dirty_transaction() {
1260        for endian in [JournalEndian::Big, JournalEndian::Little] {
1261            let mut s = build_journal(endian, 8192);
1262            let target = JIB_OFFSET + JOURNAL_SIZE + 1024;
1263            let payload = vec![0x42; JHDR_SIZE as usize];
1264            // Write to journal but DON'T apply — a dirty volume.
1265            s.dirty_blocks(&[(target, payload.clone())]);
1266            // The real location is still zero.
1267            assert_eq!(
1268                s.read_block_at(target, JHDR_SIZE as usize),
1269                vec![0u8; JHDR_SIZE as usize]
1270            );
1271
1272            let jib = s.jib.clone();
1273            let mut jh = s.header.clone();
1274            let part = s.partition_offset;
1275            let report = {
1276                let mut cur = s.cursor();
1277                replay_journal(&mut cur, &jib, &mut jh, part).unwrap()
1278            };
1279            assert_eq!(report.transactions_applied, 1);
1280            assert_eq!(report.blocks_written, 1);
1281            assert!(!report.partial);
1282            // Now the real location carries the journaled bytes, and the
1283            // journal is clean (start == end).
1284            assert_eq!(s.read_block_at(target, JHDR_SIZE as usize), payload);
1285            assert_eq!(jh.start, jh.end);
1286        }
1287    }
1288
1289    #[test]
1290    fn replay_is_idempotent() {
1291        let mut s = build_journal(JournalEndian::Big, 0);
1292        let target = JIB_OFFSET + JOURNAL_SIZE + 512;
1293        let payload = vec![0x7e; JHDR_SIZE as usize];
1294        s.dirty_blocks(&[(target, payload.clone())]);
1295
1296        let jib = s.jib.clone();
1297        let part = s.partition_offset;
1298
1299        // First replay.
1300        let mut jh = s.header.clone();
1301        {
1302            let mut cur = s.cursor();
1303            replay_journal(&mut cur, &jib, &mut jh, part).unwrap();
1304        }
1305        s.header = jh;
1306        // Re-read header from disk, replay again — nothing to do, no double-apply.
1307        let abs = (part + jib.offset) as usize;
1308        let mut jh2 = JournalHeader::parse(&s.disk[abs..abs + JHDR_SIZE as usize]).unwrap();
1309        let report2 = {
1310            let mut cur = s.cursor();
1311            replay_journal(&mut cur, &jib, &mut jh2, part).unwrap()
1312        };
1313        assert_eq!(report2.transactions_applied, 0);
1314        assert_eq!(s.read_block_at(target, JHDR_SIZE as usize), payload);
1315    }
1316
1317    #[test]
1318    fn replay_stops_at_corrupt_transaction() {
1319        let mut s = build_journal(JournalEndian::Big, 0);
1320        let t1 = JIB_OFFSET + JOURNAL_SIZE + 512;
1321        let t2 = JIB_OFFSET + JOURNAL_SIZE + 1024;
1322        let p1 = vec![0x11; JHDR_SIZE as usize];
1323        let p2 = vec![0x22; JHDR_SIZE as usize];
1324        s.dirty_blocks(&[(t1, p1.clone())]);
1325        // Record where the second transaction's block-list-header lands, then
1326        // corrupt its checksum after writing it.
1327        let second_blhdr = (s.partition_offset + s.jib.offset + s.header.end) as usize;
1328        s.dirty_blocks(&[(t2, p2.clone())]);
1329        // Flip a byte inside the second header's checksum-covered region.
1330        s.disk[second_blhdr + 4] ^= 0xFF;
1331
1332        let jib = s.jib.clone();
1333        let mut jh = s.header.clone();
1334        let part = s.partition_offset;
1335        let report = {
1336            let mut cur = s.cursor();
1337            replay_journal(&mut cur, &jib, &mut jh, part).unwrap()
1338        };
1339        assert_eq!(report.transactions_applied, 1);
1340        assert!(report.partial);
1341        // First transaction applied; second did not land.
1342        assert_eq!(s.read_block_at(t1, JHDR_SIZE as usize), p1);
1343        assert_eq!(
1344            s.read_block_at(t2, JHDR_SIZE as usize),
1345            vec![0u8; JHDR_SIZE as usize]
1346        );
1347    }
1348
1349    // ---- Step 26: transaction recorder ----------------------------------
1350
1351    #[test]
1352    fn recorder_roundtrips_three_blocks() {
1353        let mut s = build_journal(JournalEndian::Big, 4096);
1354        let mut builder = TransactionBuilder::new(s.header.jhdr_size);
1355        let base = JIB_OFFSET + JOURNAL_SIZE;
1356        let blocks: Vec<(u64, Vec<u8>)> = vec![
1357            (base, vec![1u8; JHDR_SIZE as usize]),
1358            (base + 512, vec![2u8; JHDR_SIZE as usize]),
1359            (base + 1024, vec![3u8; JHDR_SIZE as usize]),
1360        ];
1361        for (off, data) in &blocks {
1362            builder.record_block(*off, data);
1363        }
1364        assert_eq!(builder.block_count(), 3);
1365
1366        let jib = s.jib.clone();
1367        let mut jh = s.header.clone();
1368        let part = s.partition_offset;
1369        {
1370            let mut cur = s.cursor();
1371            // write_to_journal so we can walk it back as a dirty txn.
1372            builder
1373                .write_to_journal(&mut cur, &jib, &mut jh, part)
1374                .unwrap();
1375        }
1376
1377        let mut cur = s.cursor();
1378        let mut walker = JournalWalker::new(&mut cur, part, &jib, &jh);
1379        let txn = walker.next().unwrap().unwrap();
1380        assert_eq!(txn.sequence_num, 1);
1381        assert_eq!(txn.blocks.len(), 3);
1382        for (i, (off, data)) in blocks.iter().enumerate() {
1383            assert_eq!(txn.blocks[i].0, *off);
1384            assert_eq!(&txn.blocks[i].1, data);
1385        }
1386        assert!(walker.next().is_none());
1387    }
1388
1389    #[test]
1390    fn recorder_last_write_wins() {
1391        let mut s = build_journal(JournalEndian::Big, 0);
1392        let mut builder = TransactionBuilder::new(s.header.jhdr_size);
1393        let target = JIB_OFFSET + JOURNAL_SIZE;
1394        builder.record_block(target, &vec![0xAA; JHDR_SIZE as usize]);
1395        builder.record_block(target, &vec![0xBB; JHDR_SIZE as usize]); // overwrite
1396        assert_eq!(builder.block_count(), 1);
1397
1398        let jib = s.jib.clone();
1399        let mut jh = s.header.clone();
1400        let part = s.partition_offset;
1401        {
1402            let mut cur = s.cursor();
1403            builder.commit(&mut cur, &jib, &mut jh, part).unwrap();
1404        }
1405        assert_eq!(
1406            s.read_block_at(target, JHDR_SIZE as usize),
1407            vec![0xBB; JHDR_SIZE as usize]
1408        );
1409    }
1410
1411    #[test]
1412    fn recorder_wraparound_splits_and_replays() {
1413        // Small journal so the transaction body straddles the wrap point.
1414        let part = 0u64;
1415        let jhdr = 512u32;
1416        let jsize = 8 * 1024u64;
1417        let jib_off = 4096u64;
1418        let disk = vec![0u8; (part + jib_off + jsize + 8192) as usize];
1419        let jib = JournalInfoBlock {
1420            flags: KJI_JOURNAL_IN_FS_MASK,
1421            offset: jib_off,
1422            size: jsize,
1423        };
1424        let mut header = JournalHeader {
1425            endian: JournalEndian::Big,
1426            magic: JOURNAL_HEADER_MAGIC,
1427            // Start near the end so the body wraps.
1428            start: jsize - 1024,
1429            end: jsize - 1024,
1430            size: jsize,
1431            blhdr_size: 512,
1432            checksum: 0,
1433            jhdr_size: jhdr,
1434            sequence_num: 0,
1435        };
1436        let mut s = SyntheticJournal {
1437            disk,
1438            partition_offset: part,
1439            jib: jib.clone(),
1440            header: header.clone(),
1441        };
1442        s.write_header();
1443        header = s.header.clone();
1444
1445        // A two-block transaction = 512 (hdr) + 2*512 = 1536 bytes, which from
1446        // end = jsize-1024 runs past jsize and wraps.
1447        let target = jib_off + jsize; // beyond journal region, into the tail
1448        let mut builder = TransactionBuilder::new(jhdr);
1449        builder.record_block(target, &vec![0xC1; 512]);
1450        builder.record_block(target + 512, &vec![0xC2; 512]);
1451        {
1452            let mut cur = s.cursor();
1453            builder
1454                .write_to_journal(&mut cur, &jib, &mut header, part)
1455                .unwrap();
1456        }
1457        s.header = header.clone();
1458
1459        // Walk it back across the wrap.
1460        {
1461            let mut cur = s.cursor();
1462            let mut walker = JournalWalker::new(&mut cur, part, &jib, &header);
1463            let txn = walker.next().unwrap().unwrap();
1464            assert_eq!(txn.blocks.len(), 2);
1465            assert_eq!(txn.blocks[0].1, vec![0xC1; 512]);
1466            assert_eq!(txn.blocks[1].1, vec![0xC2; 512]);
1467        }
1468
1469        // And replay applies both across the wrap.
1470        let mut jh = header.clone();
1471        let report = {
1472            let mut cur = s.cursor();
1473            replay_journal(&mut cur, &jib, &mut jh, part).unwrap()
1474        };
1475        assert_eq!(report.blocks_written, 2);
1476        assert_eq!(s.read_block_at(target, 512), vec![0xC1; 512]);
1477        assert_eq!(s.read_block_at(target + 512, 512), vec![0xC2; 512]);
1478    }
1479}