Skip to main content

fs_ext4/
inode.rs

1//! ext4 inode parsing.
2//!
3//! Spec: docs/ext4-spec/inodes-extents.md
4//!
5//! Base inode is 128 bytes; modern ext4 with EXT4_FEATURE_RO_COMPAT_EXTRA_ISIZE
6//! adds another 32 bytes (i_extra_isize) for a total of 160 bytes. All fields
7//! little-endian. The high halves of uid/gid/size/file_acl/blocks/checksum live
8//! at the end of the base 128 bytes; nanosecond timestamps + crtime live in the
9//! extra section.
10
11use crate::error::{Error, Result};
12
13/// Minimum on-disk inode size (rev 0).
14pub const INODE_BASE_SIZE: usize = 128;
15/// The size of the original ext2 inode — `EXT2_GOOD_OLD_INODE_SIZE`.
16///
17/// Everything up to here is the fixed part every ext2/3/4 inode has;
18/// anything past it is the `i_extra_isize` region, which only larger
19/// inodes carry. So it is the length below which an inode buffer cannot
20/// hold `i_checksum_lo` at 0x7C, and the point at which
21/// `Checksummer::verify_inode` refuses.
22///
23/// It was declared in `mkfs.rs`, unused, while `checksum.rs` wrote the
24/// bare `128` twice.
25pub const GOOD_OLD_INODE_SIZE: usize = 128;
26
27/// Offset where the i_extra_isize field begins (start of extra section).
28pub const INODE_EXTRA_OFFSET: usize = 128;
29
30// Raw inode field byte offsets (from the start of the on-disk inode, little-endian).
31// Named so build_*_inode helpers can write fields without requiring readers to
32// memorise the ext4 spec layout. Source: docs/ext4-spec/inodes-extents.md.
33pub(crate) const OFF_MODE: usize = 0x00;
34pub(crate) const OFF_SIZE_LO: usize = 0x04;
35pub(crate) const OFF_ATIME: usize = 0x08;
36pub(crate) const OFF_CTIME: usize = 0x0C;
37pub(crate) const OFF_MTIME: usize = 0x10;
38pub(crate) const OFF_LINKS_COUNT: usize = 0x1A;
39pub(crate) const OFF_BLOCKS_LO: usize = 0x1C;
40pub(crate) const OFF_FLAGS: usize = 0x20;
41pub(crate) const OFF_BLOCK: usize = 0x28; // i_block area start (60 bytes, 0x28..0x64)
42pub(crate) const OFF_GENERATION: usize = 0x64;
43pub(crate) const OFF_SIZE_HI: usize = 0x6C;
44pub(crate) const OFF_BLOCKS_HI: usize = 0x74;
45pub(crate) const OFF_CHECKSUM_LO: usize = 0x7C;
46pub(crate) const OFF_EXTRA_ISIZE: usize = 0x80;
47pub(crate) const OFF_CHECKSUM_HI: usize = 0x82;
48pub(crate) const OFF_CRTIME: usize = 0x90;
49
50/// Default i_extra_isize value written into new inodes: covers checksum_hi,
51/// nsec timestamps, and i_crtime (32 bytes beyond the 128-byte base).
52pub(crate) const EXTRA_ISIZE_DEFAULT: u16 = 32;
53/// Minimum inode buffer length for i_crtime (offset 0x90) to be present.
54pub(crate) const INODE_SIZE_WITH_CRTIME: usize = 0x94;
55/// Minimum inode buffer length for i_extra_isize + i_checksum_hi.
56pub(crate) const INODE_SIZE_WITH_EXTRA: usize = 0x84;
57
58// POSIX file-type bits (high nibble of i_mode).
59pub const S_IFMT: u16 = 0xF000;
60pub const S_IFREG: u16 = 0x8000;
61pub const S_IFDIR: u16 = 0x4000;
62pub const S_IFLNK: u16 = 0xA000;
63pub const S_IFBLK: u16 = 0x6000;
64pub const S_IFCHR: u16 = 0x2000;
65pub const S_IFIFO: u16 = 0x1000;
66pub const S_IFSOCK: u16 = 0xC000;
67
68bitflags::bitflags! {
69    /// `i_flags` — per-inode behaviour flags.
70    /// Spec: kernel.org/doc/html/latest/filesystems/ext4/inodes.html
71    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
72    pub struct InodeFlags: u32 {
73        /// Secure deletion (unused).
74        const SECRM        = 0x0000_0001;
75        /// Undelete (unused).
76        const UNRM         = 0x0000_0002;
77        /// Compressed file.
78        const COMPR        = 0x0000_0004;
79        /// Synchronous writes.
80        const SYNC         = 0x0000_0008;
81        /// Immutable.
82        const IMMUTABLE    = 0x0000_0010;
83        /// Append-only.
84        const APPEND       = 0x0000_0020;
85        /// Do not dump.
86        const NODUMP       = 0x0000_0040;
87        /// Do not update access time.
88        const NOATIME      = 0x0000_0080;
89        /// Hash-tree-indexed directory.
90        const INDEX        = 0x0000_1000;
91        /// File data stored in extended attributes.
92        const EA_INODE     = 0x0020_0000;
93        /// Inode uses extents (EXT4_EXTENTS_FL).
94        const EXTENTS      = 0x0008_0000;
95        /// Inode stores a huge file (i_blocks counted in fs blocks not 512B sectors).
96        const HUGE_FILE    = 0x0004_0000;
97        /// Inline data — file contents live inside i_block + xattrs.
98        const INLINE_DATA  = 0x1000_0000;
99        /// Alias for EXTENTS (matches kernel naming `EXT4_EXTENTS_FL`).
100        const EXTENT       = 0x0008_0000;
101        /// Inode has extra (nanosecond) timestamp fields.
102        const EXTRA_ATIME  = 0x0000_0100;
103    }
104}
105
106/// Parsed ext4 inode.
107///
108/// Combines hi+lo halves for uid, gid, size, file_acl, blocks, and checksum so
109/// callers don't have to reassemble them. Nanosecond timestamps come from the
110/// `*_extra` fields when present (top 30 bits = nsec, low 2 bits = epoch).
111#[derive(Debug, Clone)]
112pub struct Inode {
113    pub mode: u16,
114    pub uid: u32,
115    pub gid: u32,
116    pub size: u64,
117    /// Seconds since the Unix epoch, **signed and 64-bit**.
118    ///
119    /// The on-disk base field is a signed 32-bit value, so dates before
120    /// 1970 are representable and must not be read as far-future ones.
121    /// When `i_extra_isize` is large enough, the low two bits of the
122    /// matching `*_extra` field extend the seconds by `<< 32`, widening
123    /// the range from 1901..2038 to roughly 1901..2446. Both are
124    /// applied here; see `decode_extra_time`.
125    pub atime: i64,
126    pub mtime: i64,
127    pub ctime: i64,
128    pub dtime: i64,
129    pub crtime: i64,
130    pub atime_nsec: u32,
131    pub mtime_nsec: u32,
132    pub ctime_nsec: u32,
133    pub crtime_nsec: u32,
134    pub links_count: u16,
135    pub blocks: u64, // 512-byte sectors (per spec; HUGE_FILE flag changes meaning)
136    pub flags: u32,
137    /// Raw 60-byte i_block area — extent header / direct pointers / inline data.
138    /// Parsed by the extent module.
139    pub block: [u8; 60],
140    pub generation: u32,
141    pub file_acl: u64,
142    pub checksum: u32,
143}
144
145/// Combine a base timestamp with its `*_extra` field.
146///
147/// ext4 stores seconds in two places once `i_extra_isize` is large
148/// enough. The base field is a **signed** 32-bit count from the Unix
149/// epoch — negative values are dates before 1970 and are legal. The
150/// `*_extra` field packs two things: its **low two bits extend the
151/// seconds by 2^32**, and the upper thirty are nanoseconds.
152///
153/// Reading only the base gives 1901..2038. Adding the two epoch bits
154/// gives roughly 1901..2446, which is what the format actually means.
155/// The nanosecond half was already being read (`extra >> 2`); the
156/// epoch half was discarded, so every timestamp past 2038 came back
157/// 136 years early.
158///
159/// Matches `ext4_decode_extra_time` in `fs/ext4/ext4.h`.
160fn decode_extra_time(base: u32, extra: u32) -> i64 {
161    // The base is signed on disk: reinterpret before widening, or a
162    // pre-1970 date becomes a date in 2106.
163    let secs = base as i32 as i64;
164    let epoch_bits = (extra & EXT4_EPOCH_MASK) as i64;
165    secs + (epoch_bits << 32)
166}
167
168/// Low two bits of an `*_extra` field: the seconds extension.
169const EXT4_EPOCH_MASK: u32 = 0x3;
170
171/// The inverse of [`decode_extra_time`]: split a POSIX seconds value
172/// into the on-disk base and the two epoch bits that belong in the low
173/// end of the matching `*_extra` field.
174///
175/// Matches `ext4_encode_extra_time` in `fs/ext4/ext4.h`:
176///
177/// ```c
178/// extra = ((time->tv_sec - (s32)time->tv_sec) >> 32) & EXT4_EPOCH_MASK;
179/// ```
180///
181/// The epoch bits account for the **signed** reinterpretation of the
182/// base, not merely for the bits above 32. 2100-01-01 is 4102444800,
183/// which fits in a `u32` but is negative as an `i32` — so it is stored
184/// as that negative base *plus* an epoch of 1, and the two cancel back
185/// to the right answer. Splitting the value at bit 32 instead would
186/// compute an epoch of 0 and store the wrong date.
187pub(crate) fn encode_extra_time(secs: i64) -> (u32, u32) {
188    let base = secs as u32;
189    let epoch = (((secs - (secs as i32 as i64)) >> 32) as u32) & EXT4_EPOCH_MASK;
190    (base, epoch)
191}
192
193/// The range [`encode_extra_time`] can represent: a signed 32-bit base
194/// plus two epoch bits, so 1901-12-13 through 2446-05-10. A caller
195/// asking to store a time outside this is asking for something the
196/// format cannot hold.
197pub(crate) const MIN_ENCODABLE_TIME: i64 = i32::MIN as i64;
198pub(crate) const MAX_ENCODABLE_TIME: i64 = i32::MAX as i64 + (3i64 << 32);
199
200impl Inode {
201    /// Parse an inode from its on-disk bytes.
202    /// Accepts any length >= 128; if >= 160 and i_extra_isize >= 28, parses the
203    /// extra (nsec + crtime + checksum_hi) section as well.
204    pub fn parse(raw: &[u8]) -> Result<Self> {
205        if raw.len() < INODE_BASE_SIZE {
206            return Err(Error::Corrupt("inode buffer too small"));
207        }
208
209        let mode = u16::from_le_bytes(raw[OFF_MODE..OFF_MODE + 2].try_into().unwrap());
210        let uid_lo = u16::from_le_bytes(raw[0x02..0x04].try_into().unwrap());
211        let size_lo = u32::from_le_bytes(raw[OFF_SIZE_LO..OFF_SIZE_LO + 4].try_into().unwrap());
212        let atime_base = u32::from_le_bytes(raw[OFF_ATIME..OFF_ATIME + 4].try_into().unwrap());
213        let ctime_base = u32::from_le_bytes(raw[OFF_CTIME..OFF_CTIME + 4].try_into().unwrap());
214        let mtime_base = u32::from_le_bytes(raw[OFF_MTIME..OFF_MTIME + 4].try_into().unwrap());
215        let dtime = u32::from_le_bytes(raw[0x14..0x18].try_into().unwrap());
216        let gid_lo = u16::from_le_bytes(raw[0x18..0x1A].try_into().unwrap());
217        let links_count = u16::from_le_bytes(
218            raw[OFF_LINKS_COUNT..OFF_LINKS_COUNT + 2]
219                .try_into()
220                .unwrap(),
221        );
222        let blocks_lo =
223            u32::from_le_bytes(raw[OFF_BLOCKS_LO..OFF_BLOCKS_LO + 4].try_into().unwrap());
224        let flags = u32::from_le_bytes(raw[OFF_FLAGS..OFF_FLAGS + 4].try_into().unwrap());
225        // 0x24..0x28 is i_osd1 (Linux: i_version_lo) — ignored here.
226
227        let mut block = [0u8; 60];
228        block.copy_from_slice(&raw[OFF_BLOCK..OFF_BLOCK + 60]);
229
230        let generation =
231            u32::from_le_bytes(raw[OFF_GENERATION..OFF_GENERATION + 4].try_into().unwrap());
232        let file_acl_lo = u32::from_le_bytes(raw[0x68..0x6C].try_into().unwrap());
233        let size_hi = u32::from_le_bytes(raw[OFF_SIZE_HI..OFF_SIZE_HI + 4].try_into().unwrap());
234        // 0x70..0x74 obso_faddr ignored.
235        let blocks_hi =
236            u16::from_le_bytes(raw[OFF_BLOCKS_HI..OFF_BLOCKS_HI + 2].try_into().unwrap());
237        let file_acl_hi = u16::from_le_bytes(raw[0x76..0x78].try_into().unwrap());
238        let uid_hi = u16::from_le_bytes(raw[0x78..0x7A].try_into().unwrap());
239        let gid_hi = u16::from_le_bytes(raw[0x7A..0x7C].try_into().unwrap());
240        let checksum_lo = u16::from_le_bytes(
241            raw[OFF_CHECKSUM_LO..OFF_CHECKSUM_LO + 2]
242                .try_into()
243                .unwrap(),
244        );
245        // 0x7E..0x80 i_reserved2.
246
247        // Defaults (when no extra section present).
248        let mut atime_nsec = 0u32;
249        let mut mtime_nsec = 0u32;
250        let mut ctime_nsec = 0u32;
251        let mut crtime_nsec = 0u32;
252        let mut crtime_base = 0u32;
253        // The `*_extra` words, zero when i_extra_isize is too small to
254        // hold them — which correctly yields no epoch extension.
255        let mut atime_extra = 0u32;
256        let mut mtime_extra = 0u32;
257        let mut ctime_extra = 0u32;
258        let mut crtime_extra = 0u32;
259        let mut checksum_hi = 0u16;
260
261        // Extra fields — only present when on-disk inode size is >= 160 AND
262        // i_extra_isize covers them (>= 28 includes through i_projid; we read
263        // what we need at >= 24 to cover up to crtime_extra).
264        if raw.len() >= INODE_EXTRA_OFFSET + 4 {
265            let i_extra_isize = u16::from_le_bytes(
266                raw[OFF_EXTRA_ISIZE..OFF_EXTRA_ISIZE + 2]
267                    .try_into()
268                    .unwrap(),
269            );
270            // Sanity: i_extra_isize is the number of bytes beyond the 128-byte
271            // base that are valid. Must fit inside the on-disk inode.
272            let extra_end = INODE_EXTRA_OFFSET + i_extra_isize as usize;
273            if extra_end > raw.len() {
274                return Err(Error::Corrupt("i_extra_isize exceeds inode size"));
275            }
276
277            // Read each extra field only if i_extra_isize covers it.
278            // Layout (offset from inode start):
279            //   0x80 u16 i_extra_isize
280            //   0x82 u16 i_checksum_hi          (needs >= 4)
281            //   0x84 u32 i_ctime_extra          (needs >= 8)
282            //   0x88 u32 i_mtime_extra          (needs >= 12)
283            //   0x8C u32 i_atime_extra          (needs >= 16)
284            //   0x90 u32 i_crtime               (needs >= 20)
285            //   0x94 u32 i_crtime_extra         (needs >= 24)
286            if i_extra_isize >= 4 {
287                checksum_hi = u16::from_le_bytes(
288                    raw[OFF_CHECKSUM_HI..OFF_CHECKSUM_HI + 2]
289                        .try_into()
290                        .unwrap(),
291                );
292            }
293            if i_extra_isize >= 8 {
294                let extra = u32::from_le_bytes(raw[0x84..0x88].try_into().unwrap());
295                ctime_nsec = extra >> 2;
296                ctime_extra = extra;
297            }
298            if i_extra_isize >= 12 {
299                let extra = u32::from_le_bytes(raw[0x88..0x8C].try_into().unwrap());
300                mtime_nsec = extra >> 2;
301                mtime_extra = extra;
302            }
303            if i_extra_isize >= 16 {
304                let extra = u32::from_le_bytes(raw[0x8C..0x90].try_into().unwrap());
305                atime_nsec = extra >> 2;
306                atime_extra = extra;
307            }
308            if i_extra_isize >= 20 {
309                crtime_base =
310                    u32::from_le_bytes(raw[OFF_CRTIME..OFF_CRTIME + 4].try_into().unwrap());
311            }
312            if i_extra_isize >= 24 {
313                let extra = u32::from_le_bytes(raw[0x94..0x98].try_into().unwrap());
314                crtime_nsec = extra >> 2;
315                crtime_extra = extra;
316            }
317        }
318
319        Ok(Self {
320            mode,
321            uid: join16(uid_hi, uid_lo),
322            gid: join16(gid_hi, gid_lo),
323            size: join32(size_hi, size_lo),
324            atime: decode_extra_time(atime_base, atime_extra),
325            mtime: decode_extra_time(mtime_base, mtime_extra),
326            ctime: decode_extra_time(ctime_base, ctime_extra),
327            // dtime has no *_extra field in the format: deletion time
328            // is a plain signed 32-bit value with no epoch extension.
329            dtime: dtime as i32 as i64,
330            crtime: decode_extra_time(crtime_base, crtime_extra),
331            atime_nsec,
332            mtime_nsec,
333            ctime_nsec,
334            crtime_nsec,
335            links_count,
336            blocks: join32(blocks_hi, blocks_lo),
337            flags,
338            block,
339            generation,
340            file_acl: join32(file_acl_hi, file_acl_lo),
341            checksum: join16(checksum_hi, checksum_lo),
342        })
343    }
344
345    /// File type from i_mode.
346    pub fn file_type(&self) -> u16 {
347        self.mode & S_IFMT
348    }
349
350    pub fn is_dir(&self) -> bool {
351        self.file_type() == S_IFDIR
352    }
353
354    pub fn is_file(&self) -> bool {
355        self.file_type() == S_IFREG
356    }
357
358    pub fn is_symlink(&self) -> bool {
359        self.file_type() == S_IFLNK
360    }
361
362    /// True when EXT4_EXTENTS_FL is set in i_flags — i_block holds an extent
363    /// tree rather than legacy direct/indirect block pointers.
364    pub fn has_extents(&self) -> bool {
365        self.flags & InodeFlags::EXTENTS.bits() != 0
366    }
367
368    /// True when INLINE_DATA flag is set — file contents live inside i_block.
369    pub fn has_inline_data(&self) -> bool {
370        self.flags & InodeFlags::INLINE_DATA.bits() != 0
371    }
372
373    /// Decode i_flags into a typed bitflags value (silently drops unknown bits).
374    pub fn flag_set(&self) -> InodeFlags {
375        InodeFlags::from_bits_truncate(self.flags)
376    }
377}
378
379/// Combine two 16-bit halves into a 32-bit value (hi occupies the upper 16 bits).
380/// Used when the on-disk layout stores a 32-bit field split across two u16 words.
381#[inline]
382fn join16(hi: u16, lo: u16) -> u32 {
383    ((hi as u32) << 16) | lo as u32
384}
385
386/// Combine a hi half (any type that fits in u64) and a 32-bit lo half into a
387/// 64-bit value. Used for size, file_acl, and i_blocks whose hi halves have
388/// different widths (u16 or u32) in the on-disk layout.
389#[inline]
390fn join32<H: Into<u64>>(hi: H, lo: u32) -> u64 {
391    (hi.into() << 32) | lo as u64
392}
393
394#[cfg(test)]
395mod timestamp_tests {
396    use super::decode_extra_time;
397
398    /// With no `*_extra` field, a timestamp is the plain signed
399    /// 32-bit value — the pre-2038 behaviour, unchanged.
400    #[test]
401    fn without_an_extra_field_the_base_is_used_as_is() {
402        assert_eq!(decode_extra_time(0, 0), 0);
403        assert_eq!(decode_extra_time(946_684_800, 0), 946_684_800);
404    }
405
406    /// **The base field is signed.** A value with the top bit set is a
407    /// date before 1970, not a date in 2106. Reading it as `u32` was
408    /// the second half of this bug.
409    #[test]
410    fn a_pre_1970_timestamp_stays_negative() {
411        // -1 as a u32 bit pattern: 1969-12-31T23:59:59Z.
412        assert_eq!(decode_extra_time(0xFFFF_FFFF, 0), -1);
413        // 1901-12-13, the earliest a signed 32-bit count reaches.
414        assert_eq!(decode_extra_time(0x8000_0000, 0), i32::MIN as i64);
415    }
416
417    /// **The fix.** The low two bits of `*_extra` extend the seconds
418    /// by 2^32 each, moving the ceiling from 2038 to roughly 2446.
419    ///
420    /// Previously these bits were discarded by the `>> 2` that
421    /// extracts nanoseconds, so every timestamp past 2038 came back
422    /// 136 years early.
423    #[test]
424    fn the_epoch_bits_extend_the_range_past_2038() {
425        // epoch=1 adds 2^32 seconds.
426        assert_eq!(decode_extra_time(0, 0b01), 1i64 << 32);
427        assert_eq!(decode_extra_time(0, 0b10), 2i64 << 32);
428        assert_eq!(decode_extra_time(0, 0b11), 3i64 << 32);
429    }
430
431    /// The nanosecond bits must not leak into the seconds. `*_extra`
432    /// packs both, and only the low two bits are the epoch.
433    #[test]
434    fn the_nanosecond_bits_do_not_affect_the_seconds() {
435        // All thirty nsec bits set, epoch bits clear.
436        let nsec_only = 0xFFFF_FFFCu32;
437        assert_eq!(
438            decode_extra_time(1_000, nsec_only),
439            1_000,
440            "nanoseconds must not be added to the seconds"
441        );
442    }
443
444    /// A real post-2038 timestamp round-trips.
445    ///
446    /// Encoded the way the kernel does it, which is subtler than
447    /// splitting the value at bit 32:
448    ///
449    /// ```c
450    /// extra = ((time->tv_sec - (s32)time->tv_sec) >> 32) & EXT4_EPOCH_MASK;
451    /// ```
452    ///
453    /// The epoch bits account for the **signed** reinterpretation of
454    /// the base, not merely for bits above 32. 2100-01-01 is
455    /// 4102444800, which fits in a `u32` but is negative as an `i32` —
456    /// so it is stored as that negative base *plus* an epoch of 1, and
457    /// the two cancel back to the right answer. A test that split at
458    /// bit 32 would compute epoch=0 and assert the wrong encoding.
459    use super::encode_extra_time as encode;
460
461    #[test]
462    fn a_date_in_2100_decodes_correctly() {
463        const SECS_2100: i64 = 4_102_444_800;
464        let (base, epoch) = encode(SECS_2100);
465        assert_eq!(epoch, 1, "2100 needs the epoch extension");
466        assert_eq!(
467            decode_extra_time(base, epoch),
468            SECS_2100,
469            "a date in 2100 must not come back 136 years early"
470        );
471    }
472
473    /// Round-trip across the interesting boundaries, so the encoder
474    /// and decoder are checked against each other rather than against
475    /// hand-computed constants.
476    #[test]
477    fn timestamps_round_trip_across_the_2038_boundary() {
478        for secs in [
479            i32::MIN as i64,       // 1901
480            -1,                    // 1969
481            0,                     // 1970
482            946_684_800,           // 2000
483            i32::MAX as i64,       // 2038-01-19, the old ceiling
484            i32::MAX as i64 + 1,   // one second past it
485            4_102_444_800,         // 2100
486            (1i64 << 33) + 12_345, // needs both epoch bits
487        ] {
488            let (base, epoch) = encode(secs);
489            assert_eq!(
490                decode_extra_time(base, epoch),
491                secs,
492                "round trip for {secs}"
493            );
494        }
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn join16_combines_halves() {
504        assert_eq!(join16(0x0001, 0x0002), 0x0001_0002);
505        assert_eq!(join16(0xFFFF, 0x0000), 0xFFFF_0000);
506        assert_eq!(join16(0x0000, 0xFFFF), 0x0000_FFFF);
507        assert_eq!(join16(0, 0), 0);
508    }
509
510    #[test]
511    fn join32_combines_halves_u16_hi() {
512        assert_eq!(join32(0x0001u16, 0x0000_0002), 0x0000_0001_0000_0002);
513        assert_eq!(join32(0xFFFFu16, 0x0000_0000), 0x0000_FFFF_0000_0000);
514        assert_eq!(join32(0x0000u16, 0xFFFF_FFFF), 0x0000_0000_FFFF_FFFF);
515    }
516
517    #[test]
518    fn join32_combines_halves_u32_hi() {
519        assert_eq!(join32(0x0000_0001u32, 0x0000_0002), 0x0000_0001_0000_0002);
520        assert_eq!(join32(0xFFFF_FFFFu32, 0x0000_0000), 0xFFFF_FFFF_0000_0000);
521    }
522
523    #[test]
524    fn parse_rejects_short_buffer() {
525        let short = vec![0u8; 64];
526        assert!(matches!(
527            Inode::parse(&short),
528            Err(crate::error::Error::Corrupt(_))
529        ));
530    }
531
532    #[test]
533    fn parse_rejects_invalid_extra_isize() {
534        // 160-byte inode with i_extra_isize claiming 200 bytes (exceeds buffer).
535        let mut raw = vec![0u8; 160];
536        raw[0x80] = 200; // i_extra_isize lo byte — claims 200 bytes extra
537        raw[0x81] = 0;
538        assert!(matches!(
539            Inode::parse(&raw),
540            Err(crate::error::Error::Corrupt(_))
541        ));
542    }
543
544    #[test]
545    fn parse_mode_and_links_roundtrip() {
546        let mut raw = vec![0u8; 128];
547        raw[0x00..0x02].copy_from_slice(&0x81A4u16.to_le_bytes()); // S_IFREG | 0644
548        raw[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes()); // links_count
549        let inode = Inode::parse(&raw).unwrap();
550        assert_eq!(inode.mode, 0x81A4);
551        assert_eq!(inode.links_count, 3);
552        assert!(inode.is_file());
553    }
554}