fstool 0.4.0

Build disk images and filesystems (ext2/3/4, MBR, GPT) from a directory tree and TOML spec, in the spirit of genext2fs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
//! HFS+ journal — Path A "real transactions".
//!
//! Apple TN1150 specifies a `JournalInfoBlock` pointing at a circular
//! journal buffer whose first sector is a [`JournalHeader`] (magic
//! `"JNLx"`). Between `start` and `end` lies a sequence of transactions;
//! each transaction is one or more *block lists*, where a block list
//! consists of a `block_list_header_t` + an array of `block_info_t`
//! entries describing the disk blocks being committed, followed by the
//! block data itself.
//!
//! ## On-disk layout (per block list, packed big-endian)
//!
//! ```text
//! 0  2  max_blocks
//! 2  2  num_blocks            (includes the sentinel info[0])
//! 4  4  bytes_used            (header + info_array + data)
//! 8  4  checksum              (zeroed when computing)
//! 12 4  flags / padding
//! 16 .. block_info[num_blocks] {
//!         u64 bnum            (sector number on disk, sector = 512 B)
//!         u32 bsize           (bytes of data; multiple of 512)
//!         u32 next            (in-memory only; written zero)
//!       }
//! ```
//!
//! The first `block_info` slot is a sentinel: its `bnum` is the
//! transaction's total byte size (`bytes_used` above) and its `bsize`
//! is 0. The block_info array is followed by [`BLHDR_SIZE`] padding so
//! that the concatenated block data starts at a fixed offset from the
//! block-list header.
//!
//! ## Endianness
//!
//! Every multi-byte field inside the journal is big-endian, matching the
//! `JOURNAL_HEADER_ENDIAN = 0x12345678` constant we encode in the
//! journal header. A LE-host reader detects the byte-swap via that
//! field and adjusts accordingly.
//!
//! ## Replay
//!
//! [`replay`] walks the circular buffer from `start` to `end`, copies
//! each described data chunk to its target sector, and then advances
//! `start := end` on disk. Idempotent — replaying a clean journal
//! (start == end) is a no-op.
//!
//! ## Crash safety
//!
//! [`JournalLog::commit`] writes the transaction body + advances `end`
//! BEFORE applying the in-place writes. A crash between those two
//! phases leaves a valid journal entry that the next [`replay`] will
//! re-apply, restoring the file system to the post-commit state.

use crate::Result;
use crate::block::BlockDevice;

use super::writer::{JOURNAL_HEADER_ENDIAN, JOURNAL_HEADER_MAGIC, VOL_ATTR_JOURNALED};

/// Hardcoded sector size used for `bnum` in the on-disk block_info_t.
/// macOS uses 512 universally; HFS+ block sizes are larger but the
/// journal always describes I/O in 512-byte units.
pub const JOURNAL_SECTOR: u64 = 512;

/// Size of one block-list-header region (header + info array, before
/// the block data). 4096 bytes gives us up to (4096 / 16) - 1 = 255
/// blocks per list, which is more than enough for a single sync.
pub const BLHDR_SIZE: u32 = 4096;

/// Journal-header size in bytes (one 512-byte sector).
pub const JHDR_SIZE: u32 = 512;

/// Number of bytes occupied by one block_info entry on disk.
const BINFO_SIZE: usize = 16;

/// The 16-byte fixed prefix of a `block_list_header_t` (before the
/// `block_info` array).
const BLHDR_FIXED_SIZE: usize = 16;

/// Per-block pending write. `dev_off` is the byte offset on the
/// underlying volume; `data` is its replacement contents.
#[derive(Debug, Clone)]
pub(crate) struct PendingBlock {
    pub dev_off: u64,
    pub data: Vec<u8>,
}

