Skip to main content

ufs/
file.rs

1//! UFS/FFS file-content assembly — the block map walk from an inode to its bytes.
2//!
3//! A UFS file's data is addressed by a block map rooted in the inode: the first
4//! `UFS_NDADDR` (12) logical blocks come from the direct pointers `di_db[0..12]`;
5//! beyond that, `di_ib[0]` is a single-indirect block (a full data block holding
6//! `fs_nindir` further block pointers), `di_ib[1]` a double-indirect block (its
7//! pointers name single-indirect blocks), and `di_ib[2]` a triple-indirect block
8//! (→ double → single → data). Every pointer is a **fragment address**
9//! (`addr * fs_fsize` bytes); a pointer of `0` is a hole and reads as zeros. The
10//! last block of a file is a partial fragment run sized to the remaining
11//! `di_size`. Pointer width is 8 bytes on UFS2 and 4 bytes on UFS1.
12//!
13//! Layout and macros follow the FreeBSD kernel header `sys/ufs/ufs/dinode.h`
14//! (`di_db`/`di_ib`, `UFS_NDADDR`/`UFS_NIADDR`) and `sys/ufs/ffs/fs.h`
15//! (`fs_nindir`); validated by SHA-256 against TSK `icat` on the real dfvfs
16//! `ufs2.raw` (direct-block + path cases) and by an independent block-map walker
17//! over a crafted image (the single/double/triple indirect chains) — see
18//! `core/tests/file_oracle.rs` and `core/tests/file_indirect.rs`.
19//!
20//! # Safety
21//!
22//! Every read is bounds-checked and every count derived from the image is capped
23//! against the partition size, so a lying `di_size`, a hostile pointer, or a
24//! truncated image can neither panic, over-read, nor allocate an absurd buffer
25//! (the Paranoid Gatekeeper standard): `di_size` larger than the partition is
26//! rejected as an allocation bomb, and indirect recursion is bounded to the three
27//! architectural levels.
28
29use crate::dir::{read_block, read_by_path};
30use crate::error::UfsError;
31use crate::inode::{read_inode, Inode, UFS_NDADDR};
32use crate::superblock::{Superblock, UfsVersion};
33
34/// Assemble the full byte content of the file inode `ino`, walking its block map
35/// (direct `di_db[0..12]`, then single/double/triple indirect via `di_ib[0..3]`)
36/// up to `di_size`. A hole (a `0` pointer, at any level of the tree) reads as
37/// zeros; the last block is sized to the remaining `di_size` (fragment tail).
38///
39/// The assembled buffer is exactly `di_size` bytes.
40///
41/// # Errors
42///
43/// - [`UfsError::ImpossibleGeometry`] if `di_size` exceeds the partition length
44///   (an allocation bomb — the file cannot possibly be that large), or if
45///   `fs_bsize` / `fs_fsize` are non-positive so block addressing is undefined.
46/// - Propagates [`UfsError`] from locating/decoding `ino`.
47pub fn read_file(partition: &[u8], sb: &Superblock, ino: u64) -> Result<Vec<u8>, UfsError> {
48    let inode = read_inode(partition, sb, ino)?;
49    read_inode_file(partition, sb, &inode)
50}
51
52/// Assemble the byte content of an already-decoded `inode` (the block-map walk
53/// [`read_file`] performs after locating the inode). Exposed so callers holding
54/// an [`Inode`] (e.g. after [`read_by_path`]) need not re-locate it.
55///
56/// # Errors
57///
58/// As [`read_file`] (minus the inode-location errors).
59pub fn read_inode_file(
60    partition: &[u8],
61    sb: &Superblock,
62    inode: &Inode,
63) -> Result<Vec<u8>, UfsError> {
64    if sb.bsize <= 0 {
65        return Err(UfsError::ImpossibleGeometry {
66            field: "fs_bsize",
67            value: sb.bsize as u64,
68            limit: i64::MAX as u64,
69        });
70    }
71    if sb.fsize <= 0 {
72        return Err(UfsError::ImpossibleGeometry {
73            field: "fs_fsize",
74            value: sb.fsize as u64,
75            limit: i64::MAX as u64,
76        });
77    }
78    let size = inode.size;
79    // Allocation-bomb guard: a real file cannot be larger than the partition it
80    // lives in. Reject a lying di_size before allocating anything.
81    let part_len = partition.len() as u64;
82    if size > part_len {
83        return Err(UfsError::ImpossibleGeometry {
84            field: "di_size",
85            value: size,
86            limit: part_len,
87        });
88    }
89    let bsize = sb.bsize as u64;
90    let size_usize = usize::try_from(size).unwrap_or(usize::MAX);
91    let mut out = vec![0u8; size_usize];
92
93    if size == 0 {
94        return Ok(out);
95    }
96
97    // Number of logical blocks the file spans (ceil to a block).
98    let n_blocks = size.div_ceil(bsize);
99    let nindir = if sb.nindir > 0 { sb.nindir as u64 } else { 0 };
100
101    let mut remaining = size;
102    let mut written = 0usize;
103    let mut bi: u64 = 0;
104    while bi < n_blocks {
105        let this_len = usize::try_from(remaining.min(bsize)).unwrap_or(usize::MAX);
106        let addr = resolve_block(partition, sb, inode, bi, nindir);
107        if addr != 0 {
108            let block = read_block(partition, sb, addr, this_len)?;
109            // block may be shorter than this_len on a truncated image; copy what
110            // is present, leaving the rest zero (already-zeroed buffer).
111            let take = block.len().min(this_len);
112            if let Some(dst) = out.get_mut(written..written + take) {
113                dst.copy_from_slice(&block[..take]);
114            }
115        }
116        // addr == 0 is a hole: leave the block's range zero-filled.
117        written = written.saturating_add(this_len);
118        remaining = remaining.saturating_sub(bsize);
119        bi += 1;
120    }
121
122    Ok(out)
123}
124
125/// Resolve logical file block index `bi` to its data-fragment address via the
126/// inode's block map. Returns `0` for a hole (a `0` pointer at any level).
127///
128/// `bi < UFS_NDADDR` → `di_db[bi]`; else the single/double/triple indirect trees
129/// rooted at `di_ib[0..3]`, each pointer block holding `nindir` further pointers.
130/// A `nindir` of `0` (corrupt superblock) collapses the indirect ranges so only
131/// the direct blocks resolve — never a divide-by-zero.
132fn resolve_block(partition: &[u8], sb: &Superblock, inode: &Inode, bi: u64, nindir: u64) -> u64 {
133    let ndaddr = UFS_NDADDR as u64;
134    if bi < ndaddr {
135        return inode.direct[bi as usize];
136    }
137    if nindir == 0 {
138        return 0; // cov:unreachable: real UFS fs_nindir > 0; guards divide-by-zero
139    }
140    let mut i = bi - ndaddr;
141
142    // single-indirect: di_ib[0] → data
143    if i < nindir {
144        return indirect_ptr(partition, sb, inode.indirect[0], i);
145    }
146    i -= nindir;
147
148    // double-indirect: di_ib[1] → single → data
149    let nindir2 = nindir.saturating_mul(nindir);
150    if i < nindir2 {
151        let sib = indirect_ptr(partition, sb, inode.indirect[1], i / nindir);
152        return indirect_ptr(partition, sb, sib, i % nindir);
153    }
154    i -= nindir2;
155
156    // triple-indirect: di_ib[2] → double → single → data. A block index past the
157    // triple reach cannot exist for a di_size that passed the allocation-bomb
158    // check (di_size <= partition length), so `i >= nindir3` is unreachable for a
159    // valid file; if it ever occurs (a future invariant break) it degrades to a
160    // hole rather than mis-address, via the saturating index into a 0-pointer.
161    let nindir3 = nindir2.saturating_mul(nindir);
162    if i >= nindir3 {
163        return 0; // cov:unreachable: di_size <= partition length caps bi below the triple reach
164    }
165    let dib = indirect_ptr(partition, sb, inode.indirect[2], i / nindir2);
166    let rem = i % nindir2;
167    let sib = indirect_ptr(partition, sb, dib, rem / nindir);
168    indirect_ptr(partition, sb, sib, rem % nindir)
169}
170
171/// Read the `idx`-th block pointer from the indirect block at fragment `ind_addr`.
172/// `ind_addr == 0` is a hole → `0`. Pointer width is 8 bytes (UFS2) or 4 bytes
173/// (UFS1); a read past the block end yields `0` (bounds-checked, never a panic).
174fn indirect_ptr(partition: &[u8], sb: &Superblock, ind_addr: u64, idx: u64) -> u64 {
175    if ind_addr == 0 {
176        return 0;
177    }
178    let bsize = if sb.bsize > 0 { sb.bsize as usize } else { 0 };
179    let Ok(block) = read_block(partition, sb, ind_addr, bsize) else {
180        return 0; // cov:unreachable: caller checks fs_fsize>0; read_block only errors on fsize<=0
181    };
182    let ptr_size = match sb.version {
183        UfsVersion::Ufs2 => 8usize,
184        UfsVersion::Ufs1 => 4usize,
185    };
186    let off = usize::try_from(idx.saturating_mul(ptr_size as u64)).unwrap_or(usize::MAX);
187    match sb.version {
188        UfsVersion::Ufs2 => sb.endian.u64(block, off),
189        UfsVersion::Ufs1 => u64::from(sb.endian.u32(block, off)),
190    }
191}
192
193/// The target of a symbolic-link inode. For a **fast (inline)** symlink
194/// (`di_size <= fs_maxsymlinklen`) the target lives in the block-pointer bytes of
195/// the dinode and is returned directly (P1 already decoded it — see
196/// [`Inode::symlink_target`]). For a **slow** symlink (`di_size >
197/// fs_maxsymlinklen`) the target is stored in the file's data block(s), so it is
198/// read via the block map like any file's content and truncated to `di_size`.
199///
200/// # Errors
201///
202/// As [`read_inode_file`] for a slow symlink (block-map walk); a fast symlink
203/// reads no data block and cannot error.
204pub fn read_symlink_target(
205    partition: &[u8],
206    sb: &Superblock,
207    inode: &Inode,
208) -> Result<Vec<u8>, UfsError> {
209    if let Some(inline) = inode.symlink_target() {
210        return Ok(inline.to_vec());
211    }
212    // Slow symlink: the target is the file's data content.
213    read_inode_file(partition, sb, inode)
214}
215
216/// Resolve an absolute path to its file content: [`read_by_path`] then
217/// [`read_file`]. Returns `Ok(None)` when the path does not resolve (like
218/// [`read_by_path`]); `Ok(Some(bytes))` with the file's `di_size` bytes otherwise.
219///
220/// # Errors
221///
222/// Propagates [`UfsError`] from path resolution or content assembly.
223pub fn read_path_content(
224    partition: &[u8],
225    sb: &Superblock,
226    path: &str,
227) -> Result<Option<Vec<u8>>, UfsError> {
228    let Some((_ino, inode)) = read_by_path(partition, sb, path)? else {
229        return Ok(None);
230    };
231    Ok(Some(read_inode_file(partition, sb, &inode)?))
232}
233
234#[cfg(test)]
235#[allow(clippy::unreadable_literal)]
236mod tests {
237    use super::*;
238    use crate::superblock::FS_UFS2_MAGIC;
239
240    // A tiny UFS2 superblock (frag == block for simple addressing) built by
241    // parsing a synthetic buffer, so tests exercise resolve_block/read_file over
242    // real geometry.
243    fn tiny_sb(bsize: i32, fsize: i32, nindir: i32) -> Superblock {
244        let mut d = vec![0u8; 1376];
245        let wr32 = |d: &mut [u8], off: usize, v: i32| {
246            d[off..off + 4].copy_from_slice(&v.to_le_bytes());
247        };
248        let wr64 = |d: &mut [u8], off: usize, v: i64| {
249            d[off..off + 8].copy_from_slice(&v.to_le_bytes());
250        };
251        wr32(&mut d, 8, 1); // sblkno
252        wr32(&mut d, 12, 2); // cblkno
253        wr32(&mut d, 16, 4); // iblkno
254        wr32(&mut d, 20, 8); // dblkno
255        wr32(&mut d, 44, 1); // ncg
256        wr32(&mut d, 48, bsize); // bsize
257        wr32(&mut d, 52, fsize); // fsize
258        wr32(&mut d, 56, 1); // frag
259        wr32(&mut d, 116, nindir); // nindir
260        wr32(&mut d, 120, bsize / 256); // inopb
261        wr32(&mut d, 184, 128); // ipg
262        wr32(&mut d, 188, 4096); // fpg
263        wr32(&mut d, 1320, 120); // maxsymlinklen
264        wr64(&mut d, 1080, 65536); // size
265        d[1372..1376].copy_from_slice(&FS_UFS2_MAGIC.to_le_bytes());
266        Superblock::parse(&d).unwrap()
267    }
268
269    /// Build a UFS2 inode with the given size, direct and indirect pointers.
270    fn inode_with(size: u64, direct: &[u64], ib: [u64; 3]) -> Inode {
271        // Encode a UFS2 dinode and parse it, so we get a real Inode.
272        let mut d = vec![0u8; 256];
273        d[0..2].copy_from_slice(&0o100644u16.to_le_bytes()); // di_mode
274        d[2..4].copy_from_slice(&1u16.to_le_bytes()); // di_nlink
275        d[16..24].copy_from_slice(&size.to_le_bytes()); // di_size
276        for (i, &a) in direct.iter().enumerate() {
277            d[112 + i * 8..112 + i * 8 + 8].copy_from_slice(&a.to_le_bytes());
278        }
279        for (i, &a) in ib.iter().enumerate() {
280            d[208 + i * 8..208 + i * 8 + 8].copy_from_slice(&a.to_le_bytes());
281        }
282        Inode::parse(&d, UfsVersion::Ufs2, crate::Endian::Little).unwrap()
283    }
284
285    #[test]
286    fn read_file_direct_only_single_block() {
287        let sb = tiny_sb(512, 512, 64);
288        // partition: data fragment 10 holds the file.
289        let mut part = vec![0u8; 512 * 32];
290        let payload: Vec<u8> = (0..100u16).map(|i| (i & 0xff) as u8).collect();
291        let frag = 10usize;
292        part[frag * 512..frag * 512 + payload.len()].copy_from_slice(&payload);
293        let inode = inode_with(payload.len() as u64, &[frag as u64], [0, 0, 0]);
294        let got = read_inode_file(&part, &sb, &inode).unwrap();
295        assert_eq!(got, payload);
296    }
297
298    #[test]
299    fn read_file_zero_size_is_empty() {
300        let sb = tiny_sb(512, 512, 64);
301        let part = vec![0u8; 512 * 4];
302        let inode = inode_with(0, &[0; 12], [0, 0, 0]);
303        assert!(read_inode_file(&part, &sb, &inode).unwrap().is_empty());
304    }
305
306    #[test]
307    fn read_file_single_indirect_block() {
308        let sb = tiny_sb(512, 512, 64);
309        // File spans 13 blocks: 12 direct + 1 via single-indirect.
310        let mut part = vec![0u8; 512 * 64];
311        let mut direct = [0u64; 12];
312        // direct data at fragments 20..32
313        for (i, slot) in direct.iter_mut().enumerate() {
314            let f = 20 + i as u64;
315            *slot = f;
316            part[f as usize * 512] = (i + 1) as u8; // marker
317        }
318        // single-indirect block at fragment 40 pointing at data fragment 41
319        let sib = 40usize;
320        let data13 = 41u64;
321        part[sib * 512..sib * 512 + 8].copy_from_slice(&data13.to_le_bytes());
322        part[data13 as usize * 512] = 0xAB;
323        let size = 13 * 512u64;
324        let inode = inode_with(size, &direct, [sib as u64, 0, 0]);
325        let got = read_inode_file(&part, &sb, &inode).unwrap();
326        assert_eq!(got.len() as u64, size);
327        assert_eq!(got[0], 1, "first direct block marker");
328        assert_eq!(got[12 * 512], 0xAB, "block 12 came via single-indirect");
329    }
330
331    #[test]
332    fn read_file_double_and_triple_indirect_with_nindir2() {
333        // nindir = 2 makes the fan-out tiny, so a ~20-block file already reaches
334        // the triple-indirect region: single=blocks[12,13], double=[14..18),
335        // triple=[18..). Each pointer block holds 2 u64 pointers (16 bytes).
336        let sb = tiny_sb(512, 512, 2);
337        let bsize = 512usize;
338        let mut part = vec![0u8; bsize * 128];
339        let put_ptr = |p: &mut [u8], frag: usize, idx: usize, target: u64| {
340            let off = frag * bsize + idx * 8;
341            p[off..off + 8].copy_from_slice(&target.to_le_bytes());
342        };
343        // A distinctive marker byte per logical block so we can prove the map.
344        let mark = |p: &mut [u8], frag: usize, m: u8| p[frag * bsize] = m;
345
346        // 12 direct data blocks at frags 20..32.
347        let mut direct = [0u64; 12];
348        for (i, slot) in direct.iter_mut().enumerate() {
349            let f = 20 + i;
350            *slot = f as u64;
351            mark(&mut part, f, i as u8 + 1);
352        }
353        // single-indirect at frag 40 → data frags 50,51 (blocks 12,13).
354        put_ptr(&mut part, 40, 0, 50);
355        put_ptr(&mut part, 40, 1, 51);
356        mark(&mut part, 50, 100);
357        mark(&mut part, 51, 101);
358        // double-indirect at frag 41 → single-indirect frags 42,43; each → 2 data.
359        put_ptr(&mut part, 41, 0, 42);
360        put_ptr(&mut part, 41, 1, 43);
361        put_ptr(&mut part, 42, 0, 52); // block 14
362        put_ptr(&mut part, 42, 1, 53); // block 15
363        put_ptr(&mut part, 43, 0, 54); // block 16
364        put_ptr(&mut part, 43, 1, 55); // block 17
365        for (blk, f) in [(14, 52), (15, 53), (16, 54), (17, 55)] {
366            mark(&mut part, f, blk as u8);
367        }
368        // triple-indirect at frag 44 → double frag 45 → single frag 46 → data 56.
369        put_ptr(&mut part, 44, 0, 45);
370        put_ptr(&mut part, 45, 0, 46);
371        put_ptr(&mut part, 46, 0, 56); // block 18
372        mark(&mut part, 56, 200);
373
374        let n_blocks = 19u64; // blocks 0..18 inclusive => reaches triple
375        let size = n_blocks * bsize as u64;
376        let inode = inode_with(size, &direct, [40, 41, 44]);
377        let got = read_inode_file(&part, &sb, &inode).unwrap();
378        assert_eq!(got.len() as u64, size);
379        assert_eq!(got[0], 1, "direct block 0");
380        assert_eq!(got[12 * bsize], 100, "block 12 via single-indirect");
381        assert_eq!(got[14 * bsize], 14, "block 14 via double-indirect");
382        assert_eq!(got[17 * bsize], 17, "block 17 via double-indirect");
383        assert_eq!(got[18 * bsize], 200, "block 18 via triple-indirect");
384    }
385
386    #[test]
387    fn read_file_ufs1_single_indirect_uses_4byte_pointers() {
388        // UFS1: 32-bit block pointers in the indirect block, 128-byte dinode.
389        let mut d = vec![0u8; 1376];
390        let wr32 = |d: &mut [u8], off: usize, v: i32| {
391            d[off..off + 4].copy_from_slice(&v.to_le_bytes());
392        };
393        wr32(&mut d, 8, 1);
394        wr32(&mut d, 12, 2);
395        wr32(&mut d, 16, 4);
396        wr32(&mut d, 20, 8);
397        wr32(&mut d, 44, 1);
398        wr32(&mut d, 48, 512); // bsize
399        wr32(&mut d, 52, 512); // fsize
400        wr32(&mut d, 56, 1); // frag
401        wr32(&mut d, 116, 128); // nindir (bsize/4 for UFS1)
402        wr32(&mut d, 120, 4); // inopb (bsize/128)
403        wr32(&mut d, 184, 128);
404        wr32(&mut d, 188, 4096);
405        wr32(&mut d, 1320, 60);
406        wr32(&mut d, 36, 65536); // fs_old_size (UFS1 size field)
407        d[1372..1376].copy_from_slice(&crate::superblock::FS_UFS1_MAGIC.to_le_bytes());
408        let sb = Superblock::parse(&d).unwrap();
409        assert_eq!(sb.version, UfsVersion::Ufs1);
410
411        let bsize = 512usize;
412        let mut part = vec![0u8; bsize * 64];
413        // 12 direct + 1 single-indirect block. UFS1 dinode: di_db@40 (u32),
414        // di_ib@88 (u32), di_size@8 (u64).
415        let mut dn = vec![0u8; 128];
416        dn[0..2].copy_from_slice(&0o100644u16.to_le_bytes());
417        dn[8..16].copy_from_slice(&(13u64 * bsize as u64).to_le_bytes());
418        for i in 0..12u32 {
419            let f = 20 + i;
420            dn[40 + i as usize * 4..40 + i as usize * 4 + 4].copy_from_slice(&f.to_le_bytes());
421            part[f as usize * bsize] = i as u8 + 1;
422        }
423        let sib = 40u32;
424        dn[88..92].copy_from_slice(&sib.to_le_bytes());
425        // single-indirect block: 4-byte pointer to data frag 41 (block 12).
426        part[sib as usize * bsize..sib as usize * bsize + 4].copy_from_slice(&41u32.to_le_bytes());
427        part[41 * bsize] = 0xCD;
428        let inode = Inode::parse(&dn, UfsVersion::Ufs1, crate::Endian::Little).unwrap();
429        let got = read_inode_file(&part, &sb, &inode).unwrap();
430        assert_eq!(got.len(), 13 * bsize);
431        assert_eq!(
432            got[12 * bsize],
433            0xCD,
434            "UFS1 4-byte indirect pointer resolved"
435        );
436    }
437
438    #[test]
439    fn read_file_by_inode_number_locates_then_reads() {
440        // Exercise the public read_file(ino) locate+read wrapper. Build a minimal
441        // partition with inode 4 as a one-block file and read it by number.
442        let (part, sb) = minimal_fs();
443        let got = read_file(&part, &sb, 4).unwrap();
444        assert_eq!(got, b"twenty-byte-content!");
445    }
446
447    #[test]
448    fn read_file_hole_zero_fills() {
449        let sb = tiny_sb(512, 512, 64);
450        let mut part = vec![0u8; 512 * 16];
451        // 2-block file, block 0 is a hole (addr 0), block 1 has data.
452        let f1 = 9u64;
453        part[f1 as usize * 512] = 0x77;
454        let inode = inode_with(2 * 512, &[0, f1], [0, 0, 0]);
455        let got = read_inode_file(&part, &sb, &inode).unwrap();
456        assert!(got[..512].iter().all(|&b| b == 0), "hole zero-filled");
457        assert_eq!(got[512], 0x77);
458    }
459
460    #[test]
461    fn read_file_rejects_allocation_bomb() {
462        let sb = tiny_sb(512, 512, 64);
463        let part = vec![0u8; 512 * 4];
464        let inode = inode_with(u64::MAX, &[0; 12], [0, 0, 0]);
465        let err = read_inode_file(&part, &sb, &inode).unwrap_err();
466        assert!(matches!(
467            err,
468            UfsError::ImpossibleGeometry {
469                field: "di_size",
470                ..
471            }
472        ));
473    }
474
475    #[test]
476    fn read_file_rejects_zero_bsize() {
477        let mut sb = tiny_sb(512, 512, 64);
478        sb.bsize = 0;
479        let part = vec![0u8; 512 * 4];
480        let inode = inode_with(100, &[1], [0, 0, 0]);
481        let err = read_inode_file(&part, &sb, &inode).unwrap_err();
482        assert!(matches!(
483            err,
484            UfsError::ImpossibleGeometry {
485                field: "fs_bsize",
486                ..
487            }
488        ));
489    }
490
491    #[test]
492    fn read_file_rejects_zero_fsize() {
493        let mut sb = tiny_sb(512, 512, 64);
494        sb.fsize = 0;
495        let part = vec![0u8; 512 * 4];
496        let inode = inode_with(100, &[1], [0, 0, 0]);
497        let err = read_inode_file(&part, &sb, &inode).unwrap_err();
498        assert!(matches!(
499            err,
500            UfsError::ImpossibleGeometry {
501                field: "fs_fsize",
502                ..
503            }
504        ));
505    }
506
507    #[test]
508    fn read_file_truncated_partition_no_panic() {
509        let sb = tiny_sb(512, 512, 64);
510        // File claims 2 blocks but its data fragment falls off the (short)
511        // partition — must not panic, missing bytes read as zero.
512        let part = vec![0u8; 512 * 3];
513        let inode = inode_with(2 * 512, &[2, 100], [0, 0, 0]); // frag 100 is past end
514        let got = read_inode_file(&part, &sb, &inode).unwrap();
515        assert_eq!(got.len(), 2 * 512);
516    }
517
518    #[test]
519    fn resolve_block_zero_nindir_collapses_to_direct_only() {
520        let sb = tiny_sb(512, 512, 0); // nindir 0 (corrupt)
521        let inode = inode_with(20 * 512, &[5; 12], [40, 0, 0]);
522        // block 12 needs the indirect tree, but nindir==0 => hole (0).
523        assert_eq!(resolve_block(&[], &sb, &inode, 12, 0), 0);
524        // a direct block still resolves.
525        assert_eq!(resolve_block(&[], &sb, &inode, 0, 0), 5);
526    }
527
528    #[test]
529    fn indirect_ptr_hole_addr_is_zero() {
530        let sb = tiny_sb(512, 512, 64);
531        assert_eq!(indirect_ptr(&[0u8; 512], &sb, 0, 0), 0);
532    }
533
534    #[test]
535    fn read_symlink_target_fast_inline() {
536        let sb = tiny_sb(512, 512, 64);
537        // A fast symlink: mode 0120xxx, size 5, target "a/b/c" inline in di_db.
538        let mut d = vec![0u8; 256];
539        d[0..2].copy_from_slice(&0o120755u16.to_le_bytes());
540        d[16..24].copy_from_slice(&5u64.to_le_bytes());
541        d[112..117].copy_from_slice(b"a/b/c");
542        let inode = Inode::parse(&d, UfsVersion::Ufs2, crate::Endian::Little).unwrap();
543        let target = read_symlink_target(&[], &sb, &inode).unwrap();
544        assert_eq!(target, b"a/b/c");
545    }
546
547    #[test]
548    fn read_symlink_target_slow_reads_data_block() {
549        let sb = tiny_sb(512, 512, 64);
550        // Slow symlink: size 130 > maxsymlinklen 120, target in data block.
551        let target = b"x".repeat(130);
552        let mut part = vec![0u8; 512 * 16];
553        let frag = 7u64;
554        part[frag as usize * 512..frag as usize * 512 + 130].copy_from_slice(&target);
555        // dinode: mode symlink, size 130, di_db[0]=frag. size>maxsymlinklen so
556        // Inode::parse leaves fast_symlink None.
557        let mut d = vec![0u8; 256];
558        d[0..2].copy_from_slice(&0o120755u16.to_le_bytes());
559        d[16..24].copy_from_slice(&130u64.to_le_bytes());
560        d[112..120].copy_from_slice(&frag.to_le_bytes());
561        let inode = Inode::parse(&d, UfsVersion::Ufs2, crate::Endian::Little).unwrap();
562        assert!(
563            inode.symlink_target().is_none(),
564            "slow symlink is not inline"
565        );
566        let got = read_symlink_target(&part, &sb, &inode).unwrap();
567        assert_eq!(got, target);
568    }
569
570    /// A minimal UFS2 partition: root dir (ino 2) with one entry `f` → ino 4, a
571    /// 20-byte file. Exercises the `read_path_content` wrapper end-to-end without
572    /// reaching into another module's test helpers.
573    fn minimal_fs() -> (Vec<u8>, Superblock) {
574        let sb = tiny_sb(512, 512, 64);
575        let fsize = 512usize;
576        let iblkno = 4usize;
577        let fpg = 4096usize;
578        let ipg = 128usize;
579        let isz = 256usize;
580        let ino_byte = |ino: usize| (ino / ipg * fpg + iblkno) * fsize + (ino % ipg) * isz;
581
582        let root_frag = 300u64;
583        let file_frag = 301u64;
584        let max = [ino_byte(5), (file_frag as usize + 1) * fsize]
585            .into_iter()
586            .max()
587            .unwrap();
588        let mut part = vec![0u8; max + 16];
589
590        // root dir inode 2 → root_frag
591        let mut rdi = vec![0u8; isz];
592        rdi[0..2].copy_from_slice(&0o040755u16.to_le_bytes());
593        rdi[16..24].copy_from_slice(&512u64.to_le_bytes());
594        rdi[112..120].copy_from_slice(&root_frag.to_le_bytes());
595        part[ino_byte(2)..ino_byte(2) + isz].copy_from_slice(&rdi);
596
597        // file inode 4 → file_frag, size 20
598        let payload = b"twenty-byte-content!";
599        let mut fi = vec![0u8; isz];
600        fi[0..2].copy_from_slice(&0o100644u16.to_le_bytes());
601        fi[16..24].copy_from_slice(&(payload.len() as u64).to_le_bytes());
602        fi[112..120].copy_from_slice(&file_frag.to_le_bytes());
603        part[ino_byte(4)..ino_byte(4) + isz].copy_from_slice(&fi);
604        let fb = file_frag as usize * fsize;
605        part[fb..fb + payload.len()].copy_from_slice(payload);
606
607        // root directory block: . .. f(4)
608        let mut rb = Vec::new();
609        let direct = |ino: u32, reclen: u16, name: &[u8]| -> Vec<u8> {
610            let mut e = vec![0u8; reclen as usize];
611            e[0..4].copy_from_slice(&ino.to_le_bytes());
612            e[4..6].copy_from_slice(&reclen.to_le_bytes());
613            e[6] = 8; // d_type reg (dir for . .. but immaterial here)
614            e[7] = name.len() as u8;
615            e[8..8 + name.len()].copy_from_slice(name);
616            e
617        };
618        rb.extend(direct(2, 12, b"."));
619        rb.extend(direct(2, 12, b".."));
620        rb.extend(direct(4, 512 - 24, b"f"));
621        let rbo = root_frag as usize * fsize;
622        part[rbo..rbo + rb.len()].copy_from_slice(&rb);
623
624        (part, sb)
625    }
626
627    #[test]
628    fn read_path_content_reads_a_file_and_missing_is_none() {
629        let (part, sb) = minimal_fs();
630        let got = read_path_content(&part, &sb, "/f").unwrap().expect("found");
631        assert_eq!(got, b"twenty-byte-content!");
632        assert!(read_path_content(&part, &sb, "/does-not-exist")
633            .unwrap()
634            .is_none());
635    }
636}