Skip to main content

forensic_mount/
fs_ntfs.rs

1#![forbid(unsafe_code)]
2
3//! NTFS filesystem support via the `ntfs-core` crate. Enabled with the `ntfs`
4//! feature flag.
5//!
6//! NTFS already numbers every file by its `$MFT` record, so those record
7//! numbers serve directly as inodes (root = record 5, the `.` directory, per
8//! the NTFS on-disk layout). The directory tree is walked once at open from the
9//! root: each entry's record is read to classify it (a directory carries an
10//! `$INDEX_ROOT`, so `directory_entries` succeeds; a file does not), and the
11//! full path is cached so file reads can go through `ntfs-core`'s path-based
12//! `read_file` (which transparently handles fragmented/compressed `$DATA`).
13//!
14//! DOS (8.3) short-name index entries are dropped so each file appears once
15//! under its long name.
16
17use 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
25/// Root inode: `$MFT` record 5 is the root directory in NTFS.
26const ROOT_INO: u64 = 5;
27
28/// Upper bound on tree nodes built at open, so a hostile/huge MFT cannot make
29/// the walk run away. Real volumes have far fewer reachable entries than this.
30const MAX_NODES: usize = 5_000_000;
31
32/// One node in the cached directory tree.
33struct 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    /// Root-relative `/`-joined path, used for `ntfs-core`'s path-based reads.
42    path: String,
43    children: Vec<u64>,
44}
45
46/// `ForensicFs` implementation for NTFS volumes.
47pub struct NtfsForensicFs<R: Read + Seek> {
48    fs: NtfsFs<R>,
49    nodes: HashMap<u64, NtfsNode>,
50    /// (parent inode, child name) -> child inode, for `lookup`.
51    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    /// Open an NTFS volume and walk its directory tree.
66    ///
67    /// # Errors
68    ///
69    /// [`FsError::Corrupt`] if the boot sector or `$MFT` cannot be parsed.
70    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        // Iterative DFS from the root directory. `visited` guards against
92        // revisiting a directory (cycles / hardlinked dirs); files reachable via
93        // multiple parents share one inode (the MFT record number) by design.
94        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; // not a directory after all
107            };
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                // Drop DOS 8.3 short names so each file appears once.
113                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                // Classify by reading the child's record: a directory carries an
125                // $INDEX_ROOT, so directory_entries succeeds.
126                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                // Link under the parent once (a record can be index-listed twice).
151                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    /// Extract the real NTFS volume `partition.dd` from the committed
265    /// `SampleTinyNtfsVolume.zip` in the sibling `ntfs-forensic` repo (a real
266    /// Windows-authored NTFS volume — the ground truth below comes from TSK
267    /// `fls`/`icat`). `None` if the corpus or `unzip` is unavailable.
268    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        // TSK fls: root holds file1.txt .. file8.txt and $RECYCLE.BIN.
296        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        // TSK fls: file1.txt is MFT record 37.
315        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        // TSK `icat partition.dd 37` begins with this resident text.
325        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}