fstool 0.4.29

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
//! XFS dinode — the on-disk inode structure (`xfs_dinode_core` + fork data).
//!
//! Layout (big-endian throughout):
//!
//! ```text
//!   off  len  field             notes
//!     0    2  di_magic          "IN" (0x494e)
//!     2    2  di_mode           S_IF* | perm bits
//!     4    1  di_version        1, 2, or 3 (v3 == v5 CRC inodes)
//!     5    1  di_format         data-fork format (see XFS_DINODE_FMT_*)
//!     6    2  di_onlink         legacy nlink (v1/v2 only)
//!     8    4  di_uid
//!    12    4  di_gid
//!    16    4  di_nlink          v2+ link count
//!    20    2  di_projid         v2+
//!    22    2  di_projid_hi
//!    24    8  di_pad
//!    32    2  di_flushiter
//!    32    8  (v3) di_changecount
//!    32   16  di_atime/mtime overlap (see below)
//!    32   32  di_atime/mtime/ctime  v2:  3*8-byte timestamps   sec(BE u32) + nsec(BE u32)
//!    56    8  di_size           size in bytes
//!    64    8  di_nblocks        blocks consumed
//!    72    4  di_extsize        preferred extent size
//!    76    4  di_nextents       number of extents in data fork
//!    80    2  di_anextents      number of extents in attribute fork
//!    82    1  di_forkoff        offset in 8-byte words of attribute fork
//!    83    1  di_aformat
//!    84    4  di_dmevmask
//!    88    2  di_dmstate
//!    90    2  di_flags
//!    92    4  di_gen
//!
//!   v3 extension (di_version == 3):
//!    96    4  di_next_unlinked
//!   100    4  di_crc             stored little-endian
//!   104    8  di_changecount
//!   112    8  di_lsn
//!   120    8  di_flags2
//!   128    4  di_cowextsize
//!   132   12  di_pad2
//!   144    8  di_crtime          creation time (sec + nsec)
//!   152    8  di_ino             self-reference
//!   160   16  di_uuid            volume meta UUID
//!   ^-- end of v3 core (176 bytes); fork starts at di_literal_area
//! ```
//!
//! Two literal-area sizes are common:
//! - v2 (256-byte) inode: core = 96 bytes (no v3 extension), literal area = 160 bytes.
//! - v3 (512-byte) inode: core = 176 bytes, literal area = 336 bytes.
//!
//! `di_forkoff` (when non-zero) measures, in 8-byte units, where the
//! attribute fork begins **inside the literal area**. When zero, the
//! attribute fork doesn't exist (or uses the secondary inode-attr format).

use crate::Result;

/// Inode magic: ASCII "IN" big-endian.
pub const XFS_DINODE_MAGIC: u16 = 0x494e;

/// Data-fork formats (`di_format`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiFormat {
    /// Special inode (no fork data, e.g. character devices).
    Dev,
    /// Inline data: shortform directory, symlink target, or zero-length file.
    Local,
    /// Extent list packed into the literal area (the common case).
    Extents,
    /// On-disk B+tree root in the literal area; leaves elsewhere.
    Btree,
    /// Unknown format byte we encountered (recorded so the caller can
    /// produce a useful error).
    Unknown(u8),
}

impl DiFormat {
    fn from_byte(b: u8) -> Self {
        match b {
            0 => Self::Dev,
            1 => Self::Local,
            2 => Self::Extents,
            3 => Self::Btree,
            other => Self::Unknown(other),
        }
    }
}

/// POSIX file-type bits.
pub const S_IFMT: u16 = 0o170_000;
pub const S_IFIFO: u16 = 0o010_000;
pub const S_IFCHR: u16 = 0o020_000;
pub const S_IFDIR: u16 = 0o040_000;
pub const S_IFBLK: u16 = 0o060_000;
pub const S_IFREG: u16 = 0o100_000;
pub const S_IFLNK: u16 = 0o120_000;
pub const S_IFSOCK: u16 = 0o140_000;