/// Sink used by the metadata-flush path to route writes either straight
/// through to the device or through the journal as a single transaction.
///
/// * [`FlushSink::Direct`] — writes are applied to `dev` immediately. Used
///   on fresh builds where the volume has no pre-existing journal header
///   yet, so there is no transaction log to thread through.
/// * [`FlushSink::Buffered`] — writes accumulate in memory. The caller is
///   responsible for handing the collected blocks to a [`JournalLog`] and
///   calling [`JournalLog::commit`] so the transaction is journaled
///   atomically before the in-place blocks land.
///
/// The split is necessary because, during a fresh format, the on-disk
/// journal header is itself one of the things `flush` writes — we cannot
/// route that write through a journal that doesn't exist yet.
pub(crate) enum FlushSink<'d> {
    Direct(&'d mut dyn BlockDevice),
    Buffered(Vec<PendingBlock>),
}

impl<'d> FlushSink<'d> {
    /// Apply `data` at `dev_off`. In `Direct` mode this immediately calls
    /// through to `dev.write_at`; in `Buffered` mode the block is recorded
    /// in memory and later committed via the journal.
    pub fn write_at(&mut self, dev_off: u64, data: &[u8]) -> Result<()> {
        match self {
            FlushSink::Direct(dev) => dev.write_at(dev_off, data),
            FlushSink::Buffered(blocks) => {
                // Coalesce identical-range writes so the latest one wins
                // — mirrors `JournalLog::add` so a flush that touches the
                // same block twice (e.g. allocation bitmap before vs.
                // after journal-stub emission) doesn't double-record.
                for slot in blocks.iter_mut() {
                    if slot.dev_off == dev_off && slot.data.len() == data.len() {
                        slot.data.clear();
                        slot.data.extend_from_slice(data);
                        return Ok(());
                    }
                }
                blocks.push(PendingBlock {
                    dev_off,
                    data: data.to_vec(),
                });
                Ok(())
            }
        }
    }
}

/// In-memory journal log. Constructed from the on-disk journal-info
/// block; collects pending writes via [`JournalLog::add`] and emits
/// one transaction per [`JournalLog::commit`].
pub(crate) struct JournalLog {
    /// Byte offset of the journal buffer on the volume.
    pub buf_off: u64,
    /// Size of the journal buffer (the circular ring).
    pub buf_size: u64,
    /// Current `start` field from the on-disk header.
    pub start: u64,
    /// Current `end` field from the on-disk header.
    pub end: u64,
    /// Pending writes accumulated since the last commit. Coalesced
    /// by `dev_off` on commit so the latest data wins.
    pending: Vec<PendingBlock>,
}

impl JournalLog {
    /// Read the volume's journal-info block and journal header. Returns
    /// `Ok(None)` if the volume is not journaled (or the JIB pointer
    /// is zero — defensive fallback).
    pub fn load(
        dev: &mut dyn BlockDevice,
        vh: &super::volume_header::VolumeHeader,
    ) -> Result<Option<Self>> {
        if vh.attributes & VOL_ATTR_JOURNALED == 0 {
            return Ok(None);
        }
        let info_block = vh.journal_info_block;
        if info_block == 0 {
            return Ok(None);
        }
        let bs = u64::from(vh.block_size);
        let info_off = u64::from(info_block) * bs;
        let mut info = [0u8; 52];
        dev.read_at(info_off, &mut info)?;
        let buf_off = u64::from_be_bytes(info[36..44].try_into().unwrap());
        let buf_size = u64::from_be_bytes(info[44..52].try_into().unwrap());
        if buf_off == 0 || buf_size == 0 {
            return Ok(None);
        }
        let mut hdr = [0u8; 24];
        dev.read_at(buf_off, &mut hdr)?;
        let magic = u32::from_be_bytes(hdr[0..4].try_into().unwrap());
        let endian = u32::from_be_bytes(hdr[4..8].try_into().unwrap());
        if magic != JOURNAL_HEADER_MAGIC || endian != JOURNAL_HEADER_ENDIAN {
            return Err(crate::Error::InvalidImage(format!(
                "hfs+ journal: unrecognised header magic/endian \
                 ({magic:#010x}/{endian:#010x})"
            )));
        }
        let start = u64::from_be_bytes(hdr[8..16].try_into().unwrap());
        let end = u64::from_be_bytes(hdr[16..24].try_into().unwrap());
        Ok(Some(Self {
            buf_off,
            buf_size,
            start,
            end,
            pending: Vec::new(),
        }))
    }

