Skip to main content

forensic_mount/
fs_hfsplus.rs

1#![forbid(unsafe_code)]
2
3//! HFS+ / HFSX filesystem support via the `hfsplus-forensic` crate. Enabled
4//! with the `hfsplus` feature flag.
5//!
6//! `hfsplus-forensic` operates on a fully-buffered volume image, so the source
7//! is read into memory at open. The catalog is walked to enumerate every entry
8//! (path, CNID, dir-or-file); each file is read once — transparently handling
9//! decmpfs (zlib/LZVN/LZFSE) decompression — into a cache so getattr can report
10//! a correct size and reads are served instantly. Entries whose data fails to
11//! decompress are skipped and counted rather than shown as empty.
12//!
13//! HFS+ entry timestamps are not exposed by the crate's listing API, so node
14//! times default to epoch-zero in this revision.
15
16use crate::archive_tree::ArchiveTree;
17use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
18use std::io::{Read, Seek, SeekFrom};
19
20/// `ForensicFs` implementation for HFS+/HFSX volumes.
21pub struct HfsPlusForensicFs {
22    tree: ArchiveTree,
23    /// File contents indexed by payload id (== position in this vector).
24    data: Vec<Vec<u8>>,
25    /// Files whose `$DATA` could not be decompressed (skipped, not shown empty).
26    decompress_failures: usize,
27}
28
29impl HfsPlusForensicFs {
30    /// Buffer and open an HFS+/HFSX volume.
31    ///
32    /// # Errors
33    ///
34    /// [`FsError::Corrupt`] if the volume header is not HFS+ or the catalog
35    /// cannot be walked.
36    pub fn new<R: Read + Seek>(mut source: R) -> Result<Self, FsError> {
37        source.seek(SeekFrom::Start(0)).map_err(FsError::Io)?;
38        let mut buf = Vec::new();
39        source.read_to_end(&mut buf).map_err(FsError::Io)?;
40
41        hfsplus_forensic::parse(&buf)
42            .ok_or_else(|| FsError::Corrupt("not an HFS+/HFSX volume".to_string()))?;
43        let entries = hfsplus_forensic::walk(&buf)
44            .ok_or_else(|| FsError::Corrupt("HFS+ catalog walk failed".to_string()))?;
45
46        let mut tree = ArchiveTree::new();
47        let mut data: Vec<Vec<u8>> = Vec::new();
48        let mut decompress_failures = 0usize;
49
50        for entry in entries {
51            // walk() yields root-relative paths; strip a leading '/' so the tree
52            // builder (which rejects absolute paths) accepts them.
53            let path = entry.path.strip_prefix('/').unwrap_or(&entry.path);
54            if path.is_empty() {
55                continue;
56            }
57            if entry.is_dir {
58                tree.insert(path, true, 0, FsTimestamp::default(), None);
59            } else {
60                match hfsplus_forensic::read_file(&buf, entry.cnid) {
61                    Some(bytes) => {
62                        let id = data.len();
63                        if tree
64                            .insert(
65                                path,
66                                false,
67                                bytes.len() as u64,
68                                FsTimestamp::default(),
69                                Some(id),
70                            )
71                            .is_some()
72                        {
73                            data.push(bytes);
74                        }
75                    }
76                    None => decompress_failures += 1,
77                }
78            }
79        }
80
81        Ok(Self {
82            tree,
83            data,
84            decompress_failures,
85        })
86    }
87}
88
89impl ForensicFs for HfsPlusForensicFs {
90    fn root_ino(&self) -> u64 {
91        self.tree.root_ino()
92    }
93
94    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
95        self.tree.read_dir(ino)
96    }
97
98    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
99        self.tree.lookup(parent_ino, name)
100    }
101
102    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
103        self.tree.metadata(ino)
104    }
105
106    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
107        let id = self
108            .tree
109            .payload_id(ino)
110            .ok_or_else(|| FsError::NotFound(format!("inode {ino} is not a file")))?;
111        self.data
112            .get(id)
113            .cloned()
114            .ok_or_else(|| FsError::NotFound(format!("payload {id}")))
115    }
116
117    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
118        let data = self.read_file(ino)?;
119        let start = (offset as usize).min(data.len());
120        let end = start.saturating_add(len as usize).min(data.len());
121        Ok(data[start..end].to_vec())
122    }
123
124    fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
125        Err(not_supported("HFS+ symlinks not resolved"))
126    }
127
128    fn fs_info(&self) -> FsResult<serde_json::Value> {
129        Ok(serde_json::json!({
130            "type": "hfsplus",
131            "entries": self.data.len(),
132            "decompress_failures": self.decompress_failures,
133        }))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::io::Cursor;
141
142    /// The committed 512 KiB HFS+ volume minted on macOS (`newfs_hfs` via
143    /// hdiutil); TSK `fls`/`icat` ground truth: `hello.txt` (cnid 18) and a
144    /// `sub/` directory holding `deep.txt`.
145    const IMG: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/hfsplus.img");
146
147    fn open() -> Option<HfsPlusForensicFs> {
148        let data = std::fs::read(IMG).ok()?;
149        HfsPlusForensicFs::new(Cursor::new(data)).ok()
150    }
151
152    #[test]
153    fn root_ino_is_2() {
154        let Some(fs) = open() else {
155            eprintln!("skip: hfsplus.img unavailable");
156            return;
157        };
158        assert_eq!(fs.root_ino(), 2);
159    }
160
161    #[test]
162    fn root_lists_hello_and_sub() {
163        let Some(mut fs) = open() else {
164            eprintln!("skip");
165            return;
166        };
167        let names: Vec<String> = fs
168            .read_dir(2)
169            .unwrap()
170            .iter()
171            .map(FsDirEntry::name_str)
172            .collect();
173        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
174        assert!(names.contains(&"sub".to_string()), "got {names:?}");
175    }
176
177    #[test]
178    fn read_hello_matches_icat() {
179        let Some(mut fs) = open() else {
180            eprintln!("skip");
181            return;
182        };
183        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
184        assert_eq!(fs.read_file(ino).unwrap(), b"hello from hfsplus\n");
185    }
186
187    #[test]
188    fn nested_file_reachable() {
189        let Some(mut fs) = open() else {
190            eprintln!("skip");
191            return;
192        };
193        let sub = fs.lookup(2, b"sub").unwrap().unwrap();
194        let deep = fs.lookup(sub, b"deep.txt").unwrap().unwrap();
195        assert_eq!(fs.read_file(deep).unwrap(), b"deep hfs content\n");
196    }
197
198    #[test]
199    fn metadata_size_matches() {
200        let Some(mut fs) = open() else {
201            eprintln!("skip");
202            return;
203        };
204        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
205        assert_eq!(fs.metadata(ino).unwrap().size, 19); // "hello from hfsplus\n"
206    }
207
208    #[test]
209    fn fs_info_reports_hfsplus() {
210        let Some(fs) = open() else {
211            eprintln!("skip");
212            return;
213        };
214        assert_eq!(fs.fs_info().unwrap()["type"], "hfsplus");
215    }
216}