/// Encode a `(major, minor)` device number pair the way XFS stores it in
/// the data fork of a `XFS_DINODE_FMT_DEV` inode: a 4-byte big-endian
/// `xfs_dev_t` holding the SysV ("old") encoding the kernel produces with
/// `sysv_encode_dev()` — `minor & 0x3ffff | (major << 18)`.
///
/// See `xfs_dinode_put_rdev` / `xfs_inode_to_disk` in
/// `fs/xfs/libxfs/xfs_inode_buf.c` and `sysv_encode_dev` in
/// `include/linux/kdev_t.h`.
pub fn encode_xfs_dev(major: u32, minor: u32) -> u32 {
    (minor & 0x3_ffff) | ((major & 0x3fff) << 18)
}

/// Inverse of [`encode_xfs_dev`] — `(major, minor)` out of an on-disk
/// `xfs_dev_t` (`sysv_major` / `sysv_minor`).
pub fn decode_xfs_dev(raw: u32) -> (u32, u32) {
    ((raw >> 18) & 0x3fff, raw & 0x3_ffff)
}

/// Offset between the bigtime epoch (1901-12-13 20:45:52 UTC — the
/// smallest 32-bit signed `time_t`) and the Unix epoch, in seconds.
/// `XFS_BIGTIME_EPOCH_OFFSET` in `fs/xfs/libxfs/xfs_format.h` is
/// `-(int64_t)S32_MIN`.
pub const XFS_BIGTIME_EPOCH_OFFSET: i64 = 2_147_483_648;

const NSEC_PER_SEC: u64 = 1_000_000_000;

/// `di_flags2` bit: this inode's timestamps use the bigtime encoding.
pub const XFS_DIFLAG2_BIGTIME: u64 = 0x8;

/// A decoded inode timestamp: seconds since the Unix epoch plus a
/// nanosecond remainder.
///
/// Two on-disk encodings exist. The legacy `xfs_legacy_timestamp` is a
/// 32-bit **signed** big-endian second count followed by a 32-bit
/// nanosecond count. An inode with `XFS_DIFLAG2_BIGTIME` set instead
/// stores one 64-bit big-endian count of nanoseconds since the bigtime
/// epoch — see `xfs_inode_from_disk_ts` / `xfs_inode_decode_bigtime` in
/// `fs/xfs/libxfs/xfs_inode_buf.c`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct XfsTimestamp {
    pub sec: i64,
    pub nsec: u32,
}

impl XfsTimestamp {
    /// Decode the legacy `{__be32 sec, __be32 nsec}` form. The seconds
    /// field is signed, exactly as the kernel reads it.
    pub fn decode(buf: &[u8]) -> Self {
        Self {
            sec: i32::from_be_bytes(buf[0..4].try_into().unwrap()) as i64,
            nsec: u32::from_be_bytes(buf[4..8].try_into().unwrap()),
        }
    }

    /// Decode the BIGTIME form: one `__be64` nanosecond count measured
    /// from 1901-12-13 20:45:52 UTC.
    pub fn decode_bigtime(buf: &[u8]) -> Self {
        let ts = u64::from_be_bytes(buf[0..8].try_into().unwrap());
        Self {
            sec: (ts / NSEC_PER_SEC) as i64 - XFS_BIGTIME_EPOCH_OFFSET,
            nsec: (ts % NSEC_PER_SEC) as u32,
        }
    }

    /// Decode whichever form `bigtime` selects.
    pub fn decode_with(buf: &[u8], bigtime: bool) -> Self {
        if bigtime {
            Self::decode_bigtime(buf)
        } else {
            Self::decode(buf)
        }
    }