    /// True iff there are unreplayed transactions on disk.
    pub fn is_dirty(&self) -> bool {
        self.start != self.end
    }

    /// Queue a pending write. If `data.len()` is not a multiple of
    /// [`JOURNAL_SECTOR`], the recorded buffer is padded with zeros to
    /// the next sector boundary on commit.
    pub fn add(&mut self, dev_off: u64, data: Vec<u8>) {
        // Coalesce identical-range writes so the latest one wins.
        for slot in self.pending.iter_mut() {
            if slot.dev_off == dev_off && slot.data.len() == data.len() {
                slot.data = data;
                return;
            }
        }
        self.pending.push(PendingBlock { dev_off, data });
    }

    /// Bulk-add a list of pending blocks (as collected by
    /// [`FlushSink::Buffered`]). Equivalent to calling [`Self::add`] in a
    /// loop, but moves the buffers in place so we don't double-copy the
    /// (potentially large) catalog / extents / bitmap payloads.
    pub fn add_batch(&mut self, blocks: Vec<PendingBlock>) {
        for b in blocks {
            self.add(b.dev_off, b.data);
        }
    }

    /// Search pending writes for the byte at `dev_off`. Used by the
    /// file handle to serve reads of bytes we've buffered but not yet
    /// committed. Returns the slice (and its start offset) of the
    /// pending block that contains `dev_off`, if any.
    pub fn lookup(&self, dev_off: u64) -> Option<(u64, &[u8])> {
        for p in self.pending.iter().rev() {
            let end = p.dev_off + p.data.len() as u64;
            if dev_off >= p.dev_off && dev_off < end {
                return Some((p.dev_off, &p.data));
            }
        }
        None
    }

