forensic_mount/
fs_hfsplus.rs1#![forbid(unsafe_code)]
2
3use crate::archive_tree::ArchiveTree;
17use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
18use std::io::{Read, Seek, SeekFrom};
19
20pub struct HfsPlusForensicFs {
22 tree: ArchiveTree,
23 data: Vec<Vec<u8>>,
25 decompress_failures: usize,
27}
28
29impl HfsPlusForensicFs {
30 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 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 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); }
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}