    /// Encode back to the on-disk form. `bigtime` must match the
    /// inode's `XFS_DIFLAG2_BIGTIME` state.
    pub fn encode(&self, bigtime: bool) -> [u8; 8] {
        let mut out = [0u8; 8];
        if bigtime {
            let secs = self.sec.saturating_add(XFS_BIGTIME_EPOCH_OFFSET).max(0) as u64;
            let ns = secs
                .saturating_mul(NSEC_PER_SEC)
                .saturating_add(self.nsec.min((NSEC_PER_SEC - 1) as u32) as u64);
            out.copy_from_slice(&ns.to_be_bytes());
        } else {
            out[0..4].copy_from_slice(&(self.sec as i32).to_be_bytes());
            out[4..8].copy_from_slice(&self.nsec.to_be_bytes());
        }
        out
    }
}

/// Decoded inode core. The literal-area bytes (where the fork lives) are
/// returned alongside as a slice into the caller's buffer.
#[derive(Debug, Clone)]
pub struct DinodeCore {
    pub magic: u16,
    pub mode: u16,
    pub version: u8,
    pub format: DiFormat,
    pub uid: u32,
    pub gid: u32,
    pub nlink: u32,
    pub atime: XfsTimestamp,
    pub mtime: XfsTimestamp,
    pub ctime: XfsTimestamp,
    pub size: u64,
    pub nblocks: u64,
    pub nextents: u32,
    /// `di_anextents` — number of extents in the attribute fork.
    pub anextents: u16,
    pub forkoff: u8,
    /// `di_aformat` — attribute-fork format (same encoding as
    /// `di_format`). Meaningless when `forkoff == 0`.
    pub aformat: u8,
    pub flags: u16,
    pub generation: u32,
    /// `di_flags2` (v3 only; 0 on v2 inodes). Carries REFLINK / BIGTIME /
    /// COWEXTSIZE / DAX.
    pub flags2: u64,
    /// v3 (CRC) only: self-reference inode number.
    pub di_ino: Option<u64>,
    /// Byte offset within the inode where the data-fork literal area begins.
    /// 96 for v2 inodes, 176 for v3 inodes.
    pub literal_offset: usize,
}

impl DinodeCore {
    /// Decode the core of an inode on a volume without
    /// `XFS_SB_FEAT_INCOMPAT_NREXT64`. Prefer
    /// [`decode_with`](Self::decode_with), which takes the feature from
    /// the superblock.
    pub fn decode(buf: &[u8]) -> Result<Self> {
        Self::decode_with(buf, false)
    }

