Skip to main content

forensic_mount/
fs_exfat.rs

1#![forbid(unsafe_code)]
2
3//! exFAT filesystem support via the `exfat-fs` crate (MIT). Enabled with the
4//! `exfat` feature flag.
5//!
6//! `exfat-fs` reads through its own positioned-read trait, so the source is
7//! wrapped in a small `Mutex` adapter (the crate shares it internally via an
8//! `Arc`). The directory tree is walked once at open and synthetic inodes are
9//! assigned by [`ArchiveTree`]; each file is read into a cache so getattr
10//! reports a correct size and reads are served instantly.
11//!
12//! exFAT is browsed read-only here: no deleted-entry recovery, journal, or
13//! overlay. Entry timestamps are not surfaced in this revision (node times
14//! default to epoch-zero).
15
16use crate::archive_tree::ArchiveTree;
17use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
18use exfat_fs::dir::entry::fs::FsElement;
19use exfat_fs::dir::Root;
20use exfat_fs::disk::{PartitionError, ReadOffset};
21use std::io::{Read, Seek, SeekFrom};
22use std::sync::Mutex;
23
24/// Bound on entries walked at open, guarding against a hostile directory loop.
25const MAX_NODES: usize = 5_000_000;
26
27/// Error type the `exfat-fs` `ReadOffset`/`PartitionError` contract requires.
28#[derive(Debug)]
29enum ExfatErr {
30    UnexpectedEop,
31    ClusterNotFound(u32),
32    Io(String),
33}
34
35impl PartitionError for ExfatErr {
36    fn unexpected_eop() -> Self {
37        ExfatErr::UnexpectedEop
38    }
39    fn cluster_not_found(cluster: u32) -> Self {
40        ExfatErr::ClusterNotFound(cluster)
41    }
42}
43
44impl std::fmt::Display for ExfatErr {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            ExfatErr::UnexpectedEop => f.write_str("unexpected end of partition"),
48            ExfatErr::ClusterNotFound(c) => write!(f, "cluster {c} not found"),
49            ExfatErr::Io(msg) => write!(f, "I/O error: {msg}"),
50        }
51    }
52}
53
54impl From<ExfatErr> for std::io::Error {
55    fn from(e: ExfatErr) -> Self {
56        std::io::Error::other(e.to_string())
57    }
58}
59
60/// Positioned-read adapter over a `Read + Seek` source. The crate wraps this in
61/// an `Arc`, so `read_at` takes `&self` and serializes access with a `Mutex`.
62struct OffsetReader<R> {
63    inner: Mutex<R>,
64}
65
66impl<R> std::fmt::Debug for OffsetReader<R> {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.write_str("OffsetReader")
69    }
70}
71
72impl<R: Read + Seek> ReadOffset for OffsetReader<R> {
73    type Err = ExfatErr;
74
75    fn read_at(&self, offset: u64, buffer: &mut [u8]) -> Result<usize, ExfatErr> {
76        let mut guard = self
77            .inner
78            .lock()
79            .map_err(|_| ExfatErr::Io("reader mutex poisoned".to_string()))?;
80        guard
81            .seek(SeekFrom::Start(offset))
82            .map_err(|e| ExfatErr::Io(e.to_string()))?;
83        guard.read(buffer).map_err(|e| ExfatErr::Io(e.to_string()))
84    }
85}
86
87/// `ForensicFs` implementation for exFAT volumes.
88pub struct ExFatForensicFs {
89    tree: ArchiveTree,
90    /// File contents indexed by payload id (== position in this vector).
91    data: Vec<Vec<u8>>,
92}
93
94fn join(prefix: &str, name: &str) -> String {
95    if prefix.is_empty() {
96        name.to_string()
97    } else {
98        format!("{prefix}/{name}")
99    }
100}
101
102impl ExFatForensicFs {
103    /// Open an exFAT volume and walk its directory tree, caching file contents.
104    ///
105    /// # Errors
106    ///
107    /// [`FsError::Corrupt`] if the boot sector / directory structure is invalid.
108    pub fn new<R: Read + Seek>(source: R) -> Result<Self, FsError> {
109        let device = OffsetReader {
110            inner: Mutex::new(source),
111        };
112        let mut root =
113            Root::open(device).map_err(|e| FsError::Corrupt(format!("not exFAT: {e:?}")))?;
114
115        let mut tree = ArchiveTree::new();
116        let mut data: Vec<Vec<u8>> = Vec::new();
117        let mut stack: Vec<(Vec<FsElement<OffsetReader<R>>>, String)> = Vec::new();
118
119        for element in root.items() {
120            ingest(element, "", &mut tree, &mut data, &mut stack)?;
121        }
122        while let Some((mut children, prefix)) = stack.pop() {
123            if tree.len() >= MAX_NODES {
124                break;
125            }
126            for element in &mut children {
127                ingest(element, &prefix, &mut tree, &mut data, &mut stack)?;
128            }
129        }
130
131        Ok(Self { tree, data })
132    }
133}
134
135/// Insert one filesystem element into the tree, reading file bytes into `data`
136/// and queueing subdirectories onto `stack` for later traversal.
137fn ingest<R: Read + Seek>(
138    element: &mut FsElement<OffsetReader<R>>,
139    prefix: &str,
140    tree: &mut ArchiveTree,
141    data: &mut Vec<Vec<u8>>,
142    stack: &mut Vec<(Vec<FsElement<OffsetReader<R>>>, String)>,
143) -> Result<(), FsError> {
144    match element {
145        FsElement::F(file) => {
146            let path = join(prefix, file.name());
147            let mut buf = Vec::new();
148            file.read_to_end(&mut buf).map_err(FsError::Io)?;
149            let id = data.len();
150            if tree
151                .insert(
152                    &path,
153                    false,
154                    buf.len() as u64,
155                    FsTimestamp::default(),
156                    Some(id),
157                )
158                .is_some()
159            {
160                data.push(buf);
161            }
162        }
163        FsElement::D(dir) => {
164            let path = join(prefix, dir.name());
165            tree.insert(&path, true, 0, FsTimestamp::default(), None);
166            let children = dir
167                .open()
168                .map_err(|e| FsError::Corrupt(format!("exFAT dir: {e:?}")))?;
169            stack.push((children, path));
170        }
171    }
172    Ok(())
173}
174
175impl ForensicFs for ExFatForensicFs {
176    fn root_ino(&self) -> u64 {
177        self.tree.root_ino()
178    }
179
180    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
181        self.tree.read_dir(ino)
182    }
183
184    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
185        self.tree.lookup(parent_ino, name)
186    }
187
188    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
189        self.tree.metadata(ino)
190    }
191
192    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
193        let id = self
194            .tree
195            .payload_id(ino)
196            .ok_or_else(|| FsError::NotFound(format!("inode {ino} is not a file")))?;
197        self.data
198            .get(id)
199            .cloned()
200            .ok_or_else(|| FsError::NotFound(format!("payload {id}")))
201    }
202
203    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
204        let data = self.read_file(ino)?;
205        let start = (offset as usize).min(data.len());
206        let end = start.saturating_add(len as usize).min(data.len());
207        Ok(data[start..end].to_vec())
208    }
209
210    fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
211        Err(not_supported("exFAT has no symlinks"))
212    }
213
214    fn fs_info(&self) -> FsResult<serde_json::Value> {
215        Ok(serde_json::json!({
216            "type": "exfat",
217            "entries": self.data.len(),
218        }))
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::io::Cursor;
226
227    /// The committed 1 MiB exFAT volume minted on macOS (`newfs_exfat` via
228    /// hdiutil); TSK `fls`/`icat` ground truth: `hello.txt` and `sub/deep.txt`.
229    const IMG: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/exfat.img");
230
231    fn open() -> Option<ExFatForensicFs> {
232        let data = std::fs::read(IMG).ok()?;
233        ExFatForensicFs::new(Cursor::new(data)).ok()
234    }
235
236    #[test]
237    fn root_ino_is_2() {
238        let Some(fs) = open() else {
239            eprintln!("skip: exfat.img unavailable");
240            return;
241        };
242        assert_eq!(fs.root_ino(), 2);
243    }
244
245    #[test]
246    fn root_lists_hello_and_sub() {
247        let Some(mut fs) = open() else {
248            eprintln!("skip");
249            return;
250        };
251        let names: Vec<String> = fs
252            .read_dir(2)
253            .unwrap()
254            .iter()
255            .map(FsDirEntry::name_str)
256            .collect();
257        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
258        assert!(names.contains(&"sub".to_string()), "got {names:?}");
259    }
260
261    #[test]
262    fn read_hello_matches_icat() {
263        let Some(mut fs) = open() else {
264            eprintln!("skip");
265            return;
266        };
267        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
268        assert_eq!(fs.read_file(ino).unwrap(), b"hello from exfat\n");
269    }
270
271    #[test]
272    fn nested_file_reachable() {
273        let Some(mut fs) = open() else {
274            eprintln!("skip");
275            return;
276        };
277        let sub = fs.lookup(2, b"sub").unwrap().unwrap();
278        let deep = fs.lookup(sub, b"deep.txt").unwrap().unwrap();
279        assert_eq!(fs.read_file(deep).unwrap(), b"deep exfat content\n");
280    }
281
282    #[test]
283    fn metadata_size_matches() {
284        let Some(mut fs) = open() else {
285            eprintln!("skip");
286            return;
287        };
288        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
289        assert_eq!(fs.metadata(ino).unwrap().size, 17); // "hello from exfat\n"
290    }
291
292    #[test]
293    fn fs_info_reports_exfat() {
294        let Some(fs) = open() else {
295            eprintln!("skip");
296            return;
297        };
298        assert_eq!(fs.fs_info().unwrap()["type"], "exfat");
299    }
300}