    /// Commit the pending writes to disk through the journal. Steps:
    ///   1. Round each block to a whole number of [`JOURNAL_SECTOR`]s.
    ///   2. Build a block-list transaction at offset `end` in the
    ///      circular buffer. Update on-disk `end` (header rewrite).
    ///   3. Apply each block to its target dev offset.
    ///   4. Advance on-disk `start := end`, header rewrite.
    ///
    /// The order is critical: after step 2 a crash leaves a complete
    /// journal entry that the next [`replay`] will redo. Between steps
    /// 3 and 4 a crash also leaves the journal claiming unreplayed
    /// work — replay is idempotent.
    pub fn commit(&mut self, dev: &mut dyn BlockDevice) -> Result<()> {
        if self.pending.is_empty() {
            return Ok(());
        }
        // 1. Lay out the transaction.
        let entries = std::mem::take(&mut self.pending);
        let mut padded: Vec<(u64, Vec<u8>)> = Vec::with_capacity(entries.len());
        for e in entries {
            let pad_len = (e.data.len() as u64).div_ceil(JOURNAL_SECTOR) * JOURNAL_SECTOR;
            let mut buf = e.data;
            buf.resize(pad_len as usize, 0);
            padded.push((e.dev_off, buf));
        }

        // 2. Encode the block list.
        let num_info: u16 = u16::try_from(padded.len() + 1).map_err(|_| {
            crate::Error::Unsupported(
                "hfs+ journal: too many blocks in one transaction (>65534)".into(),
            )
        })?;
        let data_total: u32 = padded
            .iter()
            .map(|(_, d)| d.len() as u32)
            .fold(0u32, |a, b| a.saturating_add(b));
        let bytes_used: u32 = BLHDR_SIZE.checked_add(data_total).ok_or_else(|| {
            crate::Error::Unsupported("hfs+ journal: transaction overflows u32".into())
        })?;
        if u64::from(bytes_used) > self.buf_size - u64::from(JHDR_SIZE) {
            return Err(crate::Error::Unsupported(
                "hfs+ journal: transaction larger than journal buffer".into(),
            ));
        }

        let mut tx = vec![0u8; bytes_used as usize];
        // block_list_header_t (16 B, the first 4 are followed by info[0])
        tx[0..2].copy_from_slice(&num_info.to_be_bytes());
        tx[2..4].copy_from_slice(&num_info.to_be_bytes());
        tx[4..8].copy_from_slice(&bytes_used.to_be_bytes());
        // 8..12 checksum (filled at end)
        // 12..16 flags / padding stay zero

        let info_base = BLHDR_FIXED_SIZE;
        // Slot 0 sentinel.
        tx[info_base..info_base + 8].copy_from_slice(&u64::from(bytes_used).to_be_bytes());
        // bsize / next zero.

        // Slots 1..num_info describe the data blocks.
        for (i, (dev_off, data)) in padded.iter().enumerate() {
            let slot = info_base + (i + 1) * BINFO_SIZE;
            let sector = dev_off / JOURNAL_SECTOR;
            let bsize = data.len() as u32;
            tx[slot..slot + 8].copy_from_slice(&sector.to_be_bytes());
            tx[slot + 8..slot + 12].copy_from_slice(&bsize.to_be_bytes());
        }

        // 3. Concatenated block data starts at BLHDR_SIZE.
        let mut cursor = BLHDR_SIZE as usize;
        for (_off, data) in &padded {
            tx[cursor..cursor + data.len()].copy_from_slice(data);
            cursor += data.len();
        }

        // 4. Checksum over the 16-byte block_list_header.
        let csum = crc32_reflected(&tx[..BLHDR_FIXED_SIZE]);
        tx[8..12].copy_from_slice(&csum.to_be_bytes());

        // 5. Write the transaction into the ring at `end`.
        let usable = self.buf_size - u64::from(JHDR_SIZE);
        let tx_off_in_ring = self.end - u64::from(JHDR_SIZE);
        let new_end;
        if tx_off_in_ring + u64::from(bytes_used) > usable {
            // Won't fit before the wrap point. Restart at JHDR_SIZE.
            new_end = u64::from(JHDR_SIZE) + u64::from(bytes_used);
            self.write_at_buffer(dev, u64::from(JHDR_SIZE), &tx)?;
        } else {
            new_end = self.end + u64::from(bytes_used);
            self.write_at_buffer(dev, tx_off_in_ring + u64::from(JHDR_SIZE), &tx)?;
        }

        // 6. Persist `end` on disk. From this point on a crash leaves
        //    a valid transaction the next replay will apply.
        write_journal_header(dev, self.buf_off, self.start, new_end, self.buf_size)?;
        dev.sync()?;
        self.end = new_end;

        // 7. Apply the actual block writes in place.
        for (dev_off, data) in &padded {
            dev.write_at(*dev_off, data)?;
        }
        dev.sync()?;

        // 8. Advance `start := end` to mark the transaction replayed.
        write_journal_header(dev, self.buf_off, self.end, self.end, self.buf_size)?;
        dev.sync()?;
        self.start = self.end;

        Ok(())
    }

    /// Write `data` into the journal buffer at a journal-buffer-relative
    /// `ring_off` (i.e. absolute disk offset `buf_off + ring_off`).
    fn write_at_buffer(&self, dev: &mut dyn BlockDevice, ring_off: u64, data: &[u8]) -> Result<()> {
        let abs = self.buf_off + ring_off;
        if abs + data.len() as u64 > self.buf_off + self.buf_size {
            return Err(crate::Error::Unsupported(
                "hfs+ journal: write would overflow buffer".into(),
            ));
        }
        dev.write_at(abs, data)
    }
}

/// Walk `[start, end)` of the journal buffer and apply every block
/// described by every transaction in that range, then on-disk advance
/// `start := end`. Idempotent when `start == end` (no-op).
pub(crate) fn replay(
    dev: &mut dyn BlockDevice,
    vh: &super::volume_header::VolumeHeader,
) -> Result<()> {
    let Some(log) = JournalLog::load(dev, vh)? else {
        return Ok(());
    };
    if !log.is_dirty() {
        return Ok(());
    }
    let mut cursor = log.start;
    let end = log.end;
    while cursor != end {
        let mut hdr_fixed = [0u8; BLHDR_FIXED_SIZE];
        dev.read_at(log.buf_off + cursor, &mut hdr_fixed)?;
        let num_blocks = u16::from_be_bytes(hdr_fixed[2..4].try_into().unwrap()) as usize;
        let bytes_used = u32::from_be_bytes(hdr_fixed[4..8].try_into().unwrap());
        if num_blocks == 0 || bytes_used < BLHDR_SIZE {
            return Err(crate::Error::InvalidImage(format!(
                "hfs+ journal: malformed block list (num={num_blocks}, bytes={bytes_used})"
            )));
        }
        let info_bytes = num_blocks * BINFO_SIZE;
        let mut info = vec![0u8; info_bytes];
        dev.read_at(log.buf_off + cursor + BLHDR_FIXED_SIZE as u64, &mut info)?;
        let mut data_cursor = log.buf_off + cursor + u64::from(BLHDR_SIZE);
        for i in 1..num_blocks {
            let slot = i * BINFO_SIZE;
            let sector = u64::from_be_bytes(info[slot..slot + 8].try_into().unwrap());
            let bsize = u32::from_be_bytes(info[slot + 8..slot + 12].try_into().unwrap()) as usize;
            let mut data = vec![0u8; bsize];
            dev.read_at(data_cursor, &mut data)?;
            let target = sector * JOURNAL_SECTOR;
            dev.write_at(target, &data)?;
            data_cursor += bsize as u64;
        }
        cursor += u64::from(bytes_used);
        // Wrap point.
        if cursor >= log.buf_size {
            cursor = u64::from(JHDR_SIZE);
        }
    }
    write_journal_header(dev, log.buf_off, end, end, log.buf_size)?;
    dev.sync()?;
    Ok(())
}

/// Write a fresh 512-byte journal header at the start of the journal
/// buffer, carrying the supplied `start` / `end` / `size` fields.
pub(crate) fn write_journal_header(
    dev: &mut dyn BlockDevice,
    buf_off: u64,
    start: u64,
    end: u64,
    buf_size: u64,
) -> Result<()> {
    let mut b = [0u8; JHDR_SIZE as usize];
    b[0..4].copy_from_slice(&JOURNAL_HEADER_MAGIC.to_be_bytes());
    b[4..8].copy_from_slice(&JOURNAL_HEADER_ENDIAN.to_be_bytes());
    b[8..16].copy_from_slice(&start.to_be_bytes());
    b[16..24].copy_from_slice(&end.to_be_bytes());
    b[24..32].copy_from_slice(&buf_size.to_be_bytes());
    b[32..36].copy_from_slice(&BLHDR_SIZE.to_be_bytes());
    b[36..40].copy_from_slice(&0u32.to_be_bytes());
    b[40..44].copy_from_slice(&JHDR_SIZE.to_be_bytes());
    let csum = crc32_reflected(&b);
    b[36..40].copy_from_slice(&csum.to_be_bytes());
    dev.write_at(buf_off, &b)?;
    Ok(())
}

/// CRC-32 (reflected poly 0xEDB88320, init 0xFFFFFFFF, no final XOR).
/// Matches Apple's journal-header / block-list-header checksum.
fn crc32_reflected(buf: &[u8]) -> u32 {
    let mut crc: u32 = 0xFFFF_FFFF;
    for &byte in buf {
        let mut c = (crc ^ u32::from(byte)) & 0xff;
        for _ in 0..8 {
            c = if c & 1 != 0 {
                (c >> 1) ^ 0xEDB8_8320
            } else {
                c >> 1
            };
        }
        crc = (crc >> 8) ^ c;
    }
    crc
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::block::MemoryBackend;

    /// Build a minimal in-memory journal buffer (no surrounding HFS+
    /// volume) and verify a single transaction round-trips: commit
    /// records the data, advances start = end, and the target block
    /// has the expected bytes.
    #[test]
    fn journal_commit_writes_data_and_advances_start() {
        let mut dev = MemoryBackend::new(64 * 1024);
        let buf_off: u64 = 4096;
        let buf_size: u64 = 16 * 1024;
        write_journal_header(
            &mut dev,
            buf_off,
            u64::from(JHDR_SIZE),
            u64::from(JHDR_SIZE),
            buf_size,
        )
        .unwrap();
        let mut log = JournalLog {
            buf_off,
            buf_size,
            start: u64::from(JHDR_SIZE),
            end: u64::from(JHDR_SIZE),
            pending: Vec::new(),
        };
        log.add(32 * 1024, vec![0xAB; 512]);
        log.commit(&mut dev).unwrap();
        let mut got = [0u8; 512];
        dev.read_at(32 * 1024, &mut got).unwrap();
        assert!(got.iter().all(|&b| b == 0xAB));
        assert_eq!(log.start, log.end);
    }

    /// A two-step crash simulation: write a transaction (advancing
    /// `end` and applying the writes) but stop before advancing `start`,
    /// then verify replay re-applies the writes idempotently.
    #[test]
    fn replay_reapplies_pending_transaction() {
        let mut dev = MemoryBackend::new(128 * 1024);
        let buf_off: u64 = 8192;
        let buf_size: u64 = 16 * 1024;
        write_journal_header(
            &mut dev,
            buf_off,
            u64::from(JHDR_SIZE),
            u64::from(JHDR_SIZE),
            buf_size,
        )
        .unwrap();
        let mut log = JournalLog {
            buf_off,
            buf_size,
            start: u64::from(JHDR_SIZE),
            end: u64::from(JHDR_SIZE),
            pending: Vec::new(),
        };
        log.add(32 * 1024, vec![0xCD; 512]);
        log.commit(&mut dev).unwrap();
        // Now corrupt the target block so we can verify replay
        // restores it, and rewind `start` so the journal looks dirty.
        dev.write_at(32 * 1024, &[0u8; 512]).unwrap();
        write_journal_header(&mut dev, buf_off, u64::from(JHDR_SIZE), log.end, buf_size).unwrap();
        // Replay needs a VolumeHeader to look up the JIB. Build a
        // minimal one — only the fields replay actually consults
        // (`attributes`, `block_size`, `journal_info_block`) need to
        // be meaningful.
        use crate::fs::hfs_plus::volume_header::{
            ExtentDescriptor, FORK_EXTENT_COUNT, ForkData, VolumeHeader,
        };
        let blank_fork = ForkData {
            logical_size: 0,
            clump_size: 0,
            total_blocks: 0,
            extents: [ExtentDescriptor::default(); FORK_EXTENT_COUNT],
        };
        let vh = VolumeHeader {
            signature: *b"H+",
            version: 4,
            attributes: VOL_ATTR_JOURNALED,
            journal_info_block: 1, // points at block 1; we'll stamp it
            block_size: 4096,
            total_blocks: 16,
            free_blocks: 0,
            next_catalog_id: 16,
            allocation_file: blank_fork,
            extents_file: blank_fork,
            catalog_file: blank_fork,
            attributes_file: blank_fork,
            startup_file: blank_fork,
        };
        let info_off = u64::from(vh.journal_info_block) * u64::from(vh.block_size);
        let mut info = [0u8; 52];
        info[0..4].copy_from_slice(&2u32.to_be_bytes());
        info[36..44].copy_from_slice(&buf_off.to_be_bytes());
        info[44..52].copy_from_slice(&buf_size.to_be_bytes());
        dev.write_at(info_off, &info).unwrap();

        replay(&mut dev, &vh).unwrap();
        let mut got = [0u8; 512];
        dev.read_at(32 * 1024, &mut got).unwrap();
        assert!(got.iter().all(|&b| b == 0xCD), "replay restored data");
    }
}