    /// Decode the core. `buf` must be at least `inodesize` bytes.
    ///
    /// `nrext64` is the volume's `XFS_SB_FEAT_INCOMPAT_NREXT64` state,
    /// which moves the extent counters inside `xfs_dinode`:
    ///
    /// ```text
    ///                     without NREXT64          with NREXT64
    ///   24  8   di_v3_pad / di_big_nextents  pad   __be64 nextents
    ///   76  4   di_nextents                  __be32 __be32 big_anextents
    ///   80  2   di_anextents                 __be16 __be16 nrext64_pad
    /// ```
    ///
    /// Timestamps follow `di_flags2 & XFS_DIFLAG2_BIGTIME` per inode.
    pub fn decode_with(buf: &[u8], nrext64: bool) -> Result<Self> {
        if buf.len() < 96 {
            return Err(crate::Error::InvalidImage(
                "xfs: inode buffer too small".into(),
            ));
        }
        let magic = u16::from_be_bytes(buf[0..2].try_into().unwrap());
        if magic != XFS_DINODE_MAGIC {
            return Err(crate::Error::InvalidImage(format!(
                "xfs: bad inode magic {magic:#06x} (expected IN)"
            )));
        }
        let mode = u16::from_be_bytes(buf[2..4].try_into().unwrap());
        let version = buf[4];
        let format = DiFormat::from_byte(buf[5]);
        let uid = u32::from_be_bytes(buf[8..12].try_into().unwrap());
        let gid = u32::from_be_bytes(buf[12..16].try_into().unwrap());
        let nlink = u32::from_be_bytes(buf[16..20].try_into().unwrap());
        // di_flags2 (and therefore BIGTIME) only exists on v3 inodes.
        let flags2 = if version >= 3 && buf.len() >= 128 {
            u64::from_be_bytes(buf[120..128].try_into().unwrap())
        } else {
            0
        };
        let bigtime = (flags2 & XFS_DIFLAG2_BIGTIME) != 0;
        let atime = XfsTimestamp::decode_with(&buf[32..40], bigtime);
        let mtime = XfsTimestamp::decode_with(&buf[40..48], bigtime);
        let ctime = XfsTimestamp::decode_with(&buf[48..56], bigtime);
        let size = u64::from_be_bytes(buf[56..64].try_into().unwrap());
        let nblocks = u64::from_be_bytes(buf[64..72].try_into().unwrap());
        let (nextents, anextents) = if nrext64 {
            let big_nextents = u64::from_be_bytes(buf[24..32].try_into().unwrap());
            let big_anextents = u32::from_be_bytes(buf[76..80].try_into().unwrap());
            let nextents = u32::try_from(big_nextents).map_err(|_| {
                crate::Error::Unsupported(format!(
                    "xfs: inode has {big_nextents} data-fork extents (> u32); \
                     64-bit extent counts beyond u32 are not supported"
                ))
            })?;
            let anextents = u16::try_from(big_anextents).map_err(|_| {
                crate::Error::Unsupported(format!(
                    "xfs: inode has {big_anextents} attr-fork extents (> u16); \
                     64-bit extent counts beyond u16 are not supported"
                ))
            })?;
            (nextents, anextents)
        } else {
            (
                u32::from_be_bytes(buf[76..80].try_into().unwrap()),
                u16::from_be_bytes(buf[80..82].try_into().unwrap()),
            )
        };
        let forkoff = buf[82];
        let aformat = buf[83];
        let flags = u16::from_be_bytes(buf[90..92].try_into().unwrap());
        let generation = u32::from_be_bytes(buf[92..96].try_into().unwrap());

        let (literal_offset, di_ino) = if version >= 3 {
            if buf.len() < 176 {
                return Err(crate::Error::InvalidImage(
                    "xfs: v3 inode buffer too small for core".into(),
                ));
            }
            let ino = u64::from_be_bytes(buf[152..160].try_into().unwrap());
            (176, Some(ino))
        } else {
            (96, None)
        };

        if let DiFormat::Unknown(b) = format {
            return Err(crate::Error::Unsupported(format!(
                "xfs: unknown di_format {b}"
            )));
        }

        Ok(Self {
            magic,
            mode,
            version,
            format,
            uid,
            gid,
            nlink,
            atime,
            mtime,
            ctime,
            size,
            nblocks,
            nextents,
            anextents,
            forkoff,
            aformat,
            flags,
            generation,
            flags2,
            di_ino,
            literal_offset,
        })
    }

    /// Slice into `buf` covering the literal area (data fork prefix) of
    /// length `lit_len`. `lit_len` is `inodesize - literal_offset` when the
    /// attribute fork is absent; otherwise it's `forkoff * 8` bytes.
    pub fn literal_area<'a>(&self, buf: &'a [u8], inodesize: usize) -> &'a [u8] {
        let end = if self.forkoff == 0 {
            inodesize
        } else {
            self.literal_offset + (self.forkoff as usize) * 8
        };
        &buf[self.literal_offset..end.min(buf.len())]
    }

    pub fn is_dir(&self) -> bool {
        (self.mode & S_IFMT) == S_IFDIR
    }
    pub fn is_reg(&self) -> bool {
        (self.mode & S_IFMT) == S_IFREG
    }
    pub fn is_symlink(&self) -> bool {
        (self.mode & S_IFMT) == S_IFLNK
    }
}

/// Byte offset of `di_crc` inside a v3 dinode core. CRC32C is computed
/// over the entire on-disk inode (every byte the inode occupies) with
/// this 4-byte field zeroed, then stored as little-endian (`__le32`).
pub const V3_CRC_OFFSET: usize = 100;

