Skip to main content

apfs/
lib.rs

1pub mod btree;
2pub mod catalog;
3pub mod error;
4pub mod extents;
5pub mod fletcher;
6pub mod object;
7pub mod omap;
8pub mod superblock;
9
10pub use error::{ApfsError, Result};
11
12use std::io::{Read, Seek, Write};
13
14/// Entry kind in the filesystem
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EntryKind {
17    File,
18    Directory,
19    Symlink,
20}
21
22/// A directory entry returned by list_directory
23#[derive(Debug, Clone)]
24pub struct DirEntry {
25    pub name: String,
26    pub oid: u64,
27    pub kind: EntryKind,
28    pub size: u64,
29    pub create_time: i64,
30    pub modify_time: i64,
31}
32
33/// Detailed file/directory metadata
34#[derive(Debug, Clone)]
35pub struct FileStat {
36    pub oid: u64,
37    pub kind: EntryKind,
38    pub size: u64,
39    pub create_time: i64,
40    pub modify_time: i64,
41    pub uid: u32,
42    pub gid: u32,
43    pub mode: u16,
44    pub nlink: u32,
45}
46
47/// Entry from walk() — includes full path
48#[derive(Debug, Clone)]
49pub struct WalkEntry {
50    pub path: String,
51    pub entry: DirEntry,
52}
53
54/// Volume information
55#[derive(Debug, Clone)]
56pub struct VolumeInfo {
57    pub name: String,
58    pub block_size: u32,
59    pub num_files: u64,
60    pub num_directories: u64,
61    pub num_symlinks: u64,
62}
63
64/// High-level read-only APFS volume reader
65pub struct ApfsVolume<R: Read + Seek> {
66    reader: R,
67    block_size: u32,
68    vol_omap_root_block: u64,
69    catalog_root_block: u64,
70    info: VolumeInfo,
71}
72
73impl<R: Read + Seek> ApfsVolume<R> {
74    /// Open an APFS container and mount the first volume.
75    ///
76    /// 1. Read block 0 → parse NX superblock, validate NXSB magic + Fletcher-64
77    /// 2. Scan checkpoint descriptor area for latest valid NX superblock
78    /// 3. Read container OMAP at omap_oid physical block
79    /// 4. Find first non-zero OID in fs_oids array
80    /// 5. Resolve volume OID → physical block via container OMAP
81    /// 6. Parse volume superblock (APSB magic)
82    /// 7. Read volume OMAP at vol.omap_oid physical block
83    /// 8. Resolve vol.root_tree_oid → physical block via volume OMAP → catalog B-tree root
84    /// 9. Store all state
85    pub fn open(mut reader: R) -> Result<Self> {
86        // Step 1-2: Read and validate container superblock
87        let nxsb = superblock::read_nxsb(&mut reader)?;
88        let nxsb = superblock::find_latest_nxsb(&mut reader, &nxsb)?;
89        let block_size = nxsb.block_size;
90
91        // Step 3: Read container OMAP
92        let container_omap_root =
93            omap::read_omap_tree_root(&mut reader, nxsb.omap_oid, block_size)?;
94
95        // Step 4: Find first non-zero volume OID
96        let vol_oid = nxsb
97            .fs_oids
98            .iter()
99            .find(|&&o| o != 0)
100            .copied()
101            .ok_or(ApfsError::NoVolume)?;
102
103        // Step 5: Resolve volume OID via container OMAP
104        let vol_block = omap::omap_lookup(&mut reader, container_omap_root, block_size, vol_oid)?;
105
106        // Step 6: Parse volume superblock
107        let vol_data = object::read_block(&mut reader, vol_block, block_size)?;
108        let vol_sb = superblock::ApfsSuperblock::parse(&vol_data)?;
109
110        // Step 7: Read volume OMAP
111        let vol_omap_root_block =
112            omap::read_omap_tree_root(&mut reader, vol_sb.omap_oid, block_size)?;
113
114        // Step 8: Resolve catalog root tree OID via volume OMAP
115        let catalog_root_block = omap::omap_lookup(
116            &mut reader,
117            vol_omap_root_block,
118            block_size,
119            vol_sb.root_tree_oid,
120        )?;
121
122        // Step 9: Store state
123        let info = VolumeInfo {
124            name: vol_sb.volume_name.clone(),
125            block_size,
126            num_files: vol_sb.num_files,
127            num_directories: vol_sb.num_directories,
128            num_symlinks: vol_sb.num_symlinks,
129        };
130
131        Ok(ApfsVolume {
132            reader,
133            block_size,
134            vol_omap_root_block,
135            catalog_root_block,
136            info,
137        })
138    }
139
140    /// Get volume metadata
141    pub fn volume_info(&self) -> &VolumeInfo {
142        &self.info
143    }
144
145    /// List entries in a directory by path
146    pub fn list_directory(&mut self, path: &str) -> Result<Vec<DirEntry>> {
147        let (oid, _inode) = if path == "/" || path.is_empty() {
148            // Root directory has a well-known OID
149            (catalog::ROOT_DIR_PARENT, catalog::ROOT_DIR_RECORD)
150        } else {
151            let (oid, inode) = catalog::resolve_path(
152                &mut self.reader,
153                self.catalog_root_block,
154                self.vol_omap_root_block,
155                self.block_size,
156                path,
157            )?;
158            if inode.kind() != catalog::INODE_DIR_TYPE {
159                return Err(ApfsError::NotADirectory(path.to_string()));
160            }
161            (oid, oid)
162        };
163
164        let parent = if path == "/" || path.is_empty() {
165            catalog::ROOT_DIR_RECORD
166        } else {
167            oid
168        };
169
170        catalog::list_directory(
171            &mut self.reader,
172            self.catalog_root_block,
173            self.vol_omap_root_block,
174            self.block_size,
175            parent,
176        )
177    }
178
179    /// Read an entire file into memory
180    pub fn read_file(&mut self, path: &str) -> Result<Vec<u8>> {
181        let mut buf = Vec::new();
182        self.read_file_to(path, &mut buf)?;
183        Ok(buf)
184    }
185
186    /// Stream a file to a writer
187    pub fn read_file_to<W: Write>(&mut self, path: &str, writer: &mut W) -> Result<u64> {
188        let (_oid, inode) = catalog::resolve_path(
189            &mut self.reader,
190            self.catalog_root_block,
191            self.vol_omap_root_block,
192            self.block_size,
193            path,
194        )?;
195
196        // File extents are keyed by private_id, not the inode OID
197        let file_extents = catalog::lookup_extents(
198            &mut self.reader,
199            self.catalog_root_block,
200            self.vol_omap_root_block,
201            self.block_size,
202            inode.private_id,
203        )?;
204
205        extents::read_file_data(
206            &mut self.reader,
207            self.block_size,
208            &file_extents,
209            inode.size(),
210            writer,
211        )
212    }
213
214    /// Open a file for streaming Read+Seek access
215    pub fn open_file(&mut self, path: &str) -> Result<extents::ApfsForkReader<'_, R>> {
216        let (_oid, inode) = catalog::resolve_path(
217            &mut self.reader,
218            self.catalog_root_block,
219            self.vol_omap_root_block,
220            self.block_size,
221            path,
222        )?;
223
224        // File extents are keyed by private_id, not the inode OID
225        let file_extents = catalog::lookup_extents(
226            &mut self.reader,
227            self.catalog_root_block,
228            self.vol_omap_root_block,
229            self.block_size,
230            inode.private_id,
231        )?;
232
233        Ok(extents::ApfsForkReader::new(
234            &mut self.reader,
235            self.block_size,
236            file_extents,
237            inode.size(),
238        ))
239    }
240
241    /// Get metadata for a file or directory
242    pub fn stat(&mut self, path: &str) -> Result<FileStat> {
243        let (oid, inode) = catalog::resolve_path(
244            &mut self.reader,
245            self.catalog_root_block,
246            self.vol_omap_root_block,
247            self.block_size,
248            path,
249        )?;
250
251        Ok(FileStat {
252            oid,
253            kind: match inode.kind() {
254                catalog::INODE_DIR_TYPE => EntryKind::Directory,
255                catalog::INODE_SYMLINK_TYPE => EntryKind::Symlink,
256                _ => EntryKind::File,
257            },
258            size: inode.size(),
259            create_time: inode.create_time,
260            modify_time: inode.modify_time,
261            uid: inode.uid,
262            gid: inode.gid,
263            mode: inode.mode,
264            nlink: inode.nlink(),
265        })
266    }
267
268    /// Recursive walk of all entries
269    pub fn walk(&mut self) -> Result<Vec<WalkEntry>> {
270        let mut entries = Vec::new();
271        self.walk_recursive(catalog::ROOT_DIR_RECORD, "", &mut entries)?;
272        Ok(entries)
273    }
274
275    /// Check if a path exists
276    pub fn exists(&mut self, path: &str) -> Result<bool> {
277        match catalog::resolve_path(
278            &mut self.reader,
279            self.catalog_root_block,
280            self.vol_omap_root_block,
281            self.block_size,
282            path,
283        ) {
284            Ok(_) => Ok(true),
285            Err(ApfsError::FileNotFound(_)) => Ok(false),
286            Err(e) => Err(e),
287        }
288    }
289
290    fn walk_recursive(
291        &mut self,
292        parent_oid: u64,
293        parent_path: &str,
294        entries: &mut Vec<WalkEntry>,
295    ) -> Result<()> {
296        let dir_entries = catalog::list_directory(
297            &mut self.reader,
298            self.catalog_root_block,
299            self.vol_omap_root_block,
300            self.block_size,
301            parent_oid,
302        )?;
303
304        for entry in dir_entries {
305            let full_path = if parent_path.is_empty() {
306                format!("/{}", entry.name)
307            } else {
308                format!("{}/{}", parent_path, entry.name)
309            };
310
311            let is_dir = entry.kind == EntryKind::Directory;
312            let oid = entry.oid;
313
314            entries.push(WalkEntry {
315                path: full_path.clone(),
316                entry,
317            });
318
319            if is_dir {
320                self.walk_recursive(oid, &full_path, entries)?;
321            }
322        }
323
324        Ok(())
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use std::io::BufReader;
332
333    /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`.
334    #[test]
335    #[ignore]
336    fn test_volume_open() {
337        let file = std::fs::File::open("../tests/appfs.raw").unwrap();
338        let reader = BufReader::new(file);
339
340        let mut vol = ApfsVolume::open(reader).unwrap();
341        let info = vol.volume_info();
342
343        assert!(!info.name.is_empty(), "Volume name should not be empty");
344        assert_eq!(info.block_size, 4096);
345
346        let entries = vol.list_directory("/").unwrap();
347        assert!(!entries.is_empty(), "Root directory should have entries");
348
349        let walk_entries = vol.walk().unwrap();
350        assert!(!walk_entries.is_empty());
351    }
352
353    /// Requires ../tests/appfs.raw fixture. Run with `cargo test -- --ignored`.
354    #[test]
355    #[ignore]
356    fn test_read_file_data() {
357        let file = std::fs::File::open("../tests/appfs.raw").unwrap();
358        let reader = BufReader::new(file);
359
360        let mut vol = ApfsVolume::open(reader).unwrap();
361
362        let walk = vol.walk().unwrap();
363        let small_file = walk.iter().find(|e| {
364            e.entry.kind == EntryKind::File && e.entry.size > 0 && e.entry.size < 1_000_000
365        });
366
367        let entry = small_file.expect("Should find a small file in the test image");
368        let data = vol.read_file(&entry.path).unwrap();
369        assert_eq!(
370            data.len() as u64,
371            entry.entry.size,
372            "Read size should match stat size"
373        );
374
375        let stat = vol.stat(&entry.path).unwrap();
376        assert_eq!(stat.size, entry.entry.size);
377    }
378}