Skip to main content

forensic_mount/
fs_ad1.rs

1#![forbid(unsafe_code)]
2//! AD1 (`AccessData` logical image) lazy mount — `ad1` feature.
3//!
4//! Entries are enumerated at open into a synthetic inode tree
5//! ([`crate::archive_tree::ArchiveTree`]); file bytes are read lazily via
6//! [`ad1::Ad1Reader::read_at`] on FUSE access, so a large (multi-GiB) image is
7//! browsed without full extraction. Read-only; encrypted (ADCRYPT) images are
8//! refused at open with a clear error (ciphertext cannot be mounted).
9
10use std::path::Path;
11
12use ad1::Ad1Reader;
13
14use crate::archive_tree::ArchiveTree;
15use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
16
17/// A read-only, lazily-decompressing view over an AD1 logical image.
18pub struct Ad1ForensicFs {
19    reader: Ad1Reader,
20    tree: ArchiveTree,
21}
22
23impl Ad1ForensicFs {
24    /// Open an AD1 image by path (discovers sibling `.ad2…` segments alongside
25    /// it). Returns a `NotSupported` error for encrypted (ADCRYPT) images.
26    pub fn open(path: &Path) -> Result<Self, FsError> {
27        let reader = Ad1Reader::open(path).map_err(map_err)?;
28        let mut tree = ArchiveTree::new();
29        for (idx, e) in reader.entries().iter().enumerate() {
30            // AD1 timestamps are display strings ("YYYYMMDDThhmmss"); browsing
31            // doesn't depend on them, so v1 surfaces the epoch rather than
32            // parsing. The synthetic tree carries the path, type, and size.
33            tree.insert(
34                &e.path,
35                e.is_dir,
36                e.size,
37                FsTimestamp {
38                    seconds: 0,
39                    nanoseconds: 0,
40                },
41                if e.is_dir { None } else { Some(idx) },
42            );
43        }
44        Ok(Self { reader, tree })
45    }
46
47    /// Read `len` bytes at `offset` from the file at `ino`, inflating only the
48    /// overlapping zlib chunks. `read_at` may short-read, so loop until filled.
49    fn read_range(&self, ino: u64, offset: u64, len: usize) -> FsResult<Vec<u8>> {
50        let idx = self
51            .tree
52            .payload_id(ino)
53            .ok_or_else(|| FsError::NotFound(format!("inode {ino} is not a readable file")))?;
54        // `read_at` takes `&self`; clone the small entry so the tree's `&mut
55        // self` method signatures stay intact.
56        let entry = self.reader.entries()[idx].clone();
57        let want = (entry.size.saturating_sub(offset) as usize).min(len);
58        let mut buf = vec![0u8; want];
59        let mut filled = 0;
60        while filled < want {
61            let n = self
62                .reader
63                .read_at(&entry, offset + filled as u64, &mut buf[filled..])
64                .map_err(map_err)?;
65            if n == 0 {
66                break;
67            }
68            filled += n;
69        }
70        buf.truncate(filled);
71        Ok(buf)
72    }
73}
74
75impl ForensicFs for Ad1ForensicFs {
76    fn root_ino(&self) -> u64 {
77        self.tree.root_ino()
78    }
79    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
80        self.tree.read_dir(ino)
81    }
82    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
83        self.tree.lookup(parent_ino, name)
84    }
85    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
86        self.tree.metadata(ino)
87    }
88    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
89        let size = self.metadata(ino)?.size;
90        self.read_range(ino, 0, size as usize)
91    }
92    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
93        self.read_range(ino, offset, len as usize)
94    }
95    fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
96        Err(not_supported("ad1: symlinks are not surfaced"))
97    }
98    fn fs_info(&self) -> FsResult<serde_json::Value> {
99        Ok(serde_json::json!({ "type": "ad1", "entries": self.reader.entries().len() }))
100    }
101}
102
103/// Map an `ad1::Ad1Error` onto 4n6mount's `FsError`.
104fn map_err(e: ad1::Ad1Error) -> FsError {
105    match e {
106        ad1::Ad1Error::Io(io) => FsError::Io(io),
107        // ADCRYPT (encrypted) and other unsupported features.
108        ad1::Ad1Error::Unsupported(m) => FsError::NotSupported(format!("ad1: {m}")),
109        ad1::Ad1Error::NotAd1(m) | ad1::Ad1Error::Malformed(m) => {
110            FsError::Corrupt(format!("ad1: {m}"))
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use ad1::testfix;
119
120    struct Fixture {
121        _dir: tempfile::TempDir,
122        path: std::path::PathBuf,
123        built: testfix::Built,
124    }
125
126    fn write_fixture() -> Fixture {
127        let built = testfix::build(testfix::sample_tree());
128        let dir = tempfile::tempdir().unwrap();
129        let path = dir.path().join("img.ad1");
130        std::fs::write(&path, &built.bytes).unwrap();
131        Fixture {
132            _dir: dir,
133            path,
134            built,
135        }
136    }
137
138    /// Walk `lookup` from the root to resolve a POSIX '/'-separated path.
139    fn resolve(fs: &mut Ad1ForensicFs, path: &str) -> Option<u64> {
140        let mut ino = fs.root_ino();
141        for comp in path.split('/').filter(|c| !c.is_empty()) {
142            ino = fs.lookup(ino, comp.as_bytes()).ok().flatten()?;
143        }
144        Some(ino)
145    }
146
147    #[test]
148    fn root_lists_entries() {
149        let fx = write_fixture();
150        let mut fs = Ad1ForensicFs::open(&fx.path).unwrap();
151        let root = fs.root_ino();
152        assert!(!fs.read_dir(root).unwrap().is_empty());
153    }
154
155    #[test]
156    fn files_read_back_byte_identical() {
157        let fx = write_fixture();
158        let mut fs = Ad1ForensicFs::open(&fx.path).unwrap();
159        let mut checked = 0;
160        for e in &fx.built.expected {
161            if e.is_dir {
162                continue;
163            }
164            let Some(data) = &e.data else { continue };
165            let ino =
166                resolve(&mut fs, &e.path).unwrap_or_else(|| panic!("path not found: {}", e.path));
167            assert_eq!(fs.metadata(ino).unwrap().size, e.size, "size of {}", e.path);
168            assert_eq!(&fs.read_file(ino).unwrap(), data, "content of {}", e.path);
169            checked += 1;
170        }
171        assert!(checked >= 1, "fixture should contain at least one file");
172    }
173
174    #[test]
175    fn range_read_across_chunk_boundary() {
176        let fx = write_fixture();
177        let mut fs = Ad1ForensicFs::open(&fx.path).unwrap();
178        // The largest file spans multiple zlib chunks; read a window in its
179        // middle and compare against testfix's independent ground truth.
180        let big = fx
181            .built
182            .expected
183            .iter()
184            .filter(|e| !e.is_dir && e.data.is_some())
185            .max_by_key(|e| e.size)
186            .expect("fixture should contain a file with data");
187        let data = big.data.as_ref().unwrap();
188        let ino = resolve(&mut fs, &big.path).unwrap();
189        let off = (data.len() / 2) as u64;
190        let len = 4096u64.min(data.len() as u64 - off);
191        let got = fs.read_file_range(ino, off, len).unwrap();
192        assert_eq!(
193            got,
194            &data[off as usize..(off + len) as usize],
195            "mid-file range read of {}",
196            big.path
197        );
198    }
199
200    #[test]
201    fn fs_info_reports_ad1() {
202        let fx = write_fixture();
203        let fs = Ad1ForensicFs::open(&fx.path).unwrap();
204        assert_eq!(fs.fs_info().unwrap()["type"], "ad1");
205    }
206}