Skip to main content

forensic_mount/
fs_apfs.rs

1#![forbid(unsafe_code)]
2
3//! APFS filesystem support via the `apfs-core` crate. Enabled with the `apfs`
4//! feature flag.
5//!
6//! APFS already numbers every object by its file-system oid, so those oids serve
7//! directly as inodes (root = `ROOT_DIR_INO_NUM` = 2). On open, the container
8//! superblock is parsed, the checkpoint ring walked to the live state, and the
9//! first volume's superblock parsed; navigation is then lazy — each `read_dir`/
10//! `lookup`/`metadata`/`read_file` calls straight into apfs-core's fs-tree
11//! walkers, so nothing is materialized up front.
12//!
13//! This reads the live volume (the container's current point-in-time view).
14//! `read_data` handles transparent decmpfs decompression. APFS is mounted
15//! read-only here: no deleted-inode recovery, journal, or overlay.
16
17use crate::{
18    not_supported, ForensicFs, FsDirEntry, FsError, FsFileType, FsMetadata, FsResult, FsTimestamp,
19};
20use apfs_core::volume::ApfsVolume;
21use apfs_core::ApfsContainer;
22use std::io::{Read, Seek, SeekFrom};
23
24/// APFS root directory inode number (`ROOT_DIR_INO_NUM`).
25const ROOT_INO: u64 = 2;
26
27/// `ForensicFs` implementation for APFS volumes.
28pub struct ApfsForensicFs<R: Read + Seek> {
29    reader: R,
30    volume: ApfsVolume,
31    block_size: usize,
32}
33
34/// Map an APFS `DIR_REC` entry's flag bits (low nibble = `DT_*`) to a file type.
35fn drec_file_type(flags: u16) -> FsFileType {
36    match flags & 0x0F {
37        4 => FsFileType::Directory,
38        10 => FsFileType::Symlink,
39        1 => FsFileType::Fifo,
40        2 => FsFileType::CharDevice,
41        6 => FsFileType::BlockDevice,
42        12 => FsFileType::Socket,
43        _ => FsFileType::RegularFile,
44    }
45}
46
47/// Map a POSIX `mode` (`S_IFMT` bits) to a file type.
48fn mode_file_type(mode: u16) -> FsFileType {
49    match mode & 0o170_000 {
50        0o040_000 => FsFileType::Directory,
51        0o120_000 => FsFileType::Symlink,
52        0o010_000 => FsFileType::Fifo,
53        0o020_000 => FsFileType::CharDevice,
54        0o060_000 => FsFileType::BlockDevice,
55        0o140_000 => FsFileType::Socket,
56        _ => FsFileType::RegularFile,
57    }
58}
59
60/// Convert APFS nanoseconds-since-epoch into an `FsTimestamp`.
61fn ns_ts(ns: u64) -> FsTimestamp {
62    FsTimestamp {
63        seconds: (ns / 1_000_000_000) as i64,
64        nanoseconds: (ns % 1_000_000_000) as u32,
65    }
66}
67
68impl<R: Read + Seek> ApfsForensicFs<R> {
69    /// Open an APFS container and bind to its first volume's live view.
70    ///
71    /// # Errors
72    ///
73    /// [`FsError::Corrupt`] if the container/volume superblock is invalid or the
74    /// container exposes no volumes.
75    pub fn new(reader: R) -> Result<Self, FsError> {
76        let mut container =
77            ApfsContainer::open(reader).map_err(|e| FsError::Corrupt(format!("not APFS: {e}")))?;
78        let block_size = container.superblock().block_size as usize;
79        let addrs = container
80            .volume_superblock_addrs()
81            .map_err(|e| FsError::Corrupt(format!("APFS volume resolution failed: {e}")))?;
82        let first = *addrs
83            .first()
84            .ok_or_else(|| FsError::Corrupt("APFS container exposes no volumes".to_string()))?;
85
86        // Read the first volume superblock (APSB) at its physical block address.
87        let mut reader = container.into_reader();
88        let byte_off = first
89            .checked_mul(block_size as u64)
90            .ok_or_else(|| FsError::Corrupt("APFS volume offset overflow".to_string()))?;
91        reader
92            .seek(SeekFrom::Start(byte_off))
93            .map_err(FsError::Io)?;
94        let mut block = vec![0u8; block_size];
95        reader.read_exact(&mut block).map_err(FsError::Io)?;
96        let volume = ApfsVolume::parse(&block)
97            .map_err(|e| FsError::Corrupt(format!("APFS volume superblock: {e}")))?;
98
99        Ok(Self {
100            reader,
101            volume,
102            block_size,
103        })
104    }
105}
106
107impl<R: Read + Seek> ForensicFs for ApfsForensicFs<R> {
108    fn root_ino(&self) -> u64 {
109        ROOT_INO
110    }
111
112    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
113        let entries =
114            apfs_core::dir::list_dir(&mut self.reader, &self.volume, ino, self.block_size)
115                .map_err(|e| FsError::Io(std::io::Error::other(e.to_string())))?;
116        Ok(entries
117            .into_iter()
118            .map(|e| FsDirEntry {
119                inode: e.file_id,
120                name: e.name.into_bytes(),
121                file_type: drec_file_type(e.flags),
122            })
123            .collect())
124    }
125
126    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
127        let name = std::str::from_utf8(name)
128            .map_err(|_| FsError::NotFound("non-UTF-8 APFS name".to_string()))?;
129        apfs_core::dir::lookup_child(
130            &mut self.reader,
131            &self.volume,
132            parent_ino,
133            name,
134            self.block_size,
135        )
136        .map_err(|e| FsError::Io(std::io::Error::other(e.to_string())))
137    }
138
139    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
140        let inode =
141            apfs_core::dir::load_inode(&mut self.reader, &self.volume, ino, self.block_size)
142                .map_err(|e| FsError::NotFound(format!("inode {ino}: {e}")))?;
143        let file_type = mode_file_type(inode.mode);
144        Ok(FsMetadata {
145            ino,
146            file_type,
147            mode: inode.mode & 0o7777,
148            uid: inode.uid,
149            gid: inode.gid,
150            size: inode.size.unwrap_or(0),
151            links_count: inode.nlink_or_nchildren.max(0).min(i32::from(u16::MAX)) as u16,
152            atime: ns_ts(inode.access_time),
153            mtime: ns_ts(inode.mod_time),
154            ctime: ns_ts(inode.change_time),
155            crtime: ns_ts(inode.create_time),
156            allocated: true,
157        })
158    }
159
160    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
161        let inode =
162            apfs_core::dir::load_inode(&mut self.reader, &self.volume, ino, self.block_size)
163                .map_err(|e| FsError::NotFound(format!("inode {ino}: {e}")))?;
164        if mode_file_type(inode.mode) == FsFileType::Directory {
165            return Err(not_supported("read_file on a directory"));
166        }
167        apfs_core::extent::read_data(&mut self.reader, &self.volume, &inode, self.block_size)
168            .map_err(|e| FsError::Io(std::io::Error::other(e.to_string())))
169    }
170
171    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
172        let data = self.read_file(ino)?;
173        let start = (offset as usize).min(data.len());
174        let end = start.saturating_add(len as usize).min(data.len());
175        Ok(data[start..end].to_vec())
176    }
177
178    fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
179        Err(not_supported("APFS symlink targets not yet resolved"))
180    }
181
182    fn fs_info(&self) -> FsResult<serde_json::Value> {
183        Ok(serde_json::json!({
184            "type": "apfs",
185            "block_size": self.block_size,
186        }))
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::io::Cursor;
194
195    /// The committed APFS partition carve from the sibling `apfs-forensic` repo
196    /// (a real macOS-authored APFS container; ground truth from TSK `fls`/`istat`
197    /// per its tests/data/README.md): root holds `top.txt`(22, "top level file\n",
198    /// 15B), `Dir1`(18) → `Beth.txt`(20, 38B), `Sub`(19) → `secret.bin`(21, 26B).
199    const IMG: &str = "/Users/4n6h4x0r/src/apfs-forensic/tests/data/apfs_fstree.bin";
200
201    fn open() -> Option<ApfsForensicFs<Cursor<Vec<u8>>>> {
202        let data = std::fs::read(IMG).ok()?;
203        ApfsForensicFs::new(Cursor::new(data)).ok()
204    }
205
206    #[test]
207    fn root_ino_is_2() {
208        let Some(fs) = open() else {
209            eprintln!("skip: apfs_fstree.bin unavailable");
210            return;
211        };
212        assert_eq!(fs.root_ino(), 2);
213    }
214
215    #[test]
216    fn root_lists_top_and_dir1() {
217        let Some(mut fs) = open() else {
218            eprintln!("skip");
219            return;
220        };
221        let names: Vec<String> = fs
222            .read_dir(2)
223            .unwrap()
224            .iter()
225            .map(FsDirEntry::name_str)
226            .collect();
227        assert!(names.contains(&"top.txt".to_string()), "got {names:?}");
228        assert!(names.contains(&"Dir1".to_string()), "got {names:?}");
229    }
230
231    #[test]
232    fn top_txt_resolves_to_inode_22() {
233        let Some(mut fs) = open() else {
234            eprintln!("skip");
235            return;
236        };
237        assert_eq!(fs.lookup(2, b"top.txt").unwrap(), Some(22));
238    }
239
240    #[test]
241    fn read_top_txt_matches_oracle() {
242        let Some(mut fs) = open() else {
243            eprintln!("skip");
244            return;
245        };
246        let ino = fs.lookup(2, b"top.txt").unwrap().unwrap();
247        assert_eq!(fs.read_file(ino).unwrap(), b"top level file\n");
248    }
249
250    #[test]
251    fn nested_secret_bin_reachable() {
252        let Some(mut fs) = open() else {
253            eprintln!("skip");
254            return;
255        };
256        let dir1 = fs.lookup(2, b"Dir1").unwrap().unwrap();
257        let sub = fs.lookup(dir1, b"Sub").unwrap().unwrap();
258        let secret = fs.lookup(sub, b"secret.bin").unwrap().unwrap();
259        assert_eq!(secret, 21);
260        assert_eq!(fs.metadata(secret).unwrap().size, 26);
261    }
262
263    #[test]
264    fn top_is_regular_dir1_is_dir() {
265        let Some(mut fs) = open() else {
266            eprintln!("skip");
267            return;
268        };
269        let top = fs.lookup(2, b"top.txt").unwrap().unwrap();
270        assert_eq!(fs.metadata(top).unwrap().file_type, FsFileType::RegularFile);
271        assert_eq!(fs.metadata(top).unwrap().size, 15);
272        let dir1 = fs.lookup(2, b"Dir1").unwrap().unwrap();
273        assert_eq!(fs.metadata(dir1).unwrap().file_type, FsFileType::Directory);
274    }
275
276    #[test]
277    fn fs_info_reports_apfs() {
278        let Some(fs) = open() else {
279            eprintln!("skip");
280            return;
281        };
282        assert_eq!(fs.fs_info().unwrap()["type"], "apfs");
283    }
284}