1#![forbid(unsafe_code)]
2
3use crate::{
18 not_supported, ForensicFs, FsDirEntry, FsError, FsFileType, FsMetadata, FsResult, FsTimestamp,
19};
20use ntfs_core::fs::NtfsFs;
21use ntfs_core::time::Filetime;
22use std::collections::{HashMap, HashSet};
23use std::io::{Read, Seek};
24
25const ROOT_INO: u64 = 5;
27
28const MAX_NODES: usize = 5_000_000;
31
32struct NtfsNode {
34 name: Vec<u8>,
35 is_dir: bool,
36 size: u64,
37 atime: FsTimestamp,
38 mtime: FsTimestamp,
39 ctime: FsTimestamp,
40 crtime: FsTimestamp,
41 path: String,
43 children: Vec<u64>,
44}
45
46pub struct NtfsForensicFs<R: Read + Seek> {
48 fs: NtfsFs<R>,
49 nodes: HashMap<u64, NtfsNode>,
50 index: HashMap<(u64, Vec<u8>), u64>,
52}
53
54fn ts(ft: Filetime) -> FsTimestamp {
55 if ft.is_zero() {
56 return FsTimestamp::default();
57 }
58 FsTimestamp {
59 seconds: ft.to_unix_seconds(),
60 nanoseconds: (ft.to_unix_nanos().rem_euclid(1_000_000_000)) as u32,
61 }
62}
63
64impl<R: Read + Seek> NtfsForensicFs<R> {
65 pub fn new(source: R) -> Result<Self, FsError> {
71 let mut fs =
72 NtfsFs::open(source).map_err(|e| FsError::Corrupt(format!("not NTFS: {e}")))?;
73
74 let mut nodes: HashMap<u64, NtfsNode> = HashMap::new();
75 nodes.insert(
76 ROOT_INO,
77 NtfsNode {
78 name: b"/".to_vec(),
79 is_dir: true,
80 size: 0,
81 atime: FsTimestamp::default(),
82 mtime: FsTimestamp::default(),
83 ctime: FsTimestamp::default(),
84 crtime: FsTimestamp::default(),
85 path: String::new(),
86 children: vec![],
87 },
88 );
89 let mut index: HashMap<(u64, Vec<u8>), u64> = HashMap::new();
90
91 let mut visited: HashSet<u64> = HashSet::new();
95 visited.insert(ROOT_INO);
96 let mut stack = vec![ROOT_INO];
97
98 while let Some(rec) = stack.pop() {
99 if nodes.len() >= MAX_NODES {
100 break;
101 }
102 let Ok(record) = fs.read_record(rec) else {
103 continue;
104 };
105 let Ok(entries) = fs.directory_entries(&record) else {
106 continue; };
108 let parent_path = nodes.get(&rec).map(|n| n.path.clone()).unwrap_or_default();
109
110 for entry in entries {
111 let Some(fnm) = entry.file_name else { continue };
112 if fnm.is_dos_namespace() {
114 continue;
115 }
116 if fnm.name == "." || fnm.name == ".." {
117 continue;
118 }
119 let child = entry.file_reference.record_number;
120 if child == rec {
121 continue;
122 }
123
124 let Ok(child_record) = fs.read_record(child) else {
127 continue;
128 };
129 let is_dir = fs.directory_entries(&child_record).is_ok();
130
131 let name_bytes = fnm.name.clone().into_bytes();
132 let path = if parent_path.is_empty() {
133 fnm.name.clone()
134 } else {
135 format!("{parent_path}/{}", fnm.name)
136 };
137
138 nodes.entry(child).or_insert_with(|| NtfsNode {
139 name: name_bytes.clone(),
140 is_dir,
141 size: fnm.real_size,
142 atime: ts(fnm.accessed),
143 mtime: ts(fnm.modified),
144 ctime: ts(fnm.mft_modified),
145 crtime: ts(fnm.created),
146 path,
147 children: vec![],
148 });
149
150 if let std::collections::hash_map::Entry::Vacant(slot) =
152 index.entry((rec, name_bytes))
153 {
154 slot.insert(child);
155 if let Some(p) = nodes.get_mut(&rec) {
156 p.children.push(child);
157 }
158 }
159
160 if is_dir && visited.insert(child) {
161 stack.push(child);
162 }
163 }
164 }
165
166 Ok(Self { fs, nodes, index })
167 }
168
169 fn node(&self, ino: u64) -> FsResult<&NtfsNode> {
170 self.nodes
171 .get(&ino)
172 .ok_or_else(|| FsError::NotFound(format!("inode {ino}")))
173 }
174}
175
176impl<R: Read + Seek> ForensicFs for NtfsForensicFs<R> {
177 fn root_ino(&self) -> u64 {
178 ROOT_INO
179 }
180
181 fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
182 let node = self.node(ino)?;
183 let mut out = Vec::with_capacity(node.children.len());
184 for &child in &node.children {
185 if let Some(c) = self.nodes.get(&child) {
186 out.push(FsDirEntry {
187 inode: child,
188 name: c.name.clone(),
189 file_type: if c.is_dir {
190 FsFileType::Directory
191 } else {
192 FsFileType::RegularFile
193 },
194 });
195 }
196 }
197 Ok(out)
198 }
199
200 fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
201 self.node(parent_ino)?;
202 Ok(self.index.get(&(parent_ino, name.to_vec())).copied())
203 }
204
205 fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
206 let node = self.node(ino)?;
207 let (file_type, mode) = if node.is_dir {
208 (FsFileType::Directory, 0o40555)
209 } else {
210 (FsFileType::RegularFile, 0o100_444)
211 };
212 Ok(FsMetadata {
213 ino,
214 file_type,
215 mode,
216 uid: 0,
217 gid: 0,
218 size: node.size,
219 links_count: 1,
220 atime: node.atime,
221 mtime: node.mtime,
222 ctime: node.ctime,
223 crtime: node.crtime,
224 allocated: true,
225 })
226 }
227
228 fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
229 let node = self.node(ino)?;
230 if node.is_dir {
231 return Err(not_supported("read_file on a directory"));
232 }
233 let path = node.path.clone();
234 self.fs
235 .read_file(&path)
236 .map_err(|e| FsError::Io(std::io::Error::other(e.to_string())))
237 }
238
239 fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
240 let data = self.read_file(ino)?;
241 let start = (offset as usize).min(data.len());
242 let end = start.saturating_add(len as usize).min(data.len());
243 Ok(data[start..end].to_vec())
244 }
245
246 fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
247 Err(not_supported("NTFS reparse points not resolved"))
248 }
249
250 fn fs_info(&self) -> FsResult<serde_json::Value> {
251 Ok(serde_json::json!({
252 "type": "ntfs",
253 "entries": self.nodes.len(),
254 "cluster_size": self.fs.boot().cluster_size(),
255 }))
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use std::io::Cursor;
263
264 fn load_ntfs() -> Option<Vec<u8>> {
269 let zip = "/Users/4n6h4x0r/src/ntfs-forensic/tests/data/SampleTinyNtfsVolume.zip";
270 let out = std::process::Command::new("unzip")
271 .args(["-p", zip, "SampleTinyNtfsVolume/partition.dd"])
272 .output()
273 .ok()?;
274 if !out.status.success() || out.stdout.is_empty() {
275 return None;
276 }
277 Some(out.stdout)
278 }
279
280 fn open() -> Option<NtfsForensicFs<Cursor<Vec<u8>>>> {
281 NtfsForensicFs::new(Cursor::new(load_ntfs()?)).ok()
282 }
283
284 #[test]
285 fn root_ino_is_5() {
286 let Some(fs) = open() else {
287 eprintln!("skip: ntfs corpus unavailable");
288 return;
289 };
290 assert_eq!(fs.root_ino(), 5);
291 }
292
293 #[test]
294 fn root_lists_user_files() {
295 let Some(mut fs) = open() else {
297 eprintln!("skip");
298 return;
299 };
300 let names: Vec<String> = fs
301 .read_dir(5)
302 .unwrap()
303 .iter()
304 .map(FsDirEntry::name_str)
305 .collect();
306 for f in ["file1.txt", "file2.txt", "file8.txt"] {
307 assert!(names.contains(&f.to_string()), "missing {f}, got {names:?}");
308 }
309 assert!(names.contains(&"$RECYCLE.BIN".to_string()), "got {names:?}");
310 }
311
312 #[test]
313 fn file1_resolves_to_record_37() {
314 let Some(mut fs) = open() else {
316 eprintln!("skip");
317 return;
318 };
319 assert_eq!(fs.lookup(5, b"file1.txt").unwrap(), Some(37));
320 }
321
322 #[test]
323 fn file1_content_matches_icat() {
324 let Some(mut fs) = open() else {
326 eprintln!("skip");
327 return;
328 };
329 let ino = fs.lookup(5, b"file1.txt").unwrap().unwrap();
330 let data = fs.read_file(ino).unwrap();
331 assert!(
332 data.starts_with(b"Just some bogus text to be kept resident in $MFT."),
333 "got: {:?}",
334 String::from_utf8_lossy(&data[..data.len().min(60)])
335 );
336 }
337
338 #[test]
339 fn file1_is_regular_recycle_is_dir() {
340 let Some(mut fs) = open() else {
341 eprintln!("skip");
342 return;
343 };
344 let f = fs.lookup(5, b"file1.txt").unwrap().unwrap();
345 assert_eq!(fs.metadata(f).unwrap().file_type, FsFileType::RegularFile);
346 let r = fs.lookup(5, b"$RECYCLE.BIN").unwrap().unwrap();
347 assert_eq!(fs.metadata(r).unwrap().file_type, FsFileType::Directory);
348 }
349
350 #[test]
351 fn fs_info_reports_ntfs() {
352 let Some(fs) = open() else {
353 eprintln!("skip");
354 return;
355 };
356 assert_eq!(fs.fs_info().unwrap()["type"], "ntfs");
357 }
358}