composefs/erofs/
reader.rs

1//! EROFS image reading and parsing functionality.
2//!
3//! This module provides safe parsing and navigation of EROFS filesystem
4//! images, including inode traversal, directory reading, and object
5//! reference collection for garbage collection.
6
7use core::mem::size_of;
8use std::collections::{BTreeSet, HashSet};
9use std::ops::Range;
10
11use thiserror::Error;
12use zerocopy::{little_endian::U32, FromBytes, Immutable, KnownLayout};
13
14use super::{
15    composefs::OverlayMetacopy,
16    format::{
17        CompactInodeHeader, ComposefsHeader, DataLayout, DirectoryEntryHeader, ExtendedInodeHeader,
18        InodeXAttrHeader, ModeField, Superblock, XAttrHeader,
19    },
20};
21use crate::fsverity::FsVerityHashValue;
22
23/// Rounds up a value to the nearest multiple of `to`
24pub fn round_up(n: usize, to: usize) -> usize {
25    (n + to - 1) & !(to - 1)
26}
27
28/// Common interface for accessing inode header fields across different layouts
29pub trait InodeHeader {
30    /// Returns the data layout method used by this inode
31    fn data_layout(&self) -> DataLayout;
32    /// Returns the extended attribute inode count
33    fn xattr_icount(&self) -> u16;
34    /// Returns the file mode
35    fn mode(&self) -> ModeField;
36    /// Returns the file size in bytes
37    fn size(&self) -> u64;
38    /// Returns the union field value (block address, device number, etc.)
39    fn u(&self) -> u32;
40
41    /// Calculates the number of additional bytes after the header
42    fn additional_bytes(&self, blkszbits: u8) -> usize {
43        let block_size = 1 << blkszbits;
44        self.xattr_size()
45            + match self.data_layout() {
46                DataLayout::FlatPlain => 0,
47                DataLayout::FlatInline => self.size() as usize % block_size,
48                DataLayout::ChunkBased => 4,
49            }
50    }
51
52    /// Calculates the size of the extended attributes section
53    fn xattr_size(&self) -> usize {
54        match self.xattr_icount() {
55            0 => 0,
56            n => (n as usize - 1) * 4 + 12,
57        }
58    }
59}
60
61impl InodeHeader for ExtendedInodeHeader {
62    fn data_layout(&self) -> DataLayout {
63        self.format.try_into().unwrap()
64    }
65
66    fn xattr_icount(&self) -> u16 {
67        self.xattr_icount.get()
68    }
69
70    fn mode(&self) -> ModeField {
71        self.mode
72    }
73
74    fn size(&self) -> u64 {
75        self.size.get()
76    }
77
78    fn u(&self) -> u32 {
79        self.u.get()
80    }
81}
82
83impl InodeHeader for CompactInodeHeader {
84    fn data_layout(&self) -> DataLayout {
85        self.format.try_into().unwrap()
86    }
87
88    fn xattr_icount(&self) -> u16 {
89        self.xattr_icount.get()
90    }
91
92    fn mode(&self) -> ModeField {
93        self.mode
94    }
95
96    fn size(&self) -> u64 {
97        self.size.get() as u64
98    }
99
100    fn u(&self) -> u32 {
101        self.u.get()
102    }
103}
104
105/// Extended attribute entry with header and variable-length data
106#[repr(C)]
107#[derive(FromBytes, Immutable, KnownLayout)]
108pub struct XAttr {
109    /// Extended attribute header
110    pub header: XAttrHeader,
111    /// Variable-length data containing name suffix and value
112    pub data: [u8],
113}
114
115/// Inode structure with header and variable-length data
116#[repr(C)]
117#[derive(FromBytes, Immutable, KnownLayout)]
118pub struct Inode<Header: InodeHeader> {
119    /// Inode header (compact or extended)
120    pub header: Header,
121    /// Variable-length data containing xattrs and inline content
122    pub data: [u8],
123}
124
125/// Extended attributes section of an inode
126#[repr(C)]
127#[derive(Debug, FromBytes, Immutable, KnownLayout)]
128pub struct InodeXAttrs {
129    /// Extended attributes header
130    pub header: InodeXAttrHeader,
131    /// Variable-length data containing shared xattr refs and local xattrs
132    pub data: [u8],
133}
134
135impl XAttrHeader {
136    /// Calculates the total size of this xattr including padding
137    pub fn calculate_n_elems(&self) -> usize {
138        round_up(self.name_len as usize + self.value_size.get() as usize, 4)
139    }
140}
141
142impl XAttr {
143    /// Parses an xattr from a byte slice, returning the xattr and remaining bytes
144    pub fn from_prefix(data: &[u8]) -> (&XAttr, &[u8]) {
145        let header = XAttrHeader::ref_from_bytes(&data[..4]).unwrap();
146        Self::ref_from_prefix_with_elems(data, header.calculate_n_elems()).unwrap()
147    }
148
149    /// Returns the attribute name suffix
150    pub fn suffix(&self) -> &[u8] {
151        &self.data[..self.header.name_len as usize]
152    }
153
154    /// Returns the attribute value
155    pub fn value(&self) -> &[u8] {
156        &self.data[self.header.name_len as usize..][..self.header.value_size.get() as usize]
157    }
158
159    /// Returns the padding bytes after the value
160    pub fn padding(&self) -> &[u8] {
161        &self.data[self.header.name_len as usize + self.header.value_size.get() as usize..]
162    }
163}
164
165/// Operations on inode data
166pub trait InodeOps {
167    /// Returns the extended attributes section if present
168    fn xattrs(&self) -> Option<&InodeXAttrs>;
169    /// Returns the inline data portion
170    fn inline(&self) -> Option<&[u8]>;
171    /// Returns the range of block IDs used by this inode
172    fn blocks(&self, blkszbits: u8) -> Range<u64>;
173}
174
175impl<Header: InodeHeader> InodeHeader for &Inode<Header> {
176    fn data_layout(&self) -> DataLayout {
177        self.header.data_layout()
178    }
179
180    fn xattr_icount(&self) -> u16 {
181        self.header.xattr_icount()
182    }
183
184    fn mode(&self) -> ModeField {
185        self.header.mode()
186    }
187
188    fn size(&self) -> u64 {
189        self.header.size()
190    }
191
192    fn u(&self) -> u32 {
193        self.header.u()
194    }
195}
196
197impl<Header: InodeHeader> InodeOps for &Inode<Header> {
198    fn xattrs(&self) -> Option<&InodeXAttrs> {
199        match self.header.xattr_size() {
200            0 => None,
201            n => Some(InodeXAttrs::ref_from_bytes(&self.data[..n]).unwrap()),
202        }
203    }
204
205    fn inline(&self) -> Option<&[u8]> {
206        let data = &self.data[self.header.xattr_size()..];
207
208        if data.is_empty() {
209            return None;
210        }
211
212        Some(data)
213    }
214
215    fn blocks(&self, blkszbits: u8) -> Range<u64> {
216        let size = self.header.size();
217        let block_size = 1 << blkszbits;
218        let start = self.header.u() as u64;
219
220        match self.header.data_layout() {
221            DataLayout::FlatPlain => Range {
222                start,
223                end: start + size.div_ceil(block_size),
224            },
225            DataLayout::FlatInline => Range {
226                start,
227                end: start + size / block_size,
228            },
229            DataLayout::ChunkBased => Range { start, end: start },
230        }
231    }
232}
233
234// this lets us avoid returning Box<dyn InodeOp> from Image.inode()
235// but ... wow.
236/// Inode type enum allowing static dispatch for different header layouts
237#[derive(Debug)]
238pub enum InodeType<'img> {
239    /// Compact inode with 32-byte header
240    Compact(&'img Inode<CompactInodeHeader>),
241    /// Extended inode with 64-byte header
242    Extended(&'img Inode<ExtendedInodeHeader>),
243}
244
245impl InodeHeader for InodeType<'_> {
246    fn u(&self) -> u32 {
247        match self {
248            Self::Compact(inode) => inode.u(),
249            Self::Extended(inode) => inode.u(),
250        }
251    }
252
253    fn size(&self) -> u64 {
254        match self {
255            Self::Compact(inode) => inode.size(),
256            Self::Extended(inode) => inode.size(),
257        }
258    }
259
260    fn xattr_icount(&self) -> u16 {
261        match self {
262            Self::Compact(inode) => inode.xattr_icount(),
263            Self::Extended(inode) => inode.xattr_icount(),
264        }
265    }
266
267    fn data_layout(&self) -> DataLayout {
268        match self {
269            Self::Compact(inode) => inode.data_layout(),
270            Self::Extended(inode) => inode.data_layout(),
271        }
272    }
273
274    fn mode(&self) -> ModeField {
275        match self {
276            Self::Compact(inode) => inode.mode(),
277            Self::Extended(inode) => inode.mode(),
278        }
279    }
280}
281
282impl InodeOps for InodeType<'_> {
283    fn xattrs(&self) -> Option<&InodeXAttrs> {
284        match self {
285            Self::Compact(inode) => inode.xattrs(),
286            Self::Extended(inode) => inode.xattrs(),
287        }
288    }
289
290    fn inline(&self) -> Option<&[u8]> {
291        match self {
292            Self::Compact(inode) => inode.inline(),
293            Self::Extended(inode) => inode.inline(),
294        }
295    }
296
297    fn blocks(&self, blkszbits: u8) -> Range<u64> {
298        match self {
299            Self::Compact(inode) => inode.blocks(blkszbits),
300            Self::Extended(inode) => inode.blocks(blkszbits),
301        }
302    }
303}
304
305/// Parsed EROFS image with references to key structures
306#[derive(Debug)]
307pub struct Image<'i> {
308    /// Raw image bytes
309    pub image: &'i [u8],
310    /// Composefs header
311    pub header: &'i ComposefsHeader,
312    /// Block size in bits
313    pub blkszbits: u8,
314    /// Block size in bytes
315    pub block_size: usize,
316    /// Superblock
317    pub sb: &'i Superblock,
318    /// Inode metadata region
319    pub inodes: &'i [u8],
320    /// Extended attributes region
321    pub xattrs: &'i [u8],
322}
323
324impl<'img> Image<'img> {
325    /// Opens an EROFS image from raw bytes
326    pub fn open(image: &'img [u8]) -> Self {
327        let header = ComposefsHeader::ref_from_prefix(image)
328            .expect("header err")
329            .0;
330        let sb = Superblock::ref_from_prefix(&image[1024..])
331            .expect("superblock err")
332            .0;
333        let blkszbits = sb.blkszbits;
334        let block_size = 1usize << blkszbits;
335        assert!(block_size != 0);
336        let inodes = &image[sb.meta_blkaddr.get() as usize * block_size..];
337        let xattrs = &image[sb.xattr_blkaddr.get() as usize * block_size..];
338        Image {
339            image,
340            header,
341            blkszbits,
342            block_size,
343            sb,
344            inodes,
345            xattrs,
346        }
347    }
348
349    /// Returns an inode by its ID
350    pub fn inode(&self, id: u64) -> InodeType<'_> {
351        let inode_data = &self.inodes[id as usize * 32..];
352        if inode_data[0] & 1 != 0 {
353            let header = ExtendedInodeHeader::ref_from_bytes(&inode_data[..64]).unwrap();
354            InodeType::Extended(
355                Inode::<ExtendedInodeHeader>::ref_from_prefix_with_elems(
356                    inode_data,
357                    header.additional_bytes(self.blkszbits),
358                )
359                .unwrap()
360                .0,
361            )
362        } else {
363            let header = CompactInodeHeader::ref_from_bytes(&inode_data[..32]).unwrap();
364            InodeType::Compact(
365                Inode::<CompactInodeHeader>::ref_from_prefix_with_elems(
366                    inode_data,
367                    header.additional_bytes(self.blkszbits),
368                )
369                .unwrap()
370                .0,
371            )
372        }
373    }
374
375    /// Returns a shared extended attribute by its ID
376    pub fn shared_xattr(&self, id: u32) -> &XAttr {
377        let xattr_data = &self.xattrs[id as usize * 4..];
378        let header = XAttrHeader::ref_from_bytes(&xattr_data[..4]).unwrap();
379        XAttr::ref_from_prefix_with_elems(xattr_data, header.calculate_n_elems())
380            .unwrap()
381            .0
382    }
383
384    /// Returns a data block by its ID
385    pub fn block(&self, id: u64) -> &[u8] {
386        &self.image[id as usize * self.block_size..][..self.block_size]
387    }
388
389    /// Returns a data block by its ID as a DataBlock reference
390    pub fn data_block(&self, id: u64) -> &DataBlock {
391        DataBlock::ref_from_bytes(self.block(id)).unwrap()
392    }
393
394    /// Returns a directory block by its ID
395    pub fn directory_block(&self, id: u64) -> &DirectoryBlock {
396        DirectoryBlock::ref_from_bytes(self.block(id)).unwrap()
397    }
398
399    /// Returns the root directory inode
400    pub fn root(&self) -> InodeType<'_> {
401        self.inode(self.sb.root_nid.get() as u64)
402    }
403}
404
405// TODO: there must be an easier way...
406#[derive(FromBytes, Immutable, KnownLayout)]
407#[repr(C)]
408struct Array<T>([T]);
409
410impl InodeXAttrs {
411    /// Returns the array of shared xattr IDs
412    pub fn shared(&self) -> &[U32] {
413        &Array::ref_from_prefix_with_elems(&self.data, self.header.shared_count as usize)
414            .unwrap()
415            .0
416             .0
417    }
418
419    /// Returns an iterator over local (non-shared) xattrs
420    pub fn local(&self) -> XAttrIter<'_> {
421        XAttrIter {
422            data: &self.data[self.header.shared_count as usize * 4..],
423        }
424    }
425}
426
427/// Iterator over local extended attributes
428#[derive(Debug)]
429pub struct XAttrIter<'img> {
430    data: &'img [u8],
431}
432
433impl<'img> Iterator for XAttrIter<'img> {
434    type Item = &'img XAttr;
435
436    fn next(&mut self) -> Option<Self::Item> {
437        if !self.data.is_empty() {
438            let (result, rest) = XAttr::from_prefix(self.data);
439            self.data = rest;
440            Some(result)
441        } else {
442            None
443        }
444    }
445}
446
447/// Data block containing file content
448#[repr(C)]
449#[derive(FromBytes, Immutable, KnownLayout)]
450pub struct DataBlock(pub [u8]);
451
452/// Directory block containing directory entries
453#[repr(C)]
454#[derive(FromBytes, Immutable, KnownLayout)]
455pub struct DirectoryBlock(pub [u8]);
456
457impl DirectoryBlock {
458    /// Returns the directory entry header at the given index
459    pub fn get_entry_header(&self, n: usize) -> &DirectoryEntryHeader {
460        let entry_data = &self.0
461            [n * size_of::<DirectoryEntryHeader>()..(n + 1) * size_of::<DirectoryEntryHeader>()];
462        DirectoryEntryHeader::ref_from_bytes(entry_data).unwrap()
463    }
464
465    /// Returns all directory entry headers as a slice
466    pub fn get_entry_headers(&self) -> &[DirectoryEntryHeader] {
467        &Array::ref_from_prefix_with_elems(&self.0, self.n_entries())
468            .unwrap()
469            .0
470             .0
471    }
472
473    /// Returns the number of entries in this directory block
474    pub fn n_entries(&self) -> usize {
475        let first = self.get_entry_header(0);
476        let offset = first.name_offset.get();
477        assert!(offset != 0);
478        assert!(offset.is_multiple_of(12));
479        offset as usize / 12
480    }
481
482    /// Returns an iterator over directory entries
483    pub fn entries(&self) -> DirectoryEntries<'_> {
484        DirectoryEntries {
485            block: self,
486            length: self.n_entries(),
487            position: 0,
488        }
489    }
490}
491
492// High-level iterator interface
493/// A single directory entry with header and name
494#[derive(Debug)]
495pub struct DirectoryEntry<'a> {
496    /// Directory entry header
497    pub header: &'a DirectoryEntryHeader,
498    /// Entry name
499    pub name: &'a [u8],
500}
501
502impl DirectoryEntry<'_> {
503    fn nid(&self) -> u64 {
504        self.header.inode_offset.get()
505    }
506}
507
508/// Iterator over directory entries in a directory block
509#[derive(Debug)]
510pub struct DirectoryEntries<'d> {
511    block: &'d DirectoryBlock,
512    length: usize,
513    position: usize,
514}
515
516impl<'d> Iterator for DirectoryEntries<'d> {
517    type Item = DirectoryEntry<'d>;
518
519    fn next(&mut self) -> Option<Self::Item> {
520        if self.position < self.length {
521            let header = self.block.get_entry_header(self.position);
522            let name_start = header.name_offset.get() as usize;
523            self.position += 1;
524
525            let name = if self.position == self.length {
526                let with_padding = &self.block.0[name_start..];
527                let end = with_padding.partition_point(|c| *c != 0);
528                &with_padding[..end]
529            } else {
530                let next = self.block.get_entry_header(self.position);
531                let name_end = next.name_offset.get() as usize;
532                &self.block.0[name_start..name_end]
533            };
534
535            Some(DirectoryEntry { header, name })
536        } else {
537            None
538        }
539    }
540}
541
542/// Errors that can occur when reading EROFS images
543#[derive(Error, Debug)]
544pub enum ErofsReaderError {
545    /// Directory has multiple hard links (not allowed)
546    #[error("Hardlinked directories detected")]
547    DirectoryHardlinks,
548    /// Directory nesting exceeds maximum depth
549    #[error("Maximum directory depth exceeded")]
550    DepthExceeded,
551    /// The '.' entry is invalid
552    #[error("Invalid '.' entry in directory")]
553    InvalidSelfReference,
554    /// The '..' entry is invalid
555    #[error("Invalid '..' entry in directory")]
556    InvalidParentReference,
557    /// File type in directory entry doesn't match inode
558    #[error("File type in dirent doesn't match type in inode")]
559    FileTypeMismatch,
560}
561
562type ReadResult<T> = Result<T, ErofsReaderError>;
563
564/// Collects object references from an EROFS image for garbage collection
565#[derive(Debug)]
566pub struct ObjectCollector<ObjectID: FsVerityHashValue> {
567    visited_nids: HashSet<u64>,
568    nids_to_visit: BTreeSet<u64>,
569    objects: HashSet<ObjectID>,
570}
571
572impl<ObjectID: FsVerityHashValue> ObjectCollector<ObjectID> {
573    fn visit_xattr(&mut self, attr: &XAttr) {
574        // This is the index of "trusted".  See XATTR_PREFIXES in format.rs.
575        if attr.header.name_index != 4 {
576            return;
577        }
578        if attr.suffix() != b"overlay.metacopy" {
579            return;
580        }
581        if let Ok(value) = OverlayMetacopy::read_from_bytes(attr.value()) {
582            if value.valid() {
583                self.objects.insert(value.digest);
584            }
585        }
586    }
587
588    fn visit_xattrs(&mut self, img: &Image, xattrs: &InodeXAttrs) -> ReadResult<()> {
589        for id in xattrs.shared() {
590            self.visit_xattr(img.shared_xattr(id.get()));
591        }
592        for attr in xattrs.local() {
593            self.visit_xattr(attr);
594        }
595        Ok(())
596    }
597
598    fn visit_directory_block(&mut self, block: &DirectoryBlock) {
599        for entry in block.entries() {
600            if entry.name != b"." && entry.name != b".." {
601                let nid = entry.nid();
602                if !self.visited_nids.contains(&nid) {
603                    self.nids_to_visit.insert(nid);
604                }
605            }
606        }
607    }
608
609    fn visit_nid(&mut self, img: &Image, nid: u64) -> ReadResult<()> {
610        let first_time = self.visited_nids.insert(nid);
611        assert!(first_time); // should not have been added to the "to visit" list otherwise
612
613        let inode = img.inode(nid);
614
615        if let Some(xattrs) = inode.xattrs() {
616            self.visit_xattrs(img, xattrs)?;
617        }
618
619        if inode.mode().is_dir() {
620            for blkid in inode.blocks(img.sb.blkszbits) {
621                self.visit_directory_block(img.directory_block(blkid));
622            }
623
624            if let Some(inline) = inode.inline() {
625                let inline_block = DirectoryBlock::ref_from_bytes(inline).unwrap();
626                self.visit_directory_block(inline_block);
627            }
628        }
629
630        Ok(())
631    }
632}
633
634/// Collects all object references from an EROFS image
635///
636/// This function walks the directory tree and extracts fsverity object IDs
637/// from overlay.metacopy xattrs for garbage collection purposes.
638///
639/// Returns a set of all referenced object IDs.
640pub fn collect_objects<ObjectID: FsVerityHashValue>(image: &[u8]) -> ReadResult<HashSet<ObjectID>> {
641    let img = Image::open(image);
642    let mut this = ObjectCollector {
643        visited_nids: HashSet::new(),
644        nids_to_visit: BTreeSet::new(),
645        objects: HashSet::new(),
646    };
647
648    // nids_to_visit is initialized with the root directory.  Visiting directory nids will add
649    // more nids to the "to visit" list.  Keep iterating until it's empty.
650    this.nids_to_visit.insert(img.sb.root_nid.get() as u64);
651    while let Some(nid) = this.nids_to_visit.pop_first() {
652        this.visit_nid(&img, nid)?;
653    }
654    Ok(this.objects)
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::{
661        dumpfile::dumpfile_to_filesystem, erofs::writer::mkfs_erofs, fsverity::Sha256HashValue,
662    };
663    use std::collections::HashMap;
664
665    /// Helper to validate that directory entries can be read correctly
666    fn validate_directory_entries(img: &Image, nid: u64, expected_names: &[&str]) {
667        let inode = img.inode(nid);
668        assert!(inode.mode().is_dir(), "Expected directory inode");
669
670        let mut found_names = Vec::new();
671
672        // Read inline entries if present
673        if let Some(inline) = inode.inline() {
674            let inline_block = DirectoryBlock::ref_from_bytes(inline).unwrap();
675            for entry in inline_block.entries() {
676                let name = std::str::from_utf8(entry.name).unwrap();
677                found_names.push(name.to_string());
678            }
679        }
680
681        // Read block entries
682        for blkid in inode.blocks(img.blkszbits) {
683            let block = img.directory_block(blkid);
684            for entry in block.entries() {
685                let name = std::str::from_utf8(entry.name).unwrap();
686                found_names.push(name.to_string());
687            }
688        }
689
690        // Sort for comparison (entries should include . and ..)
691        found_names.sort();
692        let mut expected_sorted: Vec<_> = expected_names.iter().map(|s| s.to_string()).collect();
693        expected_sorted.sort();
694
695        assert_eq!(
696            found_names, expected_sorted,
697            "Directory entries mismatch for nid {nid}"
698        );
699    }
700
701    #[test]
702    fn test_empty_directory() {
703        // Create filesystem with empty directory
704        let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
705/empty_dir 4096 40755 2 0 0 0 1000.0 - - -
706"#;
707
708        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile).unwrap();
709        let image = mkfs_erofs(&fs);
710        let img = Image::open(&image);
711
712        // Root should have . and .. and empty_dir
713        let root_nid = img.sb.root_nid.get() as u64;
714        validate_directory_entries(&img, root_nid, &[".", "..", "empty_dir"]);
715
716        // Find empty_dir entry
717        let root_inode = img.root();
718        let mut empty_dir_nid = None;
719        if let Some(inline) = root_inode.inline() {
720            let inline_block = DirectoryBlock::ref_from_bytes(inline).unwrap();
721            for entry in inline_block.entries() {
722                if entry.name == b"empty_dir" {
723                    empty_dir_nid = Some(entry.nid());
724                    break;
725                }
726            }
727        }
728        for blkid in root_inode.blocks(img.blkszbits) {
729            let block = img.directory_block(blkid);
730            for entry in block.entries() {
731                if entry.name == b"empty_dir" {
732                    empty_dir_nid = Some(entry.nid());
733                    break;
734                }
735            }
736        }
737
738        let empty_dir_nid = empty_dir_nid.expect("empty_dir not found");
739        validate_directory_entries(&img, empty_dir_nid, &[".", ".."]);
740    }
741
742    #[test]
743    fn test_directory_with_inline_entries() {
744        // Create filesystem with directory that has a few entries (should be inline)
745        let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
746/dir1 4096 40755 2 0 0 0 1000.0 - - -
747/dir1/file1 5 100644 1 0 0 0 1000.0 - hello -
748/dir1/file2 5 100644 1 0 0 0 1000.0 - world -
749"#;
750
751        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile).unwrap();
752        let image = mkfs_erofs(&fs);
753        let img = Image::open(&image);
754
755        // Find dir1
756        let root_inode = img.root();
757        let mut dir1_nid = None;
758        if let Some(inline) = root_inode.inline() {
759            let inline_block = DirectoryBlock::ref_from_bytes(inline).unwrap();
760            for entry in inline_block.entries() {
761                if entry.name == b"dir1" {
762                    dir1_nid = Some(entry.nid());
763                    break;
764                }
765            }
766        }
767        for blkid in root_inode.blocks(img.blkszbits) {
768            let block = img.directory_block(blkid);
769            for entry in block.entries() {
770                if entry.name == b"dir1" {
771                    dir1_nid = Some(entry.nid());
772                    break;
773                }
774            }
775        }
776
777        let dir1_nid = dir1_nid.expect("dir1 not found");
778        validate_directory_entries(&img, dir1_nid, &[".", "..", "file1", "file2"]);
779    }
780
781    #[test]
782    fn test_directory_with_many_entries() {
783        // Create a directory with many entries to force block storage
784        let mut dumpfile = String::from("/ 4096 40755 2 0 0 0 1000.0 - - -\n");
785        dumpfile.push_str("/bigdir 4096 40755 2 0 0 0 1000.0 - - -\n");
786
787        // Add many files to force directory blocks
788        for i in 0..100 {
789            dumpfile.push_str(&format!(
790                "/bigdir/file{i:03} 5 100644 1 0 0 0 1000.0 - hello -\n"
791            ));
792        }
793
794        let fs = dumpfile_to_filesystem::<Sha256HashValue>(&dumpfile).unwrap();
795        let image = mkfs_erofs(&fs);
796        let img = Image::open(&image);
797
798        // Find bigdir
799        let root_inode = img.root();
800        let mut bigdir_nid = None;
801        if let Some(inline) = root_inode.inline() {
802            let inline_block = DirectoryBlock::ref_from_bytes(inline).unwrap();
803            for entry in inline_block.entries() {
804                if entry.name == b"bigdir" {
805                    bigdir_nid = Some(entry.nid());
806                    break;
807                }
808            }
809        }
810        for blkid in root_inode.blocks(img.blkszbits) {
811            let block = img.directory_block(blkid);
812            for entry in block.entries() {
813                if entry.name == b"bigdir" {
814                    bigdir_nid = Some(entry.nid());
815                    break;
816                }
817            }
818        }
819
820        let bigdir_nid = bigdir_nid.expect("bigdir not found");
821
822        // Build expected names
823        let mut expected: Vec<String> = vec![".".to_string(), "..".to_string()];
824        for i in 0..100 {
825            expected.push(format!("file{i:03}"));
826        }
827        let expected_refs: Vec<&str> = expected.iter().map(|s| s.as_str()).collect();
828
829        validate_directory_entries(&img, bigdir_nid, &expected_refs);
830    }
831
832    #[test]
833    fn test_nested_directories() {
834        // Test deeply nested directory structure
835        let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
836/a 4096 40755 2 0 0 0 1000.0 - - -
837/a/b 4096 40755 2 0 0 0 1000.0 - - -
838/a/b/c 4096 40755 2 0 0 0 1000.0 - - -
839/a/b/c/file.txt 5 100644 1 0 0 0 1000.0 - hello -
840"#;
841
842        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile).unwrap();
843        let image = mkfs_erofs(&fs);
844        let img = Image::open(&image);
845
846        // Navigate through the structure
847        let root_nid = img.sb.root_nid.get() as u64;
848        validate_directory_entries(&img, root_nid, &[".", "..", "a"]);
849
850        // Helper to find a directory entry by name
851        let find_entry = |parent_nid: u64, name: &[u8]| -> u64 {
852            let inode = img.inode(parent_nid);
853
854            if let Some(inline) = inode.inline() {
855                let inline_block = DirectoryBlock::ref_from_bytes(inline).unwrap();
856                for entry in inline_block.entries() {
857                    if entry.name == name {
858                        return entry.nid();
859                    }
860                }
861            }
862
863            for blkid in inode.blocks(img.blkszbits) {
864                let block = img.directory_block(blkid);
865                for entry in block.entries() {
866                    if entry.name == name {
867                        return entry.nid();
868                    }
869                }
870            }
871            panic!("Entry not found: {:?}", std::str::from_utf8(name));
872        };
873
874        let a_nid = find_entry(root_nid, b"a");
875        validate_directory_entries(&img, a_nid, &[".", "..", "b"]);
876
877        let b_nid = find_entry(a_nid, b"b");
878        validate_directory_entries(&img, b_nid, &[".", "..", "c"]);
879
880        let c_nid = find_entry(b_nid, b"c");
881        validate_directory_entries(&img, c_nid, &[".", "..", "file.txt"]);
882    }
883
884    #[test]
885    fn test_mixed_entry_types() {
886        // Test directory with various file types
887        let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
888/mixed 4096 40755 2 0 0 0 1000.0 - - -
889/mixed/regular 10 100644 1 0 0 0 1000.0 - content123 -
890/mixed/symlink 7 120777 1 0 0 0 1000.0 /target - -
891/mixed/fifo 0 10644 1 0 0 0 1000.0 - - -
892/mixed/subdir 4096 40755 2 0 0 0 1000.0 - - -
893"#;
894
895        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile).unwrap();
896        let image = mkfs_erofs(&fs);
897        let img = Image::open(&image);
898
899        let root_inode = img.root();
900        let mut mixed_nid = None;
901        if let Some(inline) = root_inode.inline() {
902            let inline_block = DirectoryBlock::ref_from_bytes(inline).unwrap();
903            for entry in inline_block.entries() {
904                if entry.name == b"mixed" {
905                    mixed_nid = Some(entry.nid());
906                    break;
907                }
908            }
909        }
910        for blkid in root_inode.blocks(img.blkszbits) {
911            let block = img.directory_block(blkid);
912            for entry in block.entries() {
913                if entry.name == b"mixed" {
914                    mixed_nid = Some(entry.nid());
915                    break;
916                }
917            }
918        }
919
920        let mixed_nid = mixed_nid.expect("mixed not found");
921        validate_directory_entries(
922            &img,
923            mixed_nid,
924            &[".", "..", "regular", "symlink", "fifo", "subdir"],
925        );
926    }
927
928    #[test]
929    fn test_collect_objects_traversal() {
930        // Test that object collection properly traverses all directories
931        let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
932/dir1 4096 40755 2 0 0 0 1000.0 - - -
933/dir1/file1 5 100644 1 0 0 0 1000.0 - hello -
934/dir2 4096 40755 2 0 0 0 1000.0 - - -
935/dir2/subdir 4096 40755 2 0 0 0 1000.0 - - -
936/dir2/subdir/file2 5 100644 1 0 0 0 1000.0 - world -
937"#;
938
939        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile).unwrap();
940        let image = mkfs_erofs(&fs);
941
942        // This should traverse all directories without error
943        let result = collect_objects::<Sha256HashValue>(&image);
944        assert!(
945            result.is_ok(),
946            "Failed to collect objects: {:?}",
947            result.err()
948        );
949    }
950
951    #[test]
952    fn test_pr188_empty_inline_directory() -> anyhow::Result<()> {
953        // Regression test for https://github.com/containers/composefs-rs/pull/188
954        //
955        // The bug: ObjectCollector::visit_inode at lines 553-554 unconditionally does:
956        //   let tail = DirectoryBlock::ref_from_bytes(inode.inline()).unwrap();
957        //   self.visit_directory_block(tail);
958        //
959        // When inode.inline() is empty, DirectoryBlock::ref_from_bytes succeeds but then
960        // visit_directory_block calls n_entries() which panics trying to read 12 bytes
961        // from an empty slice.
962        //
963        // This test generates an erofs image using C mkcomposefs, which creates directories
964        // with empty inline sections (unlike the Rust implementation which always includes
965        // . and .. entries).
966
967        // Generate a C-generated erofs image using mkcomposefs
968        let dumpfile_content = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
969/empty_dir 4096 40755 2 0 0 0 1000.0 - - -
970"#;
971
972        // Create temporary files for dumpfile and erofs output
973        let temp_dir = tempfile::TempDir::new()?;
974        let temp_dir = temp_dir.path();
975        let dumpfile_path = temp_dir.join("pr188_test.dump");
976        let erofs_path = temp_dir.join("pr188_test.erofs");
977
978        // Write dumpfile
979        std::fs::write(&dumpfile_path, dumpfile_content).expect("Failed to write test dumpfile");
980
981        // Run mkcomposefs to generate erofs image
982        let output = std::process::Command::new("mkcomposefs")
983            .arg("--from-file")
984            .arg(&dumpfile_path)
985            .arg(&erofs_path)
986            .output()
987            .expect("Failed to run mkcomposefs - is it installed?");
988
989        assert!(
990            output.status.success(),
991            "mkcomposefs failed: {}",
992            String::from_utf8_lossy(&output.stderr)
993        );
994
995        // Read the generated erofs image
996        let image = std::fs::read(&erofs_path).expect("Failed to read generated erofs");
997
998        // The C mkcomposefs creates directories with empty inline sections.
999        let r = collect_objects::<Sha256HashValue>(&image).unwrap();
1000        assert_eq!(r.len(), 0);
1001
1002        Ok(())
1003    }
1004
1005    #[test]
1006    fn test_round_trip_basic() {
1007        // Full round-trip: dumpfile -> tree -> erofs -> read back -> validate
1008        let dumpfile = r#"/ 4096 40755 2 0 0 0 1000.0 - - -
1009/file1 5 100644 1 0 0 0 1000.0 - hello -
1010/file2 6 100644 1 0 0 0 1000.0 - world! -
1011/dir1 4096 40755 2 0 0 0 1000.0 - - -
1012/dir1/nested 8 100644 1 0 0 0 1000.0 - content1 -
1013"#;
1014
1015        let fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile).unwrap();
1016        let image = mkfs_erofs(&fs);
1017        let img = Image::open(&image);
1018
1019        // Verify root entries
1020        let root_nid = img.sb.root_nid.get() as u64;
1021        validate_directory_entries(&img, root_nid, &[".", "..", "file1", "file2", "dir1"]);
1022
1023        // Collect all entries and verify structure
1024        let mut entries_map: HashMap<Vec<u8>, u64> = HashMap::new();
1025        let root_inode = img.root();
1026
1027        if let Some(inline) = root_inode.inline() {
1028            let inline_block = DirectoryBlock::ref_from_bytes(inline).unwrap();
1029            for entry in inline_block.entries() {
1030                entries_map.insert(entry.name.to_vec(), entry.nid());
1031            }
1032        }
1033
1034        for blkid in root_inode.blocks(img.blkszbits) {
1035            let block = img.directory_block(blkid);
1036            for entry in block.entries() {
1037                entries_map.insert(entry.name.to_vec(), entry.nid());
1038            }
1039        }
1040
1041        // Verify we can read file contents
1042        let file1_nid = entries_map
1043            .get(b"file1".as_slice())
1044            .expect("file1 not found");
1045        let file1_inode = img.inode(*file1_nid);
1046        assert!(!file1_inode.mode().is_dir());
1047        assert_eq!(file1_inode.size(), 5);
1048
1049        let inline_data = file1_inode.inline();
1050        assert_eq!(inline_data, Some(b"hello".as_slice()));
1051    }
1052}