Skip to main content

ufs/
dir.rs

1//! UFS/FFS directory (`struct direct`) walk and path resolution.
2//!
3//! A UFS directory is a sequence of `DIRBLKSIZ`-byte (512) blocks, each holding
4//! variable-length `struct direct` entries. Each entry begins with a fixed
5//! 8-byte head — `d_ino`(u32)@0, `d_reclen`(u16)@4, `d_type`(u8)@6,
6//! `d_namlen`(u8)@7 — followed by `d_name` (NUL-terminated, padded to a 4-byte
7//! boundary); the whole entry spans `d_reclen` bytes. Field offsets follow
8//! `struct direct` in the FreeBSD kernel header `sys/ufs/ufs/dir.h`, verified
9//! against the real dfvfs `ufs2.raw` root directory block with the TSK
10//! `fls`/`ffind` oracle (see `docs/RESEARCH.md` and `tests/data/README.md`).
11//!
12//! ## Deleted / empty slots
13//!
14//! A `d_ino == 0` entry is a free/deleted slot: UFS reclaims a removed entry's
15//! space by extending the *previous* record's `d_reclen`, but the removed
16//! entry's `d_name` bytes often remain readable within that slack. [`list_dir`]
17//! returns live entries by default; [`list_dir_all`] additionally surfaces the
18//! `d_ino == 0` slots (flagged `deleted`) so a forensic analyzer can recover the
19//! residual names. Recovering names hidden *inside* a preceding entry's slack is
20//! a `ufs-forensic` concern (a later phase); this phase exposes the block-level
21//! `d_ino == 0` slots the `direct` walk lands on.
22//!
23//! ## UFS1 big-endian `d_namlen`/`d_type` quirk
24//!
25//! In the historic "old" directory format (`OLDDIRFMT`) the type byte did not
26//! exist: the field was a 16-bit `d_namlen`. On a little-endian host the low
27//! byte reads as the name length (offset 7 held 0), so old- and new-format
28//! entries decode identically; on a **big-endian** old-format image the two
29//! bytes are swapped — offset 6 is the name length and offset 7 is 0. The dfvfs
30//! oracle is UFS2 little-endian (new format), so this reader decodes the common
31//! new-format case (`d_type`@6, `d_namlen`@7); the big-endian old-format swap is
32//! documented here and handled when a real such image lands (a follow-on, like
33//! the UFS1 path in `docs/RESEARCH.md`).
34
35use crate::bytes::Endian;
36use crate::error::UfsError;
37use crate::inode::{read_inode, Inode};
38use crate::superblock::{Superblock, UFS_ROOTINO};
39
40/// The directory block size (`DIRBLKSIZ`) — a directory's data is a sequence of
41/// these atomically-written blocks.
42pub const DIRBLKSIZ: usize = 512;
43
44/// The directory-entry name roundup (`DIR_ROUNDUP`): names are padded to a
45/// 4-byte boundary.
46pub const DIR_ROUNDUP: usize = 4;
47
48/// Fixed size of a `struct direct` head (before `d_name`): `d_ino`(4) +
49/// `d_reclen`(2) + `d_type`(1) + `d_namlen`(1).
50const DIRECT_HEAD: usize = 8;
51
52// ── struct direct field offsets (dir.h) ──────────────────────────────────────
53const OFF_INO: usize = 0;
54const OFF_RECLEN: usize = 4;
55const OFF_TYPE: usize = 6;
56const OFF_NAMLEN: usize = 7;
57const OFF_NAME: usize = 8;
58
59/// A directory-entry file type (`d_type`, `DT_*` in `dir.h`).
60///
61/// `#[non_exhaustive]` so a later phase can add a variant without a breaking
62/// change; consumers matching this enum use a `_` arm. An undefined type byte is
63/// carried as [`DirEntryType::Other`] so an unknown value is reported with its
64/// evidence rather than hidden.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum DirEntryType {
68    /// `DT_UNKNOWN` (0) — the entry does not record a type (old-format dirs).
69    Unknown,
70    /// `DT_FIFO` (1) — named pipe / FIFO.
71    Fifo,
72    /// `DT_CHR` (2) — character device.
73    CharDevice,
74    /// `DT_DIR` (4) — directory.
75    Directory,
76    /// `DT_BLK` (6) — block device.
77    BlockDevice,
78    /// `DT_REG` (8) — regular file.
79    Regular,
80    /// `DT_LNK` (10) — symbolic link.
81    Symlink,
82    /// `DT_SOCK` (12) — UNIX-domain socket.
83    Socket,
84    /// `DT_WHT` (14) — whiteout.
85    Whiteout,
86    /// A `d_type` value not defined by the format — carries the raw byte so an
87    /// unknown type is reported with its evidence.
88    Other(u8),
89}
90
91impl DirEntryType {
92    /// Classify a directory-entry type from the raw `d_type` byte.
93    #[must_use]
94    pub fn from_d_type(d_type: u8) -> Self {
95        match d_type {
96            0 => DirEntryType::Unknown,
97            1 => DirEntryType::Fifo,
98            2 => DirEntryType::CharDevice,
99            4 => DirEntryType::Directory,
100            6 => DirEntryType::BlockDevice,
101            8 => DirEntryType::Regular,
102            10 => DirEntryType::Symlink,
103            12 => DirEntryType::Socket,
104            14 => DirEntryType::Whiteout,
105            other => DirEntryType::Other(other),
106        }
107    }
108}
109
110/// One decoded directory entry (`struct direct`).
111///
112/// `#[non_exhaustive]` so later phases add fields without a breaking change.
113#[derive(Debug, Clone, PartialEq, Eq)]
114#[non_exhaustive]
115pub struct DirEntry {
116    /// The entry name (`d_name`), decoded from the `d_namlen` bytes after the
117    /// head. Not NUL-terminated; invalid UTF-8 is preserved as raw bytes.
118    pub name: Vec<u8>,
119    /// `d_ino` — the inode number this entry points at. `0` for a free/deleted
120    /// slot (only surfaced by [`list_dir_all`]).
121    pub ino: u64,
122    /// The file type from `d_type`.
123    pub file_type: DirEntryType,
124    /// `true` when this is a free/deleted slot (`d_ino == 0`) whose residual
125    /// name bytes are still readable. Live entries are `false`.
126    pub deleted: bool,
127}
128
129/// Read `len` bytes of a file's block `addr` from `partition` (the filesystem
130/// partition bytes, filesystem byte 0). `addr` is a **fragment address** as
131/// stored in an inode's `di_db[]`/`di_ib[]`; the byte offset is
132/// `addr * fs_fsize`.
133///
134/// A directory's data lives in whole blocks; the tail block may be a partial
135/// fragment run, so `len` bounds how much is read (typically `fs_bsize`, or the
136/// remaining `di_size` for the last block). Reading past the partition end is
137/// **not** an error here — the returned slice is clamped to what is present, so
138/// a truncated image yields a short (possibly empty) block rather than a
139/// failure, and the caller's `d_reclen`/`di_size` bounds still hold.
140///
141/// # Errors
142///
143/// [`UfsError::ImpossibleGeometry`] if `fs_fsize <= 0` (the multiplier for the
144/// byte offset), so the address cannot be computed — never a panic.
145pub fn read_block<'a>(
146    partition: &'a [u8],
147    sb: &Superblock,
148    addr: u64,
149    len: usize,
150) -> Result<&'a [u8], UfsError> {
151    if sb.fsize <= 0 {
152        return Err(UfsError::ImpossibleGeometry {
153            field: "fs_fsize",
154            value: sb.fsize as u64,
155            limit: i64::MAX as u64,
156        });
157    }
158    let fsize = sb.fsize as u64;
159    let start = usize::try_from(addr.saturating_mul(fsize)).unwrap_or(usize::MAX);
160    let end = start.saturating_add(len).min(partition.len());
161    Ok(partition.get(start..end.max(start)).unwrap_or(&[]))
162}
163
164/// Decode the directory entries of the directory inode `dir_ino`, returning the
165/// **live** entries (skipping `d_ino == 0` free/deleted slots). See
166/// [`list_dir_all`] to also surface the deleted slots.
167///
168/// Reads the directory inode's direct data blocks (`di_db[..]`), bounded by
169/// `di_size`, and walks consecutive `struct direct` entries by `d_reclen`. A
170/// lying `d_reclen` (`0` or past the block) or an over-long `d_namlen` can
171/// neither panic nor loop forever: a zero/short `d_reclen` ends the block walk,
172/// and a name that would run past the entry is clamped.
173///
174/// # Errors
175///
176/// - [`UfsError::InodeOutOfRange`] / [`UfsError::ImpossibleGeometry`] /
177///   [`UfsError::Truncated`] propagated from locating/decoding `dir_ino`.
178pub fn list_dir(
179    partition: &[u8],
180    sb: &Superblock,
181    dir_ino: u64,
182) -> Result<Vec<DirEntry>, UfsError> {
183    Ok(list_dir_all(partition, sb, dir_ino)?
184        .into_iter()
185        .filter(|e| !e.deleted)
186        .collect())
187}
188
189/// Decode the directory entries of `dir_ino`, including `d_ino == 0`
190/// free/deleted slots (flagged `deleted`). The forensic-relevant superset of
191/// [`list_dir`] — a deleted slot's residual `d_name` bytes are preserved so an
192/// analyzer can recover them.
193///
194/// # Errors
195///
196/// As [`list_dir`].
197pub fn list_dir_all(
198    partition: &[u8],
199    sb: &Superblock,
200    dir_ino: u64,
201) -> Result<Vec<DirEntry>, UfsError> {
202    let inode = read_inode(partition, sb, dir_ino)?;
203    Ok(list_dir_entries(partition, sb, &inode))
204}
205
206/// Walk the `struct direct` entries of an already-decoded directory `inode`,
207/// over its direct data blocks bounded by `di_size`. Returns every slot
208/// (including `d_ino == 0`); callers filter on `deleted` as needed.
209///
210/// The walk is bounded three ways so a hostile image is safe: (1) only the bytes
211/// within `di_size` are consumed; (2) each block is walked while a full
212/// `struct direct` head fits and `d_reclen` advances the cursor; (3) a
213/// `d_reclen` of `0` (or one that would not advance past the head) ends the
214/// current block rather than spinning.
215fn list_dir_entries(partition: &[u8], sb: &Superblock, inode: &Inode) -> Vec<DirEntry> {
216    let mut entries = Vec::new();
217    if sb.fsize <= 0 {
218        return entries; // cov:unreachable: read_inode already rejects fs_fsize<=0
219    }
220    let bsize = if sb.bsize > 0 {
221        sb.bsize as u64
222    } else {
223        DIRBLKSIZ as u64 // cov:unreachable: Superblock::parse rejects fs_bsize<=0
224    };
225    let mut remaining = inode.size;
226
227    for &addr in &inode.direct {
228        if remaining == 0 {
229            break;
230        }
231        if addr == 0 {
232            // A hole in the directory file — no data block. Still account for
233            // the block's worth of the logical size so the loop terminates.
234            remaining = remaining.saturating_sub(bsize);
235            continue;
236        }
237        let want = usize::try_from(remaining.min(bsize)).unwrap_or(usize::MAX);
238        let Ok(block) = read_block(partition, sb, addr, want) else {
239            break; // cov:unreachable: fs_fsize>0 checked above; read_block only errors on fsize<=0
240        };
241        walk_block(block, sb.endian, &mut entries);
242        remaining = remaining.saturating_sub(bsize);
243    }
244    entries
245}
246
247/// Walk one directory data `block`, appending every `struct direct` slot to
248/// `out`. Bounds every read; a lying `d_reclen`/`d_namlen` never over-reads or
249/// loops forever.
250fn walk_block(block: &[u8], endian: Endian, out: &mut Vec<DirEntry>) {
251    let mut off = 0usize;
252    while off + DIRECT_HEAD <= block.len() {
253        let ino = u64::from(endian.u32(block, off + OFF_INO));
254        let reclen = endian.u16(block, off + OFF_RECLEN) as usize;
255        let d_type = crate::bytes::u8_at(block, off + OFF_TYPE);
256        let namlen = crate::bytes::u8_at(block, off + OFF_NAMLEN) as usize;
257
258        // A d_reclen that cannot even hold the head (or is zero) is corrupt /
259        // marks the block's end — stop walking this block rather than spin.
260        if reclen < DIRECT_HEAD {
261            break;
262        }
263
264        // The name spans namlen bytes after the head, clamped so a lying namlen
265        // cannot read past this entry's record or the block.
266        let name_start = off + OFF_NAME;
267        let name_cap = reclen.saturating_sub(OFF_NAME);
268        let take = namlen.min(name_cap);
269        let name_end = name_start.saturating_add(take).min(block.len());
270        let name = block
271            .get(name_start..name_end)
272            .map(<[u8]>::to_vec)
273            .unwrap_or_default();
274
275        out.push(DirEntry {
276            name,
277            ino,
278            file_type: DirEntryType::from_d_type(d_type),
279            deleted: ino == 0,
280        });
281
282        off = off.saturating_add(reclen);
283    }
284}
285
286/// Resolve an absolute path (e.g. `"/a/b/c"`) to its `(inode number, inode)`,
287/// descending from the root inode (`UFS_ROOTINO` = 2) and matching each
288/// component against the live directory entries at each level.
289///
290/// The root (`"/"`) resolves to inode 2. An empty component (`//`, or a trailing
291/// `/`) is skipped. A component that names a non-directory before the final
292/// component (so the path cannot continue) yields `None`, as does a component no
293/// entry matches.
294///
295/// # Errors
296///
297/// Propagates [`UfsError`] from locating/decoding an inode along the path. A
298/// component that simply does not exist is `Ok(None)`, not an error.
299pub fn read_by_path(
300    partition: &[u8],
301    sb: &Superblock,
302    path: &str,
303) -> Result<Option<(u64, Inode)>, UfsError> {
304    let root = read_inode(partition, sb, UFS_ROOTINO)?;
305    let mut cur_ino = UFS_ROOTINO;
306    let mut cur = root;
307
308    for comp in path.split('/') {
309        if comp.is_empty() {
310            continue; // leading/trailing/duplicate slash
311        }
312        if !cur.is_dir() {
313            return Ok(None); // cannot descend through a non-directory
314        }
315        let entries = list_dir_entries(partition, sb, &cur);
316        let Some(hit) = entries
317            .iter()
318            .find(|e| !e.deleted && e.name == comp.as_bytes())
319        else {
320            return Ok(None);
321        };
322        cur_ino = hit.ino;
323        cur = read_inode(partition, sb, cur_ino)?;
324    }
325    Ok(Some((cur_ino, cur)))
326}
327
328#[cfg(test)]
329#[allow(clippy::unreadable_literal)]
330mod tests {
331    use super::*;
332    use crate::superblock::{UfsVersion, FS_UFS2_MAGIC, SBLOCK_UFS2};
333
334    #[test]
335    fn d_type_classifies_all_dt_values() {
336        assert_eq!(DirEntryType::from_d_type(0), DirEntryType::Unknown);
337        assert_eq!(DirEntryType::from_d_type(1), DirEntryType::Fifo);
338        assert_eq!(DirEntryType::from_d_type(2), DirEntryType::CharDevice);
339        assert_eq!(DirEntryType::from_d_type(4), DirEntryType::Directory);
340        assert_eq!(DirEntryType::from_d_type(6), DirEntryType::BlockDevice);
341        assert_eq!(DirEntryType::from_d_type(8), DirEntryType::Regular);
342        assert_eq!(DirEntryType::from_d_type(10), DirEntryType::Symlink);
343        assert_eq!(DirEntryType::from_d_type(12), DirEntryType::Socket);
344        assert_eq!(DirEntryType::from_d_type(14), DirEntryType::Whiteout);
345        assert_eq!(DirEntryType::from_d_type(9), DirEntryType::Other(9));
346    }
347
348    /// Encode one `struct direct` entry: head + name padded to `reclen`.
349    fn direct(ino: u32, reclen: u16, d_type: u8, name: &[u8]) -> Vec<u8> {
350        let mut e = vec![0u8; reclen as usize];
351        e[OFF_INO..OFF_INO + 4].copy_from_slice(&ino.to_le_bytes());
352        e[OFF_RECLEN..OFF_RECLEN + 2].copy_from_slice(&reclen.to_le_bytes());
353        e[OFF_TYPE] = d_type;
354        e[OFF_NAMLEN] = name.len() as u8;
355        e[OFF_NAME..OFF_NAME + name.len()].copy_from_slice(name);
356        e
357    }
358
359    /// Build the root directory block exactly as the real dfvfs image lays it
360    /// out: `.`(2)/`..`(2)/`.snap`(3)/`a_directory`(128)/`passwords.txt`(4)/
361    /// `a_link`(5), the last record's reclen absorbing the rest of the 512 block.
362    fn real_root_block() -> Vec<u8> {
363        let mut b = Vec::new();
364        b.extend(direct(2, 12, 4, b"."));
365        b.extend(direct(2, 12, 4, b".."));
366        b.extend(direct(3, 16, 4, b".snap"));
367        b.extend(direct(128, 20, 4, b"a_directory"));
368        b.extend(direct(4, 24, 8, b"passwords.txt"));
369        b.extend(direct(5, 428, 10, b"a_link"));
370        assert_eq!(b.len(), DIRBLKSIZ, "root block is one DIRBLKSIZ");
371        b
372    }
373
374    fn walk(block: &[u8]) -> Vec<DirEntry> {
375        let mut out = Vec::new();
376        walk_block(block, Endian::Little, &mut out);
377        out
378    }
379
380    #[test]
381    fn walk_block_decodes_real_root_layout() {
382        let entries = walk(&real_root_block());
383        let names: Vec<&[u8]> = entries.iter().map(|e| e.name.as_slice()).collect();
384        assert_eq!(
385            names,
386            vec![
387                &b"."[..],
388                &b".."[..],
389                &b".snap"[..],
390                &b"a_directory"[..],
391                &b"passwords.txt"[..],
392                &b"a_link"[..],
393            ]
394        );
395        let inos: Vec<u64> = entries.iter().map(|e| e.ino).collect();
396        assert_eq!(inos, vec![2, 2, 3, 128, 4, 5]);
397        assert_eq!(entries[3].file_type, DirEntryType::Directory);
398        assert_eq!(entries[4].file_type, DirEntryType::Regular);
399        assert_eq!(entries[5].file_type, DirEntryType::Symlink);
400        assert!(entries.iter().all(|e| !e.deleted));
401    }
402
403    #[test]
404    fn walk_block_surfaces_deleted_slot() {
405        // First entry live, then a d_ino==0 slot whose name bytes remain.
406        let mut b = Vec::new();
407        b.extend(direct(7, 16, 8, b"live"));
408        b.extend(direct(0, 16, 8, b"ghost")); // d_ino==0 => deleted slot
409        let entries = walk(&b);
410        assert_eq!(entries.len(), 2);
411        assert!(!entries[0].deleted);
412        assert_eq!(entries[0].name, b"live");
413        assert!(entries[1].deleted, "d_ino==0 is a deleted slot");
414        assert_eq!(entries[1].ino, 0);
415        assert_eq!(entries[1].name, b"ghost", "residual name preserved");
416    }
417
418    #[test]
419    fn lying_zero_reclen_does_not_loop_forever() {
420        // A d_reclen of 0 must end the block walk, not spin.
421        let mut b = direct(9, 16, 8, b"ok");
422        // Append a head with reclen==0.
423        let mut bad = vec![0u8; DIRECT_HEAD];
424        bad[OFF_INO..OFF_INO + 4].copy_from_slice(&5u32.to_le_bytes());
425        // reclen stays 0
426        b.extend(bad);
427        let entries = walk(&b);
428        assert_eq!(entries.len(), 1, "walk stops at the zero-reclen entry");
429        assert_eq!(entries[0].name, b"ok");
430    }
431
432    #[test]
433    fn over_long_namlen_is_clamped_not_overread() {
434        // namlen claims 200 but the record is only 16 bytes: clamp to the record.
435        let mut e = vec![0u8; 16];
436        e[OFF_INO..OFF_INO + 4].copy_from_slice(&3u32.to_le_bytes());
437        e[OFF_RECLEN..OFF_RECLEN + 2].copy_from_slice(&16u16.to_le_bytes());
438        e[OFF_TYPE] = 8;
439        e[OFF_NAMLEN] = 200; // lying length
440        e[OFF_NAME..OFF_NAME + 4].copy_from_slice(b"abcd");
441        let entries = walk(&e);
442        assert_eq!(entries.len(), 1);
443        // The name is clamped to what the record can hold (reclen-8 = 8 bytes),
444        // never reading past the record/block.
445        assert!(entries[0].name.len() <= 16 - OFF_NAME);
446    }
447
448    #[test]
449    fn reclen_below_head_ends_block() {
450        // A reclen smaller than the 8-byte head is corrupt: stop, don't advance
451        // by a sub-head amount and mis-align forever.
452        let mut e = vec![0u8; 8];
453        e[OFF_INO..OFF_INO + 4].copy_from_slice(&1u32.to_le_bytes());
454        e[OFF_RECLEN..OFF_RECLEN + 2].copy_from_slice(&4u16.to_le_bytes()); // < 8
455        let entries = walk(&e);
456        assert!(entries.is_empty());
457    }
458
459    #[test]
460    fn walk_empty_or_short_block_is_safe() {
461        assert!(walk(&[]).is_empty());
462        assert!(walk(&[0u8; 3]).is_empty()); // shorter than a head
463    }
464
465    // ── read_block address math ──────────────────────────────────────────────
466
467    fn tiny_sb() -> Superblock {
468        // A minimal superblock via parse over a synthetic buffer.
469        let mut d = vec![0u8; 1376];
470        let wr32 = |d: &mut [u8], off: usize, v: i32| {
471            d[off..off + 4].copy_from_slice(&v.to_le_bytes());
472        };
473        let wr64 = |d: &mut [u8], off: usize, v: i64| {
474            d[off..off + 8].copy_from_slice(&v.to_le_bytes());
475        };
476        wr32(&mut d, 8, 24); // sblkno
477        wr32(&mut d, 12, 32); // cblkno
478        wr32(&mut d, 16, 40); // iblkno
479        wr32(&mut d, 20, 48); // dblkno
480        wr32(&mut d, 44, 4); // ncg
481        wr32(&mut d, 48, 32768); // bsize
482        wr32(&mut d, 52, 4096); // fsize
483        wr32(&mut d, 56, 8); // frag
484        wr32(&mut d, 184, 128); // ipg
485        wr32(&mut d, 188, 256); // fpg
486        wr32(&mut d, 1320, 120); // maxsymlinklen
487        wr64(&mut d, 1080, 1022); // size
488        wr64(&mut d, 1000, SBLOCK_UFS2 as i64);
489        d[1372..1376].copy_from_slice(&FS_UFS2_MAGIC.to_le_bytes());
490        Superblock::parse(&d).unwrap()
491    }
492
493    #[test]
494    fn read_block_offsets_by_fragment_size() {
495        let sb = tiny_sb();
496        // fragment 2, fsize 4096 => byte 8192.
497        let mut part = vec![0u8; 8192 + 16];
498        part[8192..8192 + 4].copy_from_slice(b"HERE");
499        let block = read_block(&part, &sb, 2, 4).unwrap();
500        assert_eq!(block, b"HERE");
501    }
502
503    #[test]
504    fn read_block_clamps_past_end_without_error() {
505        let sb = tiny_sb();
506        let part = vec![0u8; 100];
507        // fragment 1 => byte 4096, past the 100-byte partition: clamped to empty.
508        let block = read_block(&part, &sb, 1, 512).unwrap();
509        assert!(block.is_empty());
510    }
511
512    #[test]
513    fn read_block_rejects_zero_fsize() {
514        let mut sb = tiny_sb();
515        sb.fsize = 0;
516        assert!(matches!(
517            read_block(&[0u8; 16], &sb, 0, 4),
518            Err(UfsError::ImpossibleGeometry {
519                field: "fs_fsize",
520                ..
521            })
522        ));
523    }
524
525    // ── list_dir / read_by_path over a synthetic partition ───────────────────
526
527    /// Build a synthetic partition holding: a UFS2 superblock at `SBLOCK_UFS2`;
528    /// the root dir inode (2) pointing at a data block that lists the real root
529    /// layout; and a nested `a_directory` (inode 128) + `a_file` (inode 129).
530    /// Returns (partition, superblock).
531    fn synthetic_fs() -> (Vec<u8>, Superblock) {
532        let sb = tiny_sb();
533        let fsize = 4096usize;
534        let iblkno = 40usize;
535        let fpg = 256usize;
536        let ipg = 128usize;
537        let inode_size = 256usize;
538
539        // Choose fragment addresses (in cg0 data region) for the two dir blocks
540        // and give the file inode a dummy block.
541        let root_dir_frag = 60u64;
542        let adir_frag = 61u64;
543
544        // Layout inode-table location for cg c, inode within: byte =
545        // (c*fpg + iblkno)*fsize + within*inode_size.
546        let ino_byte = |ino: usize| -> usize {
547            let c = ino / ipg;
548            let within = ino % ipg;
549            (c * fpg + iblkno) * fsize + within * inode_size
550        };
551
552        // Size the partition to cover the superblock, the inode table region,
553        // and the two data fragments.
554        let max_byte = [
555            SBLOCK_UFS2 + 1376,
556            ino_byte(130) + inode_size,
557            (root_dir_frag as usize + 1) * fsize,
558            (adir_frag as usize + 1) * fsize,
559        ]
560        .into_iter()
561        .max()
562        .unwrap();
563        let mut part = vec![0u8; max_byte + 16];
564
565        // Write the superblock at SBLOCK_UFS2.
566        let sb_bytes = {
567            let mut d = vec![0u8; 1376];
568            let wr32 = |d: &mut [u8], off: usize, v: i32| {
569                d[off..off + 4].copy_from_slice(&v.to_le_bytes());
570            };
571            let wr64 = |d: &mut [u8], off: usize, v: i64| {
572                d[off..off + 8].copy_from_slice(&v.to_le_bytes());
573            };
574            wr32(&mut d, 8, 24);
575            wr32(&mut d, 12, 32);
576            wr32(&mut d, 16, iblkno as i32);
577            wr32(&mut d, 20, 48);
578            wr32(&mut d, 44, 4);
579            wr32(&mut d, 48, 32768);
580            wr32(&mut d, 52, fsize as i32);
581            wr32(&mut d, 56, 8);
582            wr32(&mut d, 184, ipg as i32);
583            wr32(&mut d, 188, fpg as i32);
584            wr32(&mut d, 1320, 120);
585            wr64(&mut d, 1080, 1022);
586            wr64(&mut d, 1000, SBLOCK_UFS2 as i64);
587            d[1372..1376].copy_from_slice(&FS_UFS2_MAGIC.to_le_bytes());
588            d
589        };
590        part[SBLOCK_UFS2..SBLOCK_UFS2 + 1376].copy_from_slice(&sb_bytes);
591
592        // A UFS2 dinode: dir with size 512 pointing at `frag`, or a regular file.
593        let dir_inode = |frag: u64, size: u64, mode: u16| -> Vec<u8> {
594            let mut d = vec![0u8; inode_size];
595            d[0..2].copy_from_slice(&mode.to_le_bytes()); // di_mode
596            d[2..4].copy_from_slice(&1u16.to_le_bytes()); // di_nlink
597            d[16..24].copy_from_slice(&size.to_le_bytes()); // di_size
598            d[112..120].copy_from_slice(&frag.to_le_bytes()); // di_db[0]
599            d
600        };
601        // root inode 2: directory
602        part[ino_byte(2)..ino_byte(2) + inode_size].copy_from_slice(&dir_inode(
603            root_dir_frag,
604            512,
605            0o040755,
606        ));
607        // a_directory inode 128: directory
608        part[ino_byte(128)..ino_byte(128) + inode_size]
609            .copy_from_slice(&dir_inode(adir_frag, 512, 0o040755));
610        // passwords.txt inode 4: regular file (116 bytes, matching P1).
611        part[ino_byte(4)..ino_byte(4) + inode_size].copy_from_slice(&dir_inode(0, 116, 0o100644));
612        // a_file inode 129: regular file
613        part[ino_byte(129)..ino_byte(129) + inode_size]
614            .copy_from_slice(&dir_inode(0, 116, 0o100644));
615
616        // root data block: real layout.
617        let root_block = real_root_block();
618        let rb = root_dir_frag as usize * fsize;
619        part[rb..rb + root_block.len()].copy_from_slice(&root_block);
620
621        // a_directory data block: ./ ../ a_file(129).
622        let mut adir = Vec::new();
623        adir.extend(direct(128, 12, 4, b"."));
624        adir.extend(direct(2, 12, 4, b".."));
625        adir.extend(direct(129, 488, 8, b"a_file"));
626        let ab = adir_frag as usize * fsize;
627        part[ab..ab + adir.len()].copy_from_slice(&adir);
628
629        (part, sb)
630    }
631
632    #[test]
633    fn list_dir_returns_live_root_entries() {
634        let (part, sb) = synthetic_fs();
635        let entries = list_dir(&part, &sb, 2).unwrap();
636        let names: Vec<&[u8]> = entries.iter().map(|e| e.name.as_slice()).collect();
637        assert_eq!(
638            names,
639            vec![
640                &b"."[..],
641                &b".."[..],
642                &b".snap"[..],
643                &b"a_directory"[..],
644                &b"passwords.txt"[..],
645                &b"a_link"[..],
646            ]
647        );
648        // passwords.txt is inode 4 (the P1 known file).
649        let pw = entries.iter().find(|e| e.name == b"passwords.txt").unwrap();
650        assert_eq!(pw.ino, 4);
651        assert_eq!(pw.file_type, DirEntryType::Regular);
652    }
653
654    #[test]
655    fn list_dir_skips_hole_in_direct_pointers() {
656        // A directory whose logical size spans two blocks but whose second
657        // direct pointer is a hole (addr == 0): the walk consumes the first
658        // block's entries and skips the hole without reading a data block or
659        // looping. Exercises the addr==0 branch in list_dir_entries.
660        let sb = tiny_sb();
661        let fsize = sb.fsize as u64;
662        let bsize = sb.bsize as u64; // 32768
663        let frag0 = 60u64;
664
665        // Build a directory inode: size = 2 * bsize, direct[0] = frag0, the rest
666        // (incl. direct[1]) left 0 (holes).
667        let mut dino = vec![0u8; 256];
668        dino[0..2].copy_from_slice(&0o040755u16.to_le_bytes()); // di_mode = dir
669        dino[2..4].copy_from_slice(&1u16.to_le_bytes());
670        dino[16..24].copy_from_slice(&(2 * bsize).to_le_bytes()); // di_size
671        dino[112..120].copy_from_slice(&frag0.to_le_bytes()); // di_db[0]
672        let inode = Inode::parse(&dino, UfsVersion::Ufs2, Endian::Little).unwrap();
673        assert_eq!(inode.direct[1], 0, "second pointer is a hole");
674
675        // Lay the first block's entries at frag0.
676        let mut block = Vec::new();
677        block.extend(direct(9, 12, 8, b"x"));
678        block.extend(direct(10, DIRBLKSIZ as u16 - 12, 8, b"y"));
679        let start = (frag0 * fsize) as usize;
680        let mut part = vec![0u8; start + block.len()];
681        part[start..start + block.len()].copy_from_slice(&block);
682
683        let entries = list_dir_entries(&part, &sb, &inode);
684        let names: Vec<&[u8]> = entries.iter().map(|e| e.name.as_slice()).collect();
685        assert_eq!(
686            names,
687            vec![&b"x"[..], &b"y"[..]],
688            "first block walked, hole skipped"
689        );
690    }
691
692    #[test]
693    fn read_by_path_root_resolves_to_inode2() {
694        let (part, sb) = synthetic_fs();
695        let (ino, inode) = read_by_path(&part, &sb, "/").unwrap().unwrap();
696        assert_eq!(ino, 2);
697        assert!(inode.is_dir());
698    }
699
700    #[test]
701    fn read_by_path_resolves_known_file_inode4() {
702        let (part, sb) = synthetic_fs();
703        let (ino, inode) = read_by_path(&part, &sb, "/passwords.txt").unwrap().unwrap();
704        assert_eq!(ino, 4);
705        assert_eq!(inode.size, 116, "P1 metadata: passwords.txt is 116 bytes");
706    }
707
708    #[test]
709    fn read_by_path_descends_nested_directory() {
710        let (part, sb) = synthetic_fs();
711        let (ino, inode) = read_by_path(&part, &sb, "/a_directory/a_file")
712            .unwrap()
713            .unwrap();
714        assert_eq!(ino, 129);
715        assert!(inode.is_regular());
716    }
717
718    #[test]
719    fn read_by_path_missing_component_is_none() {
720        let (part, sb) = synthetic_fs();
721        assert!(read_by_path(&part, &sb, "/nope").unwrap().is_none());
722        assert!(read_by_path(&part, &sb, "/a_directory/missing")
723            .unwrap()
724            .is_none());
725    }
726
727    #[test]
728    fn read_by_path_through_non_directory_is_none() {
729        let (part, sb) = synthetic_fs();
730        // passwords.txt (inode 4) is a file; descending through it fails.
731        assert!(read_by_path(&part, &sb, "/passwords.txt/x")
732            .unwrap()
733            .is_none());
734    }
735}