Skip to main content

ext4fs/
inode.rs

1#![forbid(unsafe_code)]
2use crate::block::BlockReader;
3use crate::error::{Ext4Error, Result};
4use crate::ondisk::xattr::XattrEntry;
5use crate::ondisk::{ExtentHeader, ExtentIndex, ExtentLeaf, Inode};
6use std::io::{Read, Seek};
7
8// ---------------------------------------------------------------------------
9// BlockMapping
10// ---------------------------------------------------------------------------
11
12/// A single logical→physical block mapping produced by the extent tree or
13/// indirect-block walk.
14#[derive(Debug, Clone)]
15pub struct BlockMapping {
16    pub logical_block: u64,
17    pub physical_block: u64,
18    pub length: u64,
19    pub unwritten: bool,
20}
21
22// ---------------------------------------------------------------------------
23// Inline data overflow helper
24// ---------------------------------------------------------------------------
25
26/// Search the ibody xattr region for a `system.data` xattr (inline data overflow).
27///
28/// The ibody slice should start immediately after the fixed+extended inode
29/// fields, i.e. at offset `0x80 + extra_isize` within the raw inode bytes.
30fn find_system_data_xattr(ibody: &[u8]) -> Option<Vec<u8>> {
31    let mut offset = 0;
32    while offset + 16 <= ibody.len() {
33        // A zero first byte (name_len == 0) signals end of entries.
34        if ibody[offset] == 0 {
35            break;
36        }
37        match XattrEntry::parse(&ibody[offset..]) {
38            Ok(entry) => {
39                // system.data: System namespace (index 7) with name "data"
40                if entry.name == b"data"
41                    && matches!(
42                        entry.name_index,
43                        crate::ondisk::xattr::XattrNamespace::System
44                    )
45                {
46                    let vs = entry.value_offset as usize;
47                    let ve = vs + entry.value_size as usize;
48                    if ve <= ibody.len() {
49                        return Some(ibody[vs..ve].to_vec());
50                    }
51                }
52                offset += entry.entry_size;
53            }
54            Err(_) => break,
55        }
56    }
57    None
58}
59
60// ---------------------------------------------------------------------------
61// InodeReader
62// ---------------------------------------------------------------------------
63
64pub struct InodeReader<R: Read + Seek> {
65    pub(crate) block_reader: BlockReader<R>,
66}
67
68impl<R: Read + Seek> InodeReader<R> {
69    /// Wrap a `BlockReader` to provide inode-level access.
70    pub fn new(br: BlockReader<R>) -> Self {
71        Self { block_reader: br }
72    }
73
74    /// Borrow the underlying `BlockReader`.
75    pub fn block_reader(&self) -> &BlockReader<R> {
76        &self.block_reader
77    }
78
79    /// Mutably borrow the underlying `BlockReader`.
80    pub fn block_reader_mut(&mut self) -> &mut BlockReader<R> {
81        &mut self.block_reader
82    }
83
84    // -----------------------------------------------------------------------
85    // Inode lookup
86    // -----------------------------------------------------------------------
87
88    /// Read and parse inode `ino` (1-based).
89    ///
90    /// Returns `Ext4Error::InodeOutOfRange` when `ino == 0` or exceeds
91    /// the filesystem's inode count.
92    pub fn read_inode(&self, ino: u64) -> Result<Inode> {
93        let sb = self.block_reader.superblock();
94        let max = u64::from(sb.inodes_count);
95        if ino == 0 || ino > max {
96            return Err(Ext4Error::InodeOutOfRange { ino, max });
97        }
98
99        let inodes_per_group = u64::from(sb.inodes_per_group);
100        let inode_size = u64::from(sb.inode_size);
101        let block_size = u64::from(sb.block_size);
102
103        let group = ((ino - 1) / inodes_per_group) as u32;
104        let index = (ino - 1) % inodes_per_group;
105
106        let inode_table = self.block_reader.inode_table_block(group)?;
107        let offset = inode_table * block_size + index * inode_size;
108
109        let buf = self.block_reader.read_bytes(offset, inode_size as usize)?;
110        Inode::parse(&buf, self.block_reader.superblock().inode_size)
111    }
112
113    // -----------------------------------------------------------------------
114    // Raw inode access
115    // -----------------------------------------------------------------------
116
117    /// Read the raw inode bytes for an inode number.
118    /// Returns the full inode-sized buffer from the inode table.
119    pub fn read_inode_raw(&self, ino: u64) -> Result<Vec<u8>> {
120        let sb = self.block_reader.superblock();
121        let inode_size = u64::from(sb.inode_size);
122        let inodes_per_group = u64::from(sb.inodes_per_group);
123        let max = u64::from(sb.inodes_count);
124        if ino < 1 || ino > max {
125            return Err(Ext4Error::InodeOutOfRange { ino, max });
126        }
127        let group = ((ino - 1) / inodes_per_group) as u32;
128        let index = (ino - 1) % inodes_per_group;
129        let inode_table_block = self.block_reader.inode_table_block(group)?;
130        let block_size = u64::from(self.block_reader.superblock().block_size);
131        let byte_offset = inode_table_block * block_size + index * inode_size;
132        self.block_reader
133            .read_bytes(byte_offset, inode_size as usize)
134    }
135
136    // -----------------------------------------------------------------------
137    // Block map / extent tree
138    // -----------------------------------------------------------------------
139
140    /// Return the full logical→physical block mapping for inode `ino`.
141    pub fn inode_block_map(&self, ino: u64) -> Result<Vec<BlockMapping>> {
142        let inode = self.read_inode(ino)?;
143        if inode.uses_extents() {
144            self.walk_extent_tree(&inode.i_block)
145        } else {
146            self.walk_indirect_blocks(&inode.i_block)
147        }
148    }
149
150    /// Walk the extent tree rooted at `i_block` (60-byte raw field).
151    ///
152    /// The first 12 bytes are the `ExtentHeader`; subsequent 12-byte slots
153    /// are either `ExtentLeaf` entries (depth == 0) or `ExtentIndex` entries
154    /// (depth > 0) whose child blocks must be read and recursed into.
155    pub fn walk_extent_tree(&self, i_block: &[u8; 60]) -> Result<Vec<BlockMapping>> {
156        let mut mappings = Vec::new();
157        self.walk_extent_node(i_block.as_slice(), &mut mappings)?;
158        Ok(mappings)
159    }
160
161    fn walk_extent_node(&self, buf: &[u8], out: &mut Vec<BlockMapping>) -> Result<()> {
162        let header = ExtentHeader::parse(buf)?;
163        let entries = header.entries as usize;
164
165        if header.depth == 0 {
166            // Leaf node — entries are ExtentLeaf structs at offsets 12, 24, …
167            for i in 0..entries {
168                let off = 12 + i * 12;
169                if off + 12 > buf.len() {
170                    break;
171                }
172                let leaf = ExtentLeaf::parse(&buf[off..]);
173                out.push(BlockMapping {
174                    logical_block: u64::from(leaf.logical_block),
175                    physical_block: leaf.physical_block,
176                    length: u64::from(leaf.length),
177                    unwritten: leaf.unwritten,
178                });
179            }
180        } else {
181            // Internal node — entries are ExtentIndex structs; recurse into children.
182            for i in 0..entries {
183                let off = 12 + i * 12;
184                if off + 12 > buf.len() {
185                    break;
186                }
187                let idx = ExtentIndex::parse(&buf[off..]);
188                let child_data = self.block_reader.read_block(idx.child_block)?;
189                self.walk_extent_node(&child_data, out)?;
190            }
191        }
192        Ok(())
193    }
194
195    /// Walk legacy indirect-block pointers stored in `i_block` (60 bytes).
196    ///
197    /// Layout (u32 LE pointers):
198    ///   - offsets  0..48 (12 direct pointers)
199    ///   - offset  48     single-indirect pointer
200    ///   - offset  52     double-indirect pointer
201    ///   - offset  56     triple-indirect pointer
202    pub fn walk_indirect_blocks(&self, i_block: &[u8; 60]) -> Result<Vec<BlockMapping>> {
203        let block_size = self.block_reader.block_size() as usize;
204        let ptrs_per_block = block_size / 4;
205
206        let read_u32 = |buf: &[u8], off: usize| -> u32 {
207            u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]])
208        };
209
210        let mut out = Vec::new();
211        let mut logical = 0u64;
212
213        // 12 direct pointers
214        for i in 0..12usize {
215            let ptr = u64::from(read_u32(i_block, i * 4));
216            if ptr != 0 {
217                out.push(BlockMapping {
218                    logical_block: logical,
219                    physical_block: ptr,
220                    length: 1,
221                    unwritten: false,
222                });
223            }
224            logical += 1;
225        }
226
227        // Single indirect
228        let sind = u64::from(read_u32(i_block, 48));
229        if sind != 0 {
230            let blk = self.block_reader.read_block(sind)?;
231            for i in 0..ptrs_per_block {
232                let ptr = u64::from(read_u32(&blk, i * 4));
233                if ptr != 0 {
234                    out.push(BlockMapping {
235                        logical_block: logical,
236                        physical_block: ptr,
237                        length: 1,
238                        unwritten: false,
239                    });
240                }
241                logical += 1;
242            }
243        } else {
244            logical += ptrs_per_block as u64;
245        }
246
247        // Double indirect
248        let dind = u64::from(read_u32(i_block, 52));
249        if dind != 0 {
250            let l1 = self.block_reader.read_block(dind)?;
251            for i in 0..ptrs_per_block {
252                let ptr1 = u64::from(read_u32(&l1, i * 4));
253                if ptr1 != 0 {
254                    let l2 = self.block_reader.read_block(ptr1)?;
255                    for j in 0..ptrs_per_block {
256                        let ptr2 = u64::from(read_u32(&l2, j * 4));
257                        if ptr2 != 0 {
258                            out.push(BlockMapping {
259                                logical_block: logical,
260                                physical_block: ptr2,
261                                length: 1,
262                                unwritten: false,
263                            });
264                        }
265                        logical += 1;
266                    }
267                } else {
268                    logical += ptrs_per_block as u64;
269                }
270            }
271        } else {
272            logical += (ptrs_per_block * ptrs_per_block) as u64;
273        }
274
275        // Triple indirect
276        let tind = u64::from(read_u32(i_block, 56));
277        if tind != 0 {
278            let l1 = self.block_reader.read_block(tind)?;
279            for i in 0..ptrs_per_block {
280                let ptr1 = u64::from(read_u32(&l1, i * 4));
281                if ptr1 != 0 {
282                    let l2 = self.block_reader.read_block(ptr1)?;
283                    for j in 0..ptrs_per_block {
284                        let ptr2 = u64::from(read_u32(&l2, j * 4));
285                        if ptr2 != 0 {
286                            let l3 = self.block_reader.read_block(ptr2)?;
287                            for k in 0..ptrs_per_block {
288                                let ptr3 = u64::from(read_u32(&l3, k * 4));
289                                if ptr3 != 0 {
290                                    out.push(BlockMapping {
291                                        logical_block: logical,
292                                        physical_block: ptr3,
293                                        length: 1,
294                                        unwritten: false,
295                                    });
296                                }
297                                logical += 1;
298                            }
299                        } else {
300                            logical += ptrs_per_block as u64;
301                        }
302                    }
303                } else {
304                    logical += (ptrs_per_block * ptrs_per_block) as u64;
305                }
306            }
307        }
308
309        Ok(out)
310    }
311
312    // -----------------------------------------------------------------------
313    // Data reading
314    // -----------------------------------------------------------------------
315
316    /// Read the entire data of inode `ino`, truncated to `inode.size`.
317    ///
318    /// For inodes with the `INLINE_DATA` flag the content is the first
319    /// `size` bytes of `i_block` (up to 60 bytes), returned directly.
320    pub fn read_inode_data(&self, ino: u64) -> Result<Vec<u8>> {
321        let inode = self.read_inode(ino)?;
322        if inode.has_inline_data() {
323            let len = (inode.size as usize).min(60);
324            let mut data = inode.i_block[..len].to_vec();
325            // For inline data files > 60 bytes, overflow is in system.data xattr
326            if inode.size > 60 {
327                if let Ok(raw) = self.read_inode_raw(ino) {
328                    let inode_size = self.block_reader.superblock().inode_size as usize;
329                    let ibody_offset = 0x80 + inode.extra_isize as usize;
330                    if inode_size > ibody_offset {
331                        let ibody = &raw[ibody_offset..inode_size.min(raw.len())];
332                        if let Some(value) = find_system_data_xattr(ibody) {
333                            data.extend_from_slice(&value);
334                        }
335                    }
336                }
337            }
338            data.truncate(inode.size as usize);
339            return Ok(data);
340        }
341        let size = inode.size as usize;
342        let _block_size = self.block_reader.block_size() as usize;
343        let map = if inode.uses_extents() {
344            self.walk_extent_tree(&inode.i_block)?
345        } else {
346            self.walk_indirect_blocks(&inode.i_block)?
347        };
348
349        let mut data: Vec<u8> = Vec::with_capacity(size);
350        for mapping in &map {
351            for blk_offset in 0..mapping.length {
352                let phys = mapping.physical_block + blk_offset;
353                let blk_data = self.block_reader.read_block(phys)?;
354                data.extend_from_slice(&blk_data);
355                if data.len() >= size {
356                    data.truncate(size);
357                    return Ok(data);
358                }
359            }
360        }
361        data.truncate(size);
362        Ok(data)
363    }
364
365    /// Read a byte range `[offset, offset+len)` from inode `ino`'s data.
366    ///
367    /// This is more efficient than `read_inode_data` for partial reads
368    /// (e.g. FUSE `read` calls) because it skips blocks that fall entirely
369    /// outside the requested window.
370    pub fn read_inode_data_range(&self, ino: u64, offset: u64, len: usize) -> Result<Vec<u8>> {
371        let inode = self.read_inode(ino)?;
372        if inode.has_inline_data() {
373            let start = offset as usize;
374            let end = (start + len).min(60).min(inode.size as usize);
375            if start >= end {
376                return Ok(Vec::new());
377            }
378            return Ok(inode.i_block[start..end].to_vec());
379        }
380
381        let file_size = inode.size;
382        if offset >= file_size {
383            return Ok(Vec::new());
384        }
385        let want_end = (offset + len as u64).min(file_size);
386        let want_len = (want_end - offset) as usize;
387
388        let block_size = u64::from(self.block_reader.block_size());
389        let map = if inode.uses_extents() {
390            self.walk_extent_tree(&inode.i_block)?
391        } else {
392            self.walk_indirect_blocks(&inode.i_block)?
393        };
394
395        let mut out = vec![0u8; want_len];
396        let mut written = 0usize;
397
398        for mapping in &map {
399            for blk_offset in 0..mapping.length {
400                let logical = (mapping.logical_block + blk_offset) * block_size;
401                let logical_end = logical + block_size;
402                if logical_end <= offset || logical >= want_end {
403                    continue;
404                }
405                let src_start = if logical < offset {
406                    (offset - logical) as usize
407                } else {
408                    0
409                };
410                let dst_start = if logical > offset {
411                    (logical - offset) as usize
412                } else {
413                    0
414                };
415                let phys = mapping.physical_block + blk_offset;
416                let blk_data = self.block_reader.read_block(phys)?;
417                let src_end = blk_data.len().min(src_start + (want_len - dst_start));
418                let copy_len = src_end - src_start;
419                if dst_start + copy_len <= out.len() {
420                    out[dst_start..dst_start + copy_len]
421                        .copy_from_slice(&blk_data[src_start..src_end]);
422                    written += copy_len;
423                }
424            }
425        }
426        out.truncate(written.min(want_len));
427        Ok(out)
428    }
429
430    // -----------------------------------------------------------------------
431    // Bitmap operations
432    // -----------------------------------------------------------------------
433
434    /// Return `true` if inode `ino` (1-based) is marked allocated in its
435    /// group's inode bitmap.
436    pub fn is_inode_allocated(&self, ino: u64) -> Result<bool> {
437        let sb = self.block_reader.superblock();
438        let max = u64::from(sb.inodes_count);
439        if ino == 0 || ino > max {
440            return Err(Ext4Error::InodeOutOfRange { ino, max });
441        }
442        let inodes_per_group = u64::from(sb.inodes_per_group);
443        let group = ((ino - 1) / inodes_per_group) as u32;
444        let index = ((ino - 1) % inodes_per_group) as usize;
445
446        let bitmap_block = self.block_reader.inode_bitmap_block(group)?;
447        let bitmap = self.block_reader.read_block(bitmap_block)?;
448        Ok((bitmap[index / 8] >> (index % 8)) & 1 == 1)
449    }
450
451    /// Return `true` if block `block` is marked allocated in its group's
452    /// block bitmap.
453    pub fn is_block_allocated(&self, block: u64) -> Result<bool> {
454        let sb = self.block_reader.superblock();
455        let blocks_per_group = u64::from(sb.blocks_per_group);
456        let group = (block / blocks_per_group) as u32;
457        let index = (block % blocks_per_group) as usize;
458
459        let bitmap_block = self.block_reader.block_bitmap_block(group)?;
460        let bitmap = self.block_reader.read_block(bitmap_block)?;
461        Ok((bitmap[index / 8] >> (index % 8)) & 1 == 1)
462    }
463
464    // -----------------------------------------------------------------------
465    // Iteration helpers
466    // -----------------------------------------------------------------------
467
468    /// Return all valid inodes in `group` as `(ino, Inode)` pairs.
469    ///
470    /// An entry is skipped when `mode == 0 && dtime == 0` (empty slot).
471    pub fn iter_inodes_in_group(&self, group: u32) -> Result<Vec<(u64, Inode)>> {
472        let sb = self.block_reader.superblock();
473        let inodes_per_group = u64::from(sb.inodes_per_group);
474        let inode_size = sb.inode_size as usize;
475        let block_size = u64::from(sb.block_size);
476        let first_ino = u64::from(group) * inodes_per_group + 1;
477
478        let inode_table = self.block_reader.inode_table_block(group)?;
479        let table_bytes = inode_size as u64 * inodes_per_group;
480        let table_offset = inode_table * block_size;
481        let buf = self
482            .block_reader
483            .read_bytes(table_offset, table_bytes as usize)?;
484
485        let stored_inode_size = self.block_reader.superblock().inode_size;
486        let mut result = Vec::new();
487        for i in 0..inodes_per_group as usize {
488            let off = i * inode_size;
489            let slice = &buf[off..off + inode_size];
490            // Skip empty slots: mode == 0 and dtime == 0
491            let mode = u16::from_le_bytes([slice[0], slice[1]]);
492            let dtime = u32::from_le_bytes([slice[0x14], slice[0x15], slice[0x16], slice[0x17]]);
493            if mode == 0 && dtime == 0 {
494                continue;
495            }
496            if let Ok(inode) = Inode::parse(slice, stored_inode_size) {
497                result.push((first_ino + i as u64, inode));
498            }
499        }
500        Ok(result)
501    }
502
503    /// Return all valid inodes across all block groups.
504    pub fn iter_all_inodes(&self) -> Result<Vec<(u64, Inode)>> {
505        let group_count = self.block_reader.group_count();
506        let mut all = Vec::new();
507        for g in 0..group_count {
508            let inodes = self.iter_inodes_in_group(g)?;
509            all.extend(inodes);
510        }
511        Ok(all)
512    }
513}
514
515// ---------------------------------------------------------------------------
516// Tests
517// ---------------------------------------------------------------------------
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::dir::DirReader;
523    use crate::ondisk::FileType;
524    use std::io::Cursor;
525
526    fn open_minimal() -> InodeReader<Cursor<Vec<u8>>> {
527        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
528        let data = std::fs::read(path).expect("minimal.img required");
529        let br = BlockReader::open(Cursor::new(data)).unwrap();
530        InodeReader::new(br)
531    }
532
533    /// Resolve a path on minimal.img and return its inode number.
534    fn resolve_minimal(path: &str) -> u64 {
535        let img_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
536        let data = std::fs::read(img_path).expect("minimal.img required");
537        let br = BlockReader::open(Cursor::new(data)).unwrap();
538        let ir = InodeReader::new(br);
539        let dr = DirReader::new(ir);
540        dr.resolve_path(path).unwrap()
541    }
542
543    /// Resolve a path on forensic.img and return its inode number.
544    fn resolve_forensic(path: &str) -> u64 {
545        let img_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
546        let data = std::fs::read(img_path).expect("forensic.img required");
547        let br = BlockReader::open(Cursor::new(data)).unwrap();
548        let ir = InodeReader::new(br);
549        let dr = DirReader::new(ir);
550        dr.resolve_path(path).unwrap()
551    }
552
553    #[test]
554    fn read_root_inode() {
555        let r = open_minimal();
556        let inode = r.read_inode(2).unwrap();
557        assert_eq!(inode.file_type(), FileType::Directory);
558        assert!(inode.links_count >= 2);
559    }
560
561    #[test]
562    fn read_inode_out_of_range() {
563        let r = open_minimal();
564        let err = r.read_inode(0).unwrap_err();
565        assert!(matches!(err, Ext4Error::InodeOutOfRange { .. }));
566    }
567
568    #[test]
569    fn read_file_data() {
570        let r = open_minimal();
571        let data = r.read_inode_data(2).unwrap();
572        assert!(!data.is_empty());
573    }
574
575    #[test]
576    fn inode_block_map_for_root() {
577        let r = open_minimal();
578        let inode = r.read_inode(2).unwrap();
579        if inode.uses_extents() {
580            let map = r.inode_block_map(2).unwrap();
581            assert!(!map.is_empty());
582            assert!(map[0].physical_block > 0);
583        }
584    }
585
586    #[test]
587    fn is_inode_allocated() {
588        let r = open_minimal();
589        assert!(r.is_inode_allocated(2).unwrap());
590    }
591
592    #[test]
593    fn read_inode_data_extent_path_returns_data() {
594        let reader = open_minimal();
595        // Root inode (2) uses extents — verify the non-inline path works
596        let inode = reader.read_inode(2).unwrap();
597        assert!(!inode.has_inline_data());
598        assert!(inode.uses_extents());
599        let data = reader.read_inode_data(2).unwrap();
600        assert_eq!(data.len(), inode.size as usize);
601        assert!(!data.is_empty());
602    }
603
604    // -------------------------------------------------------------------
605    // Helper: open forensic.img
606    // -------------------------------------------------------------------
607
608    fn open_forensic() -> InodeReader<Cursor<Vec<u8>>> {
609        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
610        let data = std::fs::read(path).expect("forensic.img required");
611        let br = BlockReader::open(Cursor::new(data)).unwrap();
612        InodeReader::new(br)
613    }
614
615    // -------------------------------------------------------------------
616    // 1. block_reader() and block_reader_mut() accessors
617    // -------------------------------------------------------------------
618
619    #[test]
620    fn block_reader_accessor() {
621        let r = open_minimal();
622        let sb = r.block_reader().superblock();
623        assert!(sb.block_size >= 1024, "block size should be at least 1024");
624        assert!(sb.inodes_count > 0);
625    }
626
627    #[test]
628    fn block_reader_mut_accessor() {
629        let mut r = open_minimal();
630        let sb = r.block_reader_mut().superblock();
631        assert!(sb.block_size >= 1024);
632    }
633
634    // -------------------------------------------------------------------
635    // 2. read_inode_raw — raw bytes for inode 2
636    // -------------------------------------------------------------------
637
638    #[test]
639    fn read_inode_raw_minimal() {
640        let r = open_minimal();
641        let inode_size = r.block_reader().superblock().inode_size as usize;
642        let raw = r.read_inode_raw(2).unwrap();
643        assert_eq!(
644            raw.len(),
645            inode_size,
646            "raw inode length should equal inode_size"
647        );
648        // The mode field (u16 LE at offset 0) should be non-zero for root dir
649        let mode = u16::from_le_bytes([raw[0], raw[1]]);
650        assert_ne!(mode, 0, "root inode mode should be non-zero");
651    }
652
653    #[test]
654    fn read_inode_raw_out_of_range() {
655        let r = open_minimal();
656        assert!(r.read_inode_raw(0).is_err());
657        assert!(r.read_inode_raw(u64::MAX).is_err());
658    }
659
660    // -------------------------------------------------------------------
661    // 3. inode_block_map — hello.txt (inode 12) on minimal.img
662    // -------------------------------------------------------------------
663
664    #[test]
665    fn inode_block_map_hello_txt() {
666        let hello_ino = resolve_minimal("/hello.txt");
667        let r = open_minimal();
668        let map = r.inode_block_map(hello_ino).unwrap();
669        assert!(
670            !map.is_empty(),
671            "hello.txt should have at least one block mapping"
672        );
673        for m in &map {
674            assert!(m.physical_block > 0, "physical block should be non-zero");
675            assert!(m.length > 0, "mapping length should be positive");
676        }
677    }
678
679    // -------------------------------------------------------------------
680    // 4. read_inode_data_range — first 5 bytes of hello.txt
681    // -------------------------------------------------------------------
682
683    #[test]
684    fn read_inode_data_range_hello_prefix() {
685        let hello_ino = resolve_minimal("/hello.txt");
686        let r = open_minimal();
687        let data = r.read_inode_data_range(hello_ino, 0, 5).unwrap();
688        assert_eq!(
689            &data, b"Hello",
690            "first 5 bytes of hello.txt should be 'Hello'"
691        );
692    }
693
694    #[test]
695    fn read_inode_data_range_past_eof() {
696        let hello_ino = resolve_minimal("/hello.txt");
697        let r = open_minimal();
698        let inode = r.read_inode(hello_ino).unwrap();
699        let data = r
700            .read_inode_data_range(hello_ino, inode.size + 100, 10)
701            .unwrap();
702        assert!(data.is_empty(), "reading past EOF should return empty");
703    }
704
705    // -------------------------------------------------------------------
706    // 5. is_block_allocated — block 0 should be allocated
707    // -------------------------------------------------------------------
708
709    #[test]
710    fn is_block_allocated_block_zero() {
711        let r = open_minimal();
712        let alloc = r.is_block_allocated(0).unwrap();
713        assert!(alloc, "block 0 (superblock) should be allocated");
714    }
715
716    // -------------------------------------------------------------------
717    // 6. iter_inodes_in_group(0) — includes root inode 2
718    // -------------------------------------------------------------------
719
720    #[test]
721    fn iter_inodes_in_group_zero() {
722        let r = open_minimal();
723        let inodes = r.iter_inodes_in_group(0).unwrap();
724        assert!(!inodes.is_empty(), "group 0 should have inodes");
725        let inos: Vec<u64> = inodes.iter().map(|(ino, _)| *ino).collect();
726        assert!(inos.contains(&2), "group 0 should contain root inode 2");
727    }
728
729    // -------------------------------------------------------------------
730    // 7. iter_all_inodes — multiple inodes including inode 2
731    // -------------------------------------------------------------------
732
733    #[test]
734    fn iter_all_inodes_includes_root() {
735        let r = open_minimal();
736        let all = r.iter_all_inodes().unwrap();
737        assert!(all.len() >= 2, "should return multiple inodes");
738        let inos: Vec<u64> = all.iter().map(|(ino, _)| *ino).collect();
739        assert!(inos.contains(&2), "should include root inode 2");
740    }
741
742    // -------------------------------------------------------------------
743    // 8. read_inode_data for a directory (inode 2)
744    // -------------------------------------------------------------------
745
746    #[test]
747    fn read_inode_data_directory() {
748        let r = open_minimal();
749        let inode = r.read_inode(2).unwrap();
750        assert_eq!(inode.file_type(), FileType::Directory);
751        let data = r.read_inode_data(2).unwrap();
752        assert!(!data.is_empty(), "root directory data should not be empty");
753        assert_eq!(data.len(), inode.size as usize);
754    }
755
756    // -------------------------------------------------------------------
757    // 9. read_inode for various inodes
758    // -------------------------------------------------------------------
759
760    #[test]
761    fn read_inode_hello_txt() {
762        let hello_ino = resolve_minimal("/hello.txt");
763        let r = open_minimal();
764        let inode = r.read_inode(hello_ino).unwrap();
765        assert_eq!(inode.file_type(), FileType::RegularFile);
766        // "Hello, ext4!" without or with trailing newline
767        assert!(
768            inode.size == 11 || inode.size == 12,
769            "hello.txt should be 11 or 12 bytes, got {}",
770            inode.size
771        );
772    }
773
774    #[test]
775    fn read_inode_lost_found() {
776        let lf_ino = resolve_minimal("/lost+found");
777        let r = open_minimal();
778        let inode = r.read_inode(lf_ino).unwrap();
779        assert_eq!(inode.file_type(), FileType::Directory);
780        assert!(
781            inode.links_count >= 2,
782            "lost+found should have at least 2 links"
783        );
784    }
785
786    // -------------------------------------------------------------------
787    // 10. read_inode_raw on forensic.img
788    // -------------------------------------------------------------------
789
790    #[test]
791    fn read_inode_raw_forensic() {
792        let r = open_forensic();
793        let inode_size = r.block_reader().superblock().inode_size as usize;
794        let raw = r.read_inode_raw(2).unwrap();
795        assert_eq!(raw.len(), inode_size);
796        // Verify mode is non-zero for root dir
797        let mode = u16::from_le_bytes([raw[0], raw[1]]);
798        assert_ne!(mode, 0);
799    }
800
801    // -------------------------------------------------------------------
802    // 11. read_inode_data_range partial read on forensic.img
803    // -------------------------------------------------------------------
804
805    #[test]
806    fn read_inode_data_range_forensic_middle() {
807        let hello_ino = resolve_forensic("/hello.txt");
808        let r = open_forensic();
809        // hello.txt = "Hello, forensic world!\n" (23 bytes)
810        // Read bytes 7..15 → "forensic"
811        let data = r.read_inode_data_range(hello_ino, 7, 8).unwrap();
812        assert_eq!(
813            std::str::from_utf8(&data).unwrap(),
814            "forensic",
815            "middle bytes of forensic hello.txt"
816        );
817    }
818
819    #[test]
820    fn read_inode_data_range_forensic_start() {
821        let hello_ino = resolve_forensic("/hello.txt");
822        let r = open_forensic();
823        let data = r.read_inode_data_range(hello_ino, 0, 5).unwrap();
824        assert_eq!(&data, b"Hello");
825    }
826
827    // -------------------------------------------------------------------
828    // 12. is_block_allocated on forensic.img — various blocks
829    // -------------------------------------------------------------------
830
831    #[test]
832    fn is_block_allocated_forensic_block_zero() {
833        let r = open_forensic();
834        assert!(
835            r.is_block_allocated(0).unwrap(),
836            "block 0 should be allocated on forensic.img"
837        );
838    }
839
840    #[test]
841    fn is_block_allocated_forensic_high_block() {
842        let r = open_forensic();
843        // The forensic image is 32MB with 4096-byte blocks = 8192 blocks.
844        // The last block should be unallocated (unused space beyond fs data).
845        let sb = r.block_reader().superblock();
846        let total_blocks = sb.blocks_count;
847        // Check a block near the end — likely unallocated
848        if total_blocks > 100 {
849            let result = r.is_block_allocated(total_blocks - 1);
850            // Just verify it doesn't panic/error — the value depends on layout
851            assert!(result.is_ok());
852        }
853    }
854
855    #[test]
856    fn is_block_allocated_forensic_superblock_area() {
857        let r = open_forensic();
858        // Block 1 on a 4096-byte blocksize fs holds the superblock backup or GDT
859        let alloc = r.is_block_allocated(1).unwrap();
860        assert!(alloc, "block 1 should be allocated (GDT/superblock area)");
861    }
862}