Skip to main content

ufs/
inode.rs

1//! UFS inode (`struct ufs2_dinode` / `ufs1_dinode`) locate and decode.
2//!
3//! An inode carries a file's metadata (mode, ownership, size, timestamps) and
4//! the block-pointer arrays that map file offsets to disk fragments. UFS2 uses
5//! a 256-byte dinode with 64-bit block pointers and a birthtime; UFS1 a
6//! 128-byte dinode with 32-bit pointers and no birthtime. Field offsets follow
7//! `struct ufs2_dinode` / `struct ufs1_dinode` in the FreeBSD kernel header
8//! `sys/ufs/ufs/dinode.h`, verified field-by-field against the dfvfs `ufs2.raw`
9//! image with the TSK `istat` oracle (see `docs/RESEARCH.md` and
10//! `tests/data/README.md`).
11//!
12//! ## Inode location math
13//!
14//! An inode number maps to a byte offset within the filesystem partition:
15//! `cg = ino / fs_ipg`; the cg's inode table begins at fragment
16//! `cgimin = cg * fs_fpg + fs_iblkno`; so the dinode is at byte
17//! `cgimin * fs_fsize + (ino % fs_ipg) * inode_size`. [`read_inode`] operates on
18//! the **filesystem-partition** bytes (filesystem byte 0) — callers holding a
19//! whole disk image slice past the partition base first.
20
21use crate::bytes::Endian;
22use crate::error::UfsError;
23use crate::superblock::{Superblock, UfsVersion};
24
25/// Number of direct block pointers in a dinode (`UFS_NDADDR`).
26pub const UFS_NDADDR: usize = 12;
27
28/// Number of indirect block pointers in a dinode (`UFS_NIADDR`): single,
29/// double, and triple indirect.
30pub const UFS_NIADDR: usize = 3;
31
32/// Size in bytes of a UFS2 dinode (`sizeof(struct ufs2_dinode)`).
33pub const UFS2_DINODE_SIZE: usize = 256;
34
35/// Size in bytes of a UFS1 dinode (`sizeof(struct ufs1_dinode)`).
36pub const UFS1_DINODE_SIZE: usize = 128;
37
38// ── struct ufs2_dinode field offsets (dinode.h) ──────────────────────────────
39const U2_MODE: usize = 0;
40const U2_NLINK: usize = 2;
41const U2_UID: usize = 4;
42const U2_GID: usize = 8;
43const U2_SIZE: usize = 16;
44const U2_BLOCKS: usize = 24;
45const U2_ATIME: usize = 32;
46const U2_MTIME: usize = 40;
47const U2_CTIME: usize = 48;
48const U2_BIRTHTIME: usize = 56;
49const U2_MTIMENSEC: usize = 64;
50const U2_ATIMENSEC: usize = 68;
51const U2_CTIMENSEC: usize = 72;
52const U2_BIRTHNSEC: usize = 76;
53const U2_DB: usize = 112;
54const U2_IB: usize = 208;
55
56// ── struct ufs1_dinode field offsets (dinode.h) ──────────────────────────────
57const U1_MODE: usize = 0;
58const U1_NLINK: usize = 2;
59const U1_SIZE: usize = 8;
60const U1_ATIME: usize = 16;
61const U1_ATIMENSEC: usize = 20;
62const U1_MTIME: usize = 24;
63const U1_MTIMENSEC: usize = 28;
64const U1_CTIME: usize = 32;
65const U1_CTIMENSEC: usize = 36;
66const U1_DB: usize = 40;
67const U1_IB: usize = 88;
68const U1_BLOCKS: usize = 104;
69const U1_UID: usize = 112;
70const U1_GID: usize = 116;
71
72/// `IFMT` mask over `di_mode` selecting the file-type bits.
73const IFMT: u16 = 0o170_000;
74
75/// A UFS timestamp: whole seconds since the Unix epoch plus a nanosecond
76/// fraction. UFS2 stores seconds as a signed 64-bit value; UFS1 as 32-bit
77/// (widened here). The nanosecond field is a signed 32-bit count.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub struct Timespec {
80    /// Whole seconds since the Unix epoch (may be negative before 1970).
81    pub sec: i64,
82    /// Nanoseconds within the second.
83    pub nsec: i32,
84}
85
86/// The file type decoded from `di_mode & IFMT`.
87///
88/// `#[non_exhaustive]` so a later phase can add a variant (or the `Other`
89/// classification changes) without a breaking change; consumers matching this
90/// enum use a `_` arm.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum FileType {
94    /// Named pipe / FIFO (`IFIFO`, 0o010000).
95    Fifo,
96    /// Character device (`IFCHR`, 0o020000).
97    CharDevice,
98    /// Directory (`IFDIR`, 0o040000).
99    Directory,
100    /// Block device (`IFBLK`, 0o060000).
101    BlockDevice,
102    /// Regular file (`IFREG`, 0o100000).
103    Regular,
104    /// Symbolic link (`IFLNK`, 0o120000).
105    Symlink,
106    /// UNIX-domain socket (`IFSOCK`, 0o140000).
107    Socket,
108    /// Whiteout (`IFWHT`, 0o160000).
109    Whiteout,
110    /// An `IFMT` value not defined by the format — carries the raw type nibble
111    /// so an unknown type is reported with its evidence, never hidden.
112    Other(u16),
113}
114
115impl FileType {
116    /// Classify the file type from a raw `di_mode`.
117    #[must_use]
118    pub fn from_mode(mode: u16) -> Self {
119        match mode & IFMT {
120            0o010_000 => FileType::Fifo,
121            0o020_000 => FileType::CharDevice,
122            0o040_000 => FileType::Directory,
123            0o060_000 => FileType::BlockDevice,
124            0o100_000 => FileType::Regular,
125            0o120_000 => FileType::Symlink,
126            0o140_000 => FileType::Socket,
127            0o160_000 => FileType::Whiteout,
128            other => FileType::Other(other),
129        }
130    }
131}
132
133/// A decoded UFS inode — the metadata and block-pointer arrays a forensic tool
134/// needs. Carries the union (superset) of the UFS1 and UFS2 dinode fields;
135/// UFS1-absent fields (birthtime) are `None`. `#[non_exhaustive]` so later
136/// phases add fields without a breaking change.
137#[derive(Debug, Clone, PartialEq, Eq)]
138#[non_exhaustive]
139pub struct Inode {
140    /// The on-disk version this inode was decoded as.
141    pub version: UfsVersion,
142    /// `di_mode` — file type (`IFMT`) plus permission bits.
143    pub mode: u16,
144    /// The file type decoded from `di_mode & IFMT`.
145    pub file_type: FileType,
146    /// `di_nlink` — hard-link count.
147    pub nlink: u16,
148    /// `di_uid` — owning user id.
149    pub uid: u32,
150    /// `di_gid` — owning group id.
151    pub gid: u32,
152    /// `di_size` — file length in bytes.
153    pub size: u64,
154    /// `di_blocks` — count of 512-byte sectors actually held.
155    pub blocks: u64,
156    /// `di_atime` — last access time.
157    pub atime: Timespec,
158    /// `di_mtime` — last data-modification time.
159    pub mtime: Timespec,
160    /// `di_ctime` — last inode-change time.
161    pub ctime: Timespec,
162    /// `di_birthtime` — inode creation time (UFS2 only; `None` on UFS1).
163    pub birthtime: Option<Timespec>,
164    /// `di_db[UFS_NDADDR]` — direct block pointers (fragment addresses). For a
165    /// fast (inline) symlink these bytes hold the target instead — see
166    /// [`Self::symlink_target`].
167    pub direct: [u64; UFS_NDADDR],
168    /// `di_ib[UFS_NIADDR]` — single/double/triple indirect block pointers
169    /// (fragment addresses).
170    pub indirect: [u64; UFS_NIADDR],
171    /// The inline fast-symlink target bytes, when this inode is a symlink whose
172    /// `di_size` fits within the block-pointer array (`di_size <=
173    /// fs_maxsymlinklen`). `None` for non-symlinks and for slow symlinks whose
174    /// target lives in a data block. The bytes are the raw path (not
175    /// NUL-terminated); length is `size`.
176    fast_symlink: Option<Vec<u8>>,
177}
178
179impl Inode {
180    /// Decode a single dinode from `data`, which must begin at the dinode (a
181    /// 256-byte UFS2 or 128-byte UFS1 record). `version` and `endian` come from
182    /// the superblock. `maxsymlinklen` is `fs_maxsymlinklen`, the inline-symlink
183    /// threshold; pass it so a symlink whose target fits inline is decoded from
184    /// the block-pointer bytes. Use [`read_inode`] to locate and decode by inode
185    /// number; this is the lower-level decode over already-located bytes.
186    ///
187    /// Reads through bounds-checked helpers, so a short `data` never panics —
188    /// missing tail bytes read as `0`. It still fails loud when `data` is too
189    /// short to hold the whole dinode, so a truncated buffer is reported rather
190    /// than silently zero-filled.
191    ///
192    /// # Errors
193    ///
194    /// [`UfsError::Truncated`] if `data` is shorter than the dinode for this
195    /// version.
196    pub fn parse(data: &[u8], version: UfsVersion, endian: Endian) -> Result<Self, UfsError> {
197        Self::parse_with_maxsymlink(data, version, endian, DEFAULT_MAXSYMLINKLEN)
198    }
199
200    /// Decode a dinode using an explicit `fs_maxsymlinklen` (the inline-symlink
201    /// threshold from the superblock). [`Self::parse`] calls this with the
202    /// format default (120); [`read_inode`] passes the superblock's value.
203    ///
204    /// # Errors
205    ///
206    /// [`UfsError::Truncated`] if `data` is shorter than the dinode.
207    pub fn parse_with_maxsymlink(
208        data: &[u8],
209        version: UfsVersion,
210        endian: Endian,
211        maxsymlinklen: i32,
212    ) -> Result<Self, UfsError> {
213        let need = match version {
214            UfsVersion::Ufs1 => UFS1_DINODE_SIZE,
215            UfsVersion::Ufs2 => UFS2_DINODE_SIZE,
216        };
217        if data.len() < need {
218            return Err(UfsError::Truncated {
219                structure: "dinode",
220                need,
221                have: data.len(),
222            });
223        }
224
225        let (mode, nlink, uid, gid, size, blocks, atime, mtime, ctime, birthtime, db_off, ib_off) =
226            match version {
227                UfsVersion::Ufs2 => (
228                    endian.u16(data, U2_MODE),
229                    endian.u16(data, U2_NLINK),
230                    endian.u32(data, U2_UID),
231                    endian.u32(data, U2_GID),
232                    endian.u64(data, U2_SIZE),
233                    endian.u64(data, U2_BLOCKS),
234                    Timespec {
235                        sec: endian.i64(data, U2_ATIME),
236                        nsec: endian.i32(data, U2_ATIMENSEC),
237                    },
238                    Timespec {
239                        sec: endian.i64(data, U2_MTIME),
240                        nsec: endian.i32(data, U2_MTIMENSEC),
241                    },
242                    Timespec {
243                        sec: endian.i64(data, U2_CTIME),
244                        nsec: endian.i32(data, U2_CTIMENSEC),
245                    },
246                    Some(Timespec {
247                        sec: endian.i64(data, U2_BIRTHTIME),
248                        nsec: endian.i32(data, U2_BIRTHNSEC),
249                    }),
250                    U2_DB,
251                    U2_IB,
252                ),
253                UfsVersion::Ufs1 => (
254                    endian.u16(data, U1_MODE),
255                    endian.u16(data, U1_NLINK),
256                    endian.u32(data, U1_UID),
257                    endian.u32(data, U1_GID),
258                    endian.u64(data, U1_SIZE),
259                    u64::from(endian.u32(data, U1_BLOCKS)),
260                    Timespec {
261                        sec: i64::from(endian.i32(data, U1_ATIME)),
262                        nsec: endian.i32(data, U1_ATIMENSEC),
263                    },
264                    Timespec {
265                        sec: i64::from(endian.i32(data, U1_MTIME)),
266                        nsec: endian.i32(data, U1_MTIMENSEC),
267                    },
268                    Timespec {
269                        sec: i64::from(endian.i32(data, U1_CTIME)),
270                        nsec: endian.i32(data, U1_CTIMENSEC),
271                    },
272                    None,
273                    U1_DB,
274                    U1_IB,
275                ),
276            };
277
278        let ptr_size = match version {
279            UfsVersion::Ufs1 => 4usize,
280            UfsVersion::Ufs2 => 8usize,
281        };
282        let read_ptr = |off: usize| -> u64 {
283            match version {
284                UfsVersion::Ufs1 => u64::from(endian.u32(data, off)),
285                UfsVersion::Ufs2 => endian.u64(data, off),
286            }
287        };
288
289        let mut direct = [0u64; UFS_NDADDR];
290        for (i, slot) in direct.iter_mut().enumerate() {
291            *slot = read_ptr(db_off + i * ptr_size);
292        }
293        let mut indirect = [0u64; UFS_NIADDR];
294        for (i, slot) in indirect.iter_mut().enumerate() {
295            *slot = read_ptr(ib_off + i * ptr_size);
296        }
297
298        let file_type = FileType::from_mode(mode);
299
300        // Fast (inline) symlink: a symlink whose target fits within the
301        // block-pointer array region (`di_size <= fs_maxsymlinklen`) stores the
302        // path inline where di_db/di_ib would be, not in a data block. The
303        // region spans (UFS_NDADDR + UFS_NIADDR) pointers = 120 bytes (UFS2) /
304        // 60 bytes (UFS1), which is exactly what fs_maxsymlinklen bounds.
305        let fast_symlink = if file_type == FileType::Symlink
306            && maxsymlinklen > 0
307            && size <= maxsymlinklen as u64
308        {
309            let region_len = (UFS_NDADDR + UFS_NIADDR) * ptr_size;
310            let take = (size as usize).min(region_len);
311            data.get(db_off..db_off + take).map(<[u8]>::to_vec)
312        } else {
313            None
314        };
315
316        Ok(Self {
317            version,
318            mode,
319            file_type,
320            nlink,
321            uid,
322            gid,
323            size,
324            blocks,
325            atime,
326            mtime,
327            ctime,
328            birthtime,
329            direct,
330            indirect,
331            fast_symlink,
332        })
333    }
334
335    /// `true` when this inode is a directory.
336    #[must_use]
337    pub fn is_dir(&self) -> bool {
338        self.file_type == FileType::Directory
339    }
340
341    /// `true` when this inode is a regular file.
342    #[must_use]
343    pub fn is_regular(&self) -> bool {
344        self.file_type == FileType::Regular
345    }
346
347    /// `true` when this inode is a symbolic link.
348    #[must_use]
349    pub fn is_symlink(&self) -> bool {
350        self.file_type == FileType::Symlink
351    }
352
353    /// The inline fast-symlink target bytes, when this inode is a symlink whose
354    /// target is stored inline (`di_size <= fs_maxsymlinklen`). `None` for
355    /// non-symlinks and for slow symlinks whose target lives in a data block
356    /// (those are resolved by reading the block in a later phase). The bytes are
357    /// the raw path, not NUL-terminated.
358    #[must_use]
359    pub fn symlink_target(&self) -> Option<&[u8]> {
360        self.fast_symlink.as_deref()
361    }
362}
363
364/// The format default for `fs_maxsymlinklen` (used by [`Inode::parse`] when the
365/// caller does not supply the superblock's value): 120, the size of the UFS2
366/// block-pointer region `(UFS_NDADDR + UFS_NIADDR) * 8`.
367const DEFAULT_MAXSYMLINKLEN: i32 = ((UFS_NDADDR + UFS_NIADDR) * 8) as i32;
368
369/// Locate and decode the inode numbered `ino` from `partition`, the filesystem
370/// partition bytes (filesystem byte 0 — a caller holding a whole disk image
371/// slices past the BSD-disklabel partition base first).
372///
373/// The byte offset is derived from the superblock geometry (see the module doc):
374/// `cg = ino / fs_ipg`; `cgimin = cg * fs_fpg + fs_iblkno` (fragments); byte =
375/// `cgimin * fs_fsize + (ino % fs_ipg) * inode_size`.
376///
377/// # Errors
378///
379/// - [`UfsError::InodeOutOfRange`] if `ino >= fs_ipg * fs_ncg` (past the last
380///   addressable inode) — carries the requested number and the bound.
381/// - [`UfsError::ImpossibleGeometry`] if a geometry field needed for the address
382///   math is non-positive (e.g. `fs_ipg <= 0`), so the address cannot be
383///   computed — never a panic or a wild read.
384/// - [`UfsError::Truncated`] if the computed offset plus the dinode size lies
385///   outside `partition`.
386pub fn read_inode(partition: &[u8], sb: &Superblock, ino: u64) -> Result<Inode, UfsError> {
387    // Guard the geometry the address math divides/multiplies by; a corrupt
388    // superblock with fs_ipg <= 0 must fail loud, not divide-by-zero or wrap.
389    if sb.ipg <= 0 {
390        return Err(UfsError::ImpossibleGeometry {
391            field: "fs_ipg",
392            value: sb.ipg as u64,
393            limit: i64::MAX as u64,
394        });
395    }
396    if sb.fpg <= 0 {
397        return Err(UfsError::ImpossibleGeometry {
398            field: "fs_fpg",
399            value: sb.fpg as u64,
400            limit: i64::MAX as u64,
401        });
402    }
403    if sb.fsize <= 0 {
404        return Err(UfsError::ImpossibleGeometry {
405            field: "fs_fsize",
406            value: sb.fsize as u64,
407            limit: i64::MAX as u64,
408        });
409    }
410    if sb.iblkno < 0 {
411        return Err(UfsError::ImpossibleGeometry {
412            field: "fs_iblkno",
413            value: sb.iblkno as u64,
414            limit: i64::MAX as u64,
415        });
416    }
417
418    let ipg = sb.ipg as u64;
419    let fpg = sb.fpg as u64;
420    let fsize = sb.fsize as u64;
421    let iblkno = sb.iblkno as u64;
422    let inode_size = u64::from(sb.inode_size());
423
424    // Reject an inode past the filesystem's total inode count.
425    let count = ipg.saturating_mul(u64::from(sb.ncg));
426    if ino >= count {
427        return Err(UfsError::InodeOutOfRange { ino, count });
428    }
429
430    // cg = ino / fs_ipg; cgimin (frags) = cg * fs_fpg + fs_iblkno; byte offset
431    // = cgimin * fs_fsize + (ino % fs_ipg) * inode_size. Saturating throughout
432    // so a hostile/huge value yields a Truncated error at the bounds check,
433    // never an arithmetic overflow.
434    let cg = ino / ipg;
435    let within = ino % ipg;
436    let cgimin = cg.saturating_mul(fpg).saturating_add(iblkno);
437    let byte = cgimin
438        .saturating_mul(fsize)
439        .saturating_add(within.saturating_mul(inode_size));
440
441    let start = usize::try_from(byte).unwrap_or(usize::MAX);
442    let end = start.saturating_add(inode_size as usize);
443    let Some(slice) = partition.get(start..end) else {
444        return Err(UfsError::Truncated {
445            structure: "dinode (located)",
446            need: end,
447            have: partition.len(),
448        });
449    };
450
451    Inode::parse_with_maxsymlink(slice, sb.version, sb.endian, sb.maxsymlinklen)
452}
453
454#[cfg(test)]
455// Octal file-mode literals (0o040755, 0o100644, …) read most clearly ungrouped —
456// the POSIX/Unix convention for modes — so the tests opt out of the
457// digit-grouping lint rather than write non-idiomatic 0o04_0755 forms.
458#[allow(clippy::unreadable_literal)]
459mod tests {
460    use super::*;
461
462    /// Build a minimal UFS2 dinode (256 B) in little-endian with the given
463    /// mode/size and a first direct block, so decode can be exercised without a
464    /// real image.
465    fn ufs2_dinode(mode: u16, size: u64, db0: u64) -> Vec<u8> {
466        let mut d = vec![0u8; UFS2_DINODE_SIZE];
467        d[U2_MODE..U2_MODE + 2].copy_from_slice(&mode.to_le_bytes());
468        d[U2_NLINK..U2_NLINK + 2].copy_from_slice(&1u16.to_le_bytes());
469        d[U2_UID..U2_UID + 4].copy_from_slice(&1000u32.to_le_bytes());
470        d[U2_GID..U2_GID + 4].copy_from_slice(&1000u32.to_le_bytes());
471        d[U2_SIZE..U2_SIZE + 8].copy_from_slice(&size.to_le_bytes());
472        d[U2_BLOCKS..U2_BLOCKS + 8].copy_from_slice(&8u64.to_le_bytes());
473        d[U2_MTIME..U2_MTIME + 8].copy_from_slice(&0x1122_3344i64.to_le_bytes());
474        d[U2_MTIMENSEC..U2_MTIMENSEC + 4].copy_from_slice(&500i32.to_le_bytes());
475        d[U2_BIRTHTIME..U2_BIRTHTIME + 8].copy_from_slice(&0x2233i64.to_le_bytes());
476        d[U2_DB..U2_DB + 8].copy_from_slice(&db0.to_le_bytes());
477        d
478    }
479
480    /// Build a minimal UFS1 dinode (128 B) in little-endian.
481    fn ufs1_dinode(mode: u16, size: u64, db0: u32) -> Vec<u8> {
482        let mut d = vec![0u8; UFS1_DINODE_SIZE];
483        d[U1_MODE..U1_MODE + 2].copy_from_slice(&mode.to_le_bytes());
484        d[U1_NLINK..U1_NLINK + 2].copy_from_slice(&2u16.to_le_bytes());
485        d[U1_SIZE..U1_SIZE + 8].copy_from_slice(&size.to_le_bytes());
486        d[U1_MTIME..U1_MTIME + 4].copy_from_slice(&0x0055_6677u32.to_le_bytes());
487        d[U1_MTIMENSEC..U1_MTIMENSEC + 4].copy_from_slice(&7i32.to_le_bytes());
488        d[U1_BLOCKS..U1_BLOCKS + 4].copy_from_slice(&4u32.to_le_bytes());
489        d[U1_UID..U1_UID + 4].copy_from_slice(&501u32.to_le_bytes());
490        d[U1_GID..U1_GID + 4].copy_from_slice(&20u32.to_le_bytes());
491        d[U1_DB..U1_DB + 4].copy_from_slice(&db0.to_le_bytes());
492        d
493    }
494
495    #[test]
496    fn file_type_from_mode_classifies_all_ifmt() {
497        assert_eq!(FileType::from_mode(0o040755), FileType::Directory);
498        assert_eq!(FileType::from_mode(0o100644), FileType::Regular);
499        assert_eq!(FileType::from_mode(0o120777), FileType::Symlink);
500        assert_eq!(FileType::from_mode(0o010000), FileType::Fifo);
501        assert_eq!(FileType::from_mode(0o020000), FileType::CharDevice);
502        assert_eq!(FileType::from_mode(0o060000), FileType::BlockDevice);
503        assert_eq!(FileType::from_mode(0o140000), FileType::Socket);
504        assert_eq!(FileType::from_mode(0o160000), FileType::Whiteout);
505        // An undefined IFMT nibble carries the raw value.
506        assert_eq!(FileType::from_mode(0o050000), FileType::Other(0o050000));
507    }
508
509    #[test]
510    fn decodes_ufs2_regular_file() {
511        let d = ufs2_dinode(0o100644, 116, 57);
512        let ino = Inode::parse(&d, UfsVersion::Ufs2, Endian::Little).unwrap();
513        assert_eq!(ino.version, UfsVersion::Ufs2);
514        assert_eq!(ino.file_type, FileType::Regular);
515        assert!(ino.is_regular());
516        assert!(!ino.is_dir());
517        assert_eq!(ino.mode & 0o7777, 0o644);
518        assert_eq!(ino.nlink, 1);
519        assert_eq!(ino.uid, 1000);
520        assert_eq!(ino.gid, 1000);
521        assert_eq!(ino.size, 116);
522        assert_eq!(ino.blocks, 8);
523        assert_eq!(ino.mtime.sec, 0x1122_3344);
524        assert_eq!(ino.mtime.nsec, 500);
525        assert_eq!(
526            ino.birthtime,
527            Some(Timespec {
528                sec: 0x2233,
529                nsec: 0
530            })
531        );
532        assert_eq!(ino.direct[0], 57);
533        assert!(ino.direct[1..].iter().all(|&b| b == 0));
534        assert!(ino.symlink_target().is_none());
535    }
536
537    #[test]
538    fn decodes_ufs2_directory() {
539        let d = ufs2_dinode(0o040755, 512, 56);
540        let ino = Inode::parse(&d, UfsVersion::Ufs2, Endian::Little).unwrap();
541        assert!(ino.is_dir());
542        assert_eq!(ino.direct[0], 56);
543    }
544
545    #[test]
546    fn decodes_ufs2_fast_symlink_inline_target() {
547        let target = b"a_directory/another_file";
548        let mut d = ufs2_dinode(0o120755, target.len() as u64, 0);
549        d[U2_DB..U2_DB + target.len()].copy_from_slice(target);
550        let ino = Inode::parse(&d, UfsVersion::Ufs2, Endian::Little).unwrap();
551        assert_eq!(ino.file_type, FileType::Symlink);
552        assert!(ino.is_symlink());
553        assert_eq!(ino.symlink_target(), Some(&target[..]));
554    }
555
556    #[test]
557    fn slow_symlink_over_threshold_has_no_inline_target() {
558        // A symlink whose size exceeds maxsymlinklen stores its target in a data
559        // block, not inline — symlink_target() is None (resolved in a later
560        // phase by reading the block).
561        let d = ufs2_dinode(0o120755, 200, 57);
562        let ino = Inode::parse_with_maxsymlink(&d, UfsVersion::Ufs2, Endian::Little, 120).unwrap();
563        assert_eq!(ino.file_type, FileType::Symlink);
564        assert!(ino.symlink_target().is_none());
565        // The block pointer is still readable as a normal direct block.
566        assert_eq!(ino.direct[0], 57);
567    }
568
569    #[test]
570    fn decodes_ufs2_big_endian() {
571        let mut d = vec![0u8; UFS2_DINODE_SIZE];
572        d[U2_MODE..U2_MODE + 2].copy_from_slice(&0o100644u16.to_be_bytes());
573        d[U2_SIZE..U2_SIZE + 8].copy_from_slice(&999u64.to_be_bytes());
574        d[U2_DB..U2_DB + 8].copy_from_slice(&123u64.to_be_bytes());
575        let ino = Inode::parse(&d, UfsVersion::Ufs2, Endian::Big).unwrap();
576        assert_eq!(ino.file_type, FileType::Regular);
577        assert_eq!(ino.size, 999);
578        assert_eq!(ino.direct[0], 123);
579    }
580
581    #[test]
582    fn decodes_ufs1_dinode_32bit_layout() {
583        let d = ufs1_dinode(0o100600, 4096, 0xdead);
584        let ino = Inode::parse(&d, UfsVersion::Ufs1, Endian::Little).unwrap();
585        assert_eq!(ino.version, UfsVersion::Ufs1);
586        assert_eq!(ino.file_type, FileType::Regular);
587        assert_eq!(ino.mode & 0o7777, 0o600);
588        assert_eq!(ino.nlink, 2);
589        assert_eq!(ino.uid, 501);
590        assert_eq!(ino.gid, 20);
591        assert_eq!(ino.size, 4096);
592        assert_eq!(ino.blocks, 4);
593        assert_eq!(ino.mtime.sec, 0x0055_6677);
594        assert_eq!(ino.mtime.nsec, 7);
595        assert_eq!(ino.birthtime, None, "UFS1 has no birthtime");
596        assert_eq!(ino.direct[0], 0xdead);
597    }
598
599    #[test]
600    fn decodes_ufs1_fast_symlink() {
601        let target = b"../elsewhere";
602        let mut d = ufs1_dinode(0o120777, target.len() as u64, 0);
603        d[U1_DB..U1_DB + target.len()].copy_from_slice(target);
604        // UFS1 inline region = (12 + 3) * 4 = 60 bytes.
605        let ino = Inode::parse_with_maxsymlink(&d, UfsVersion::Ufs1, Endian::Little, 60).unwrap();
606        assert_eq!(ino.symlink_target(), Some(&target[..]));
607    }
608
609    #[test]
610    fn truncated_dinode_fails_loud_not_panic() {
611        let d = vec![0u8; UFS2_DINODE_SIZE - 1];
612        let err = Inode::parse(&d, UfsVersion::Ufs2, Endian::Little).unwrap_err();
613        assert!(matches!(
614            err,
615            UfsError::Truncated {
616                structure: "dinode",
617                need: UFS2_DINODE_SIZE,
618                ..
619            }
620        ));
621    }
622
623    #[test]
624    fn empty_dinode_buffer_does_not_panic() {
625        assert!(matches!(
626            Inode::parse(&[], UfsVersion::Ufs2, Endian::Little),
627            Err(UfsError::Truncated { .. })
628        ));
629    }
630
631    // ── read_inode locate math over a synthetic partition ────────────────────
632
633    /// Build a tiny synthetic partition: a UFS2 superblock at `SBLOCK_UFS2` with a
634    /// known geometry, and one dinode placed where `read_inode` should find it.
635    fn synthetic_partition(ino_to_place: u64, dinode: &[u8]) -> (Vec<u8>, Superblock) {
636        use crate::superblock::{FS_UFS2_MAGIC, SBLOCK_UFS2};
637        // Geometry: fs_iblkno=40 frags, fs_fsize=4096, fs_ipg=128, fs_fpg=256,
638        // fs_ncg=4 — matching the real dfvfs image so the math is identical.
639        let iblkno = 40u64;
640        let fsize = 4096u64;
641        let ipg = 128u64;
642        let fpg = 256u64;
643        let ncg = 4u32;
644        let inode_size = 256u64;
645
646        let cg = ino_to_place / ipg;
647        let within = ino_to_place % ipg;
648        let cgimin = cg * fpg + iblkno;
649        let byte = (cgimin * fsize + within * inode_size) as usize;
650
651        // Size the partition to hold both the placed dinode and the superblock
652        // at SBLOCK_UFS2 (whichever ends later), with a little slack.
653        let sboff = SBLOCK_UFS2;
654        let total = (byte + dinode.len()).max(sboff + 1376) + 16;
655        let mut part = vec![0u8; total];
656        part[byte..byte + dinode.len()].copy_from_slice(dinode);
657        let wr32 = |p: &mut [u8], off: usize, v: i32| {
658            p[off..off + 4].copy_from_slice(&v.to_le_bytes());
659        };
660        let wr64 = |p: &mut [u8], off: usize, v: i64| {
661            p[off..off + 8].copy_from_slice(&v.to_le_bytes());
662        };
663        wr32(&mut part, sboff + 8, 24); // sblkno
664        wr32(&mut part, sboff + 12, 32); // cblkno
665        wr32(&mut part, sboff + 16, iblkno as i32); // iblkno
666        wr32(&mut part, sboff + 20, 48); // dblkno
667        wr32(&mut part, sboff + 44, ncg as i32); // ncg
668        wr32(&mut part, sboff + 48, 32768); // bsize
669        wr32(&mut part, sboff + 52, fsize as i32); // fsize
670        wr32(&mut part, sboff + 56, 8); // frag
671        wr32(&mut part, sboff + 80, 15); // bshift
672        wr32(&mut part, sboff + 84, 12); // fshift
673        wr32(&mut part, sboff + 120, 128); // inopb
674        wr32(&mut part, sboff + 184, ipg as i32); // ipg
675        wr32(&mut part, sboff + 188, fpg as i32); // fpg
676        wr32(&mut part, sboff + 1320, 120); // maxsymlinklen
677        wr64(&mut part, sboff + 1080, 1022); // size
678        wr64(&mut part, sboff + 1088, 901); // dsize
679        wr64(&mut part, sboff + 1000, SBLOCK_UFS2 as i64); // sblockloc
680        part[sboff + 1372..sboff + 1376].copy_from_slice(&FS_UFS2_MAGIC.to_le_bytes());
681
682        let sb = Superblock::parse(&part[sboff..]).unwrap();
683        (part, sb)
684    }
685
686    #[test]
687    fn read_inode_locates_and_decodes() {
688        let dinode = ufs2_dinode(0o100644, 116, 57);
689        let (part, sb) = synthetic_partition(4, &dinode);
690        let ino = read_inode(&part, &sb, 4).unwrap();
691        assert_eq!(ino.file_type, FileType::Regular);
692        assert_eq!(ino.size, 116);
693        assert_eq!(ino.direct[0], 57);
694    }
695
696    #[test]
697    fn read_inode_rejects_out_of_range() {
698        let (part, sb) = synthetic_partition(4, &ufs2_dinode(0o100644, 1, 1));
699        // fs_ipg (128) * fs_ncg (4) = 512 inodes; 512 is out of range.
700        let err = read_inode(&part, &sb, 512).unwrap_err();
701        assert!(matches!(
702            err,
703            UfsError::InodeOutOfRange {
704                ino: 512,
705                count: 512
706            }
707        ));
708    }
709
710    #[test]
711    fn read_inode_truncated_partition_fails_loud() {
712        let dinode = ufs2_dinode(0o100644, 1, 1);
713        let (mut part, sb) = synthetic_partition(4, &dinode);
714        // Truncate the partition so the located dinode falls off the end, but
715        // keep the superblock (it sits at 65536, past our small inode table).
716        part.truncate(180_000);
717        // Inode in cg1 (>= 128) is located near byte ~1.16 MiB, past the cut.
718        let err = read_inode(&part, &sb, 200).unwrap_err();
719        assert!(matches!(err, UfsError::Truncated { .. }));
720    }
721
722    #[test]
723    fn read_inode_rejects_zero_ipg_geometry() {
724        let dinode = ufs2_dinode(0o100644, 1, 1);
725        let (part, mut sb) = synthetic_partition(4, &dinode);
726        // Force a corrupt fs_ipg on the parsed superblock; read_inode must fail
727        // loud rather than divide by zero.
728        sb.ipg = 0;
729        let err = read_inode(&part, &sb, 4).unwrap_err();
730        assert!(matches!(
731            err,
732            UfsError::ImpossibleGeometry {
733                field: "fs_ipg",
734                ..
735            }
736        ));
737    }
738
739    #[test]
740    fn read_inode_rejects_zero_fpg_geometry() {
741        let (part, mut sb) = synthetic_partition(4, &ufs2_dinode(0o100644, 1, 1));
742        sb.fpg = 0;
743        let err = read_inode(&part, &sb, 4).unwrap_err();
744        assert!(matches!(
745            err,
746            UfsError::ImpossibleGeometry {
747                field: "fs_fpg",
748                ..
749            }
750        ));
751    }
752
753    #[test]
754    fn read_inode_rejects_zero_fsize_geometry() {
755        let (part, mut sb) = synthetic_partition(4, &ufs2_dinode(0o100644, 1, 1));
756        sb.fsize = 0;
757        let err = read_inode(&part, &sb, 4).unwrap_err();
758        assert!(matches!(
759            err,
760            UfsError::ImpossibleGeometry {
761                field: "fs_fsize",
762                ..
763            }
764        ));
765    }
766
767    #[test]
768    fn read_inode_rejects_negative_iblkno_geometry() {
769        let (part, mut sb) = synthetic_partition(4, &ufs2_dinode(0o100644, 1, 1));
770        sb.iblkno = -1;
771        let err = read_inode(&part, &sb, 4).unwrap_err();
772        assert!(matches!(
773            err,
774            UfsError::ImpossibleGeometry {
775                field: "fs_iblkno",
776                ..
777            }
778        ));
779    }
780}