/// On-disk byte size of the v3 dinode core (including the v3 extension
/// up to but not including the literal area).
pub const V3_CORE_SIZE: usize = 176;

/// Build a v3 (CRC) dinode. Lays out an `inodesize`-byte buffer with
/// every field except `di_crc`. Use [`stamp_v3_inode_crc`] **after**
/// writing the literal-area (fork) bytes so the checksum covers them.
#[derive(Debug, Clone)]
pub struct V3DinodeBuilder {
    pub inodesize: usize,
    /// The volume's `XFS_SB_FEAT_INCOMPAT_NREXT64` state — it decides
    /// where `di_nextents` / `di_anextents` live. Timestamps switch to
    /// the bigtime encoding from `flags2` instead.
    pub nrext64: bool,
    pub mode: u16,
    pub format: u8,
    pub uid: u32,
    pub gid: u32,
    pub nlink: u32,
    pub atime: XfsTimestamp,
    pub mtime: XfsTimestamp,
    pub ctime: XfsTimestamp,
    pub crtime: XfsTimestamp,
    pub size: u64,
    pub nblocks: u64,
    pub extsize: u32,
    pub nextents: u32,
    /// `di_anextents` — extent count of the attribute fork. Zero for a
    /// shortform (LOCAL) attr fork and for an inode with no attrs.
    pub anextents: u16,
    pub forkoff: u8,
    pub aformat: u8,
    pub flags: u16,
    pub generation: u32,
    pub di_ino: u64,
    pub uuid: [u8; 16],
    /// `di_flags2` (v3 extension, offset 120..128). Carries the
    /// REFLINK / BIGTIME / NREXT64 bits. Most callers leave this `0`;
    /// the `clone_file` writer sets `XFS_DIFLAG2_REFLINK` on both
    /// the source and destination inodes.
    pub flags2: u64,
}

/// `di_flags2` bit: file has shared extents (clone / reflink). XFS
/// kernels require this on every inode that participates in a clone;
/// `xfs_repair` flags the volume corrupt if the bit is unset but the
/// extent is in the REFCNTBT.
pub const XFS_DIFLAG2_REFLINK: u64 = 0x2;

impl V3DinodeBuilder {
    /// Allocate the on-disk inode buffer and stamp every field except
    /// `di_crc`. The literal area starts at byte 176 and is the caller's
    /// to populate.
    pub fn build(&self) -> Vec<u8> {
        let mut buf = vec![0u8; self.inodesize];
        buf[0..2].copy_from_slice(&XFS_DINODE_MAGIC.to_be_bytes());
        buf[2..4].copy_from_slice(&self.mode.to_be_bytes());
        buf[4] = 3;
        buf[5] = self.format;
        // di_onlink (legacy) — v3 zero
        buf[8..12].copy_from_slice(&self.uid.to_be_bytes());
        buf[12..16].copy_from_slice(&self.gid.to_be_bytes());
        buf[16..20].copy_from_slice(&self.nlink.to_be_bytes());
        let bigtime = (self.flags2 & XFS_DIFLAG2_BIGTIME) != 0;
        buf[32..40].copy_from_slice(&self.atime.encode(bigtime));
        buf[40..48].copy_from_slice(&self.mtime.encode(bigtime));
        buf[48..56].copy_from_slice(&self.ctime.encode(bigtime));
        buf[56..64].copy_from_slice(&self.size.to_be_bytes());
        buf[64..72].copy_from_slice(&self.nblocks.to_be_bytes());
        buf[72..76].copy_from_slice(&self.extsize.to_be_bytes());
        if self.nrext64 {
            // di_big_nextents (__be64 @ 24) + di_big_anextents (__be32 @ 76);
            // 80..82 is di_nrext64_pad and stays zero.
            buf[24..32].copy_from_slice(&(self.nextents as u64).to_be_bytes());
            buf[76..80].copy_from_slice(&(self.anextents as u32).to_be_bytes());
        } else {
            buf[76..80].copy_from_slice(&self.nextents.to_be_bytes());
            buf[80..82].copy_from_slice(&self.anextents.to_be_bytes());
        }
        buf[82] = self.forkoff;
        buf[83] = self.aformat;
        buf[90..92].copy_from_slice(&self.flags.to_be_bytes());
        buf[92..96].copy_from_slice(&self.generation.to_be_bytes());
        // di_next_unlinked at 96..100 = NULL (-1)
        buf[96..100].copy_from_slice(&u32::MAX.to_be_bytes());
        // di_crc at 100..104 — caller's responsibility via stamp_v3_inode_crc
        // di_flags2 at 120..128 — REFLINK / BIGTIME / NREXT64 etc.
        buf[120..128].copy_from_slice(&self.flags2.to_be_bytes());
        buf[144..152].copy_from_slice(&self.crtime.encode(bigtime));
        buf[152..160].copy_from_slice(&self.di_ino.to_be_bytes());
        buf[160..176].copy_from_slice(&self.uuid);
        buf
    }
}

/// Compute and store the v3 dinode CRC. CRC32C is taken over the full
/// inode buffer with the 4-byte `di_crc` field at offset 100 zeroed,
/// then written back as little-endian.
pub fn stamp_v3_inode_crc(buf: &mut [u8]) {
    buf[V3_CRC_OFFSET..V3_CRC_OFFSET + 4].copy_from_slice(&[0u8; 4]);
    let crc = crate::crc::crc32c(buf);
    buf[V3_CRC_OFFSET..V3_CRC_OFFSET + 4].copy_from_slice(&crc.to_le_bytes());
}

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

    fn synth_v3_inode(size: u64, format: u8, mode: u16, lit_payload: &[u8]) -> Vec<u8> {
        // 512-byte inode.
        let mut buf = vec![0u8; 512];
        buf[0..2].copy_from_slice(&XFS_DINODE_MAGIC.to_be_bytes());
        buf[2..4].copy_from_slice(&mode.to_be_bytes());
        buf[4] = 3; // v3
        buf[5] = format;
        buf[16..20].copy_from_slice(&1u32.to_be_bytes()); // nlink
        buf[56..64].copy_from_slice(&size.to_be_bytes());
        // di_ino self ref
        buf[152..160].copy_from_slice(&128u64.to_be_bytes());
        // Literal area at 176; copy payload (capped to 336 bytes).
        let n = lit_payload.len().min(512 - 176);
        buf[176..176 + n].copy_from_slice(&lit_payload[..n]);
        buf
    }

    #[test]
    fn decode_v3_local_dir() {
        let buf = synth_v3_inode(0, 1, S_IFDIR | 0o755, &[0xAA; 64]);
        let core = DinodeCore::decode(&buf).unwrap();
        assert_eq!(core.magic, XFS_DINODE_MAGIC);
        assert_eq!(core.version, 3);
        assert_eq!(core.format, DiFormat::Local);
        assert!(core.is_dir());
        assert_eq!(core.literal_offset, 176);
        assert_eq!(core.di_ino, Some(128));
        let lit = core.literal_area(&buf, 512);
        assert_eq!(lit.len(), 512 - 176);
        assert!(lit.iter().take(64).all(|&b| b == 0xAA));
    }

    #[test]
    fn decode_rejects_bad_magic() {
        let mut buf = synth_v3_inode(0, 1, S_IFDIR | 0o755, &[]);
        buf[0] = 0;
        assert!(matches!(
            DinodeCore::decode(&buf),
            Err(crate::Error::InvalidImage(_))
        ));
    }

    #[test]
    fn forkoff_caps_literal_area() {
        let mut buf = synth_v3_inode(0, 1, S_IFREG | 0o644, &[0; 8]);
        // forkoff = 4 words = 32 bytes of data fork inside the literal area.
        buf[82] = 4;
        let core = DinodeCore::decode(&buf).unwrap();
        let lit = core.literal_area(&buf, 512);
        assert_eq!(lit.len(), 32);
    }
}