apfs_core/dir.rs
1//! Directory records (`APFS_TYPE_DIR_REC 9`) and name→inode navigation.
2//!
3//! A directory entry's key is `j_drec_key_t` (parent oid + name) or, on
4//! case-insensitive/normalization-aware volumes, `j_drec_hashed_key_t` (parent
5//! oid + a packed name-length/hash word + name). After the 8-byte `j_key`
6//! header:
7//!
8//! - **unhashed** (`j_drec_key_t`): `name_len` u16 @8 (incl. the NUL), name @10.
9//! - **hashed** (`j_drec_hashed_key_t`): `name_len_and_hash` u32 @8
10//! (`J_DREC_LEN_MASK 0x000003ff` = name length incl. NUL,
11//! `J_DREC_HASH_SHIFT 10` for the 22-bit hash), name @12.
12//!
13//! The value `j_drec_val_t { u64 file_id; u64 date_added; u16 flags; xfields }`
14//! gives the target inode (`file_id`) and the time the entry was added
15//! (`date_added`). Layout verified verbatim against the Apple reference +
16//! libfsapfs format spec.
17//!
18//! **Navigation** resolves a path component by scanning the volume fs-tree for
19//! the `DIR_REC` whose key oid is the parent inode and whose name matches the
20//! requested component, returning the child inode (`file_id`); full-path
21//! resolution descends from `ROOT_DIR_INO_NUM` (2). The fs-tree is *virtual* —
22//! its node oids resolve through the volume object map at the volume's xid.
23
24use std::io::{Read, Seek};
25
26use crate::btree::{self, BTreeSubtype};
27use crate::fsrecord::{decode_jkey, RecordType};
28use crate::inode::Inode;
29use crate::object::{fletcher64_checksum, fletcher64_stored, ObjPhys};
30use crate::omap::ObjectMap;
31use crate::volume::ApfsVolume;
32
33/// `ROOT_DIR_INO_NUM` (Apple) — the inode number of a volume's root directory.
34pub const ROOT_DIR_INO_NUM: u64 = 2;
35
36/// `j_drec_hashed_key_t` name-length mask (low 10 bits of `name_len_and_hash`).
37const J_DREC_LEN_MASK: u32 = 0x0000_03ff;
38
39// j_drec_val_t value field offsets.
40const OFF_DREC_FILE_ID: usize = 0;
41const OFF_DREC_DATE_ADDED: usize = 8;
42const OFF_DREC_FLAGS: usize = 16;
43
44/// Depth cap on a path descent / fs-tree walk (cyclic-oid guard).
45const MAX_FSTREE_DEPTH: usize = 64;
46
47/// A directory entry.
48#[derive(Debug, Clone)]
49#[non_exhaustive]
50pub struct DirEntry {
51 /// The entry name (the directory-record key's name string).
52 pub name: String,
53 /// `file_id` — the target inode number.
54 pub file_id: u64,
55 /// `date_added` — when the entry was added (ns since 1970, distinct from the
56 /// inode's own timestamps; not updated on an in-place rename).
57 pub date_added: u64,
58 /// Directory-entry flags (`j_drec_val_t.flags`).
59 pub flags: u16,
60}
61
62/// Decode a `DIR_REC` key's name. The key begins with the 8-byte `j_key` header;
63/// the name layout depends on whether the volume uses hashed keys. The hashed
64/// form is detected structurally: its 4-byte `name_len_and_hash` low-10-bit
65/// length plus a name at offset 12 must fit the key; otherwise the unhashed
66/// (u16 length @8, name @10) form is used. Returns `None` if neither form's
67/// length fits the key slice (never panics, never over-reads).
68fn decode_drec_name(key: &[u8]) -> Option<String> {
69 // Hashed key: name_len_and_hash u32 @8, name @12.
70 let hashed_len = (crate::bytes::le_u32(key, 8) & J_DREC_LEN_MASK) as usize;
71 if hashed_len > 0 {
72 if let Some(name) = key.get(12..12 + hashed_len) {
73 return Some(decode_cstr(name));
74 }
75 }
76 // Unhashed key: name_len u16 @8, name @10.
77 let unhashed_len = crate::bytes::le_u16(key, 8) as usize;
78 if unhashed_len > 0 {
79 if let Some(name) = key.get(10..10 + unhashed_len) {
80 return Some(decode_cstr(name));
81 }
82 }
83 None
84}
85
86/// Parse a `DIR_REC` (key, value) pair into a [`DirEntry`]. `None` if the name
87/// cannot be decoded from the key (a malformed record is skipped, not fatal).
88fn parse_dir_entry(key: &[u8], value: &[u8]) -> Option<DirEntry> {
89 let name = decode_drec_name(key)?;
90 Some(DirEntry {
91 name,
92 file_id: crate::bytes::le_u64(value, OFF_DREC_FILE_ID),
93 date_added: crate::bytes::le_u64(value, OFF_DREC_DATE_ADDED),
94 flags: crate::bytes::le_u16(value, OFF_DREC_FLAGS),
95 })
96}
97
98/// Walk the volume fs-tree (a *virtual* B-tree resolved through the volume omap)
99/// keyed to a single object id: visit only the records whose `j_key` object id is
100/// `target_oid`, descending one root→leaf path per node level instead of the
101/// whole tree. The fs-tree is sorted by object id first, so all records for one
102/// object id occupy a contiguous key range; at each index node only the children
103/// whose key range can cover `target_oid` are descended (see
104/// [`child_may_contain_oid`]). Each node's Fletcher-64 checksum is verified
105/// before its TOC is trusted, the descent depth is capped, and a visited-set
106/// guards against cyclic node oids. The `visit` callback still sees the landing
107/// leaves' entries and must filter precisely (a leaf may also hold neighbours).
108///
109/// `pub(crate)` so every navigation entry point shares it — `lookup_child`,
110/// `load_inode`, `list_dir`, [`crate::extent::list_extents`],
111/// [`crate::xattr::list_xattrs`] — since each filters by a single object id,
112/// without duplicating the omap-resolution + checksum/cycle-guard walk.
113///
114/// # Errors
115/// [`crate::ApfsError::OmapUnresolved`] / [`crate::ApfsError::ChecksumMismatch`]
116/// / [`crate::ApfsError::CycleGuard`] / [`crate::ApfsError::Io`] on a
117/// structurally invalid omap/fs-tree or a read failure.
118pub(crate) fn for_each_fs_record_for_oid<R, F>(
119 reader: &mut R,
120 volume: &ApfsVolume,
121 block_size: usize,
122 target_oid: u64,
123 visit: &mut F,
124) -> crate::Result<()>
125where
126 R: Read + Seek,
127 F: FnMut(&[u8], &[u8]),
128{
129 walk_fs_tree(reader, volume, block_size, Some(target_oid), visit)
130}
131
132fn walk_fs_tree<R, F>(
133 reader: &mut R,
134 volume: &ApfsVolume,
135 block_size: usize,
136 target_oid: Option<u64>,
137 visit: &mut F,
138) -> crate::Result<()>
139where
140 R: Read + Seek,
141 F: FnMut(&[u8], &[u8]),
142{
143 // Read the volume omap header (a physical object at apfs_omap_oid).
144 let mut buf = vec![0u8; block_size];
145 let omap_off = volume.omap_oid().saturating_mul(block_size as u64);
146 reader.seek(std::io::SeekFrom::Start(omap_off))?;
147 reader.read_exact(&mut buf)?;
148 let omap = ObjectMap::parse(&buf)?;
149
150 let xid = volume.xid();
151 let mut visited = std::collections::HashSet::new();
152 descend_virtual(
153 reader,
154 &omap,
155 volume.root_tree_oid(),
156 xid,
157 block_size,
158 0,
159 target_oid,
160 &mut visited,
161 visit,
162 )
163}
164
165/// Whether an index-node child whose subtree covers keys `[sep, next_sep)` can
166/// contain a record with object id `target`. `next_sep` is the next separator's
167/// object id, or `None` for the last child (its subtree extends upward without
168/// bound). Records for one object id form a contiguous key range, so the child
169/// is relevant iff its low bound is ≤ `target` and its high bound is ≥ `target`.
170///
171/// The `next_sep == target` boundary **must** descend: a separator is the first
172/// *full* key of the next child, so a record `(target, low_type)` smaller than
173/// that separator can still live at the end of *this* child.
174fn child_may_contain_oid(sep_oid: u64, next_sep_oid: Option<u64>, target: u64) -> bool {
175 sep_oid <= target && next_sep_oid.is_none_or(|next| next >= target)
176}
177
178#[allow(clippy::too_many_arguments)]
179fn descend_virtual<R, F>(
180 reader: &mut R,
181 omap: &ObjectMap,
182 node_oid: u64,
183 xid: u64,
184 block_size: usize,
185 depth: usize,
186 target_oid: Option<u64>,
187 visited: &mut std::collections::HashSet<u64>,
188 visit: &mut F,
189) -> crate::Result<()>
190where
191 R: Read + Seek,
192 F: FnMut(&[u8], &[u8]),
193{
194 let cycle = || crate::ApfsError::CycleGuard {
195 cap: MAX_FSTREE_DEPTH,
196 };
197 // The visited-set guard below dominates — any cycle repeats a node oid
198 // (tripping it) before a legal tree reaches depth 64; this depth cap is
199 // defense-in-depth against a pathological deep acyclic tree.
200 if depth >= MAX_FSTREE_DEPTH {
201 return Err(cycle()); // cov:unreachable: visited-set guard dominates any realizable cycle
202 }
203 if !visited.insert(node_oid) {
204 return Err(cycle());
205 }
206
207 // Resolve this node's virtual oid to a physical block via the omap.
208 let entry = omap.resolve(reader, node_oid, xid, block_size)?;
209
210 let mut buf = vec![0u8; block_size];
211 let offset = entry.paddr.saturating_mul(block_size as u64);
212 reader.seek(std::io::SeekFrom::Start(offset))?;
213 reader.read_exact(&mut buf)?;
214
215 // Checksum-before-trust.
216 let stored = fletcher64_stored(&buf);
217 let computed = fletcher64_checksum(&buf);
218 if stored != computed {
219 let block = ObjPhys::parse(&buf).map_or(entry.paddr, |h| h.oid);
220 return Err(crate::ApfsError::ChecksumMismatch {
221 block,
222 stored,
223 computed,
224 });
225 }
226
227 let Some(hdr) = btree::parse_node_header(&buf) else {
228 return Ok(()); // cov:unreachable: buf is block_size >= node header length
229 };
230
231 if hdr.is_leaf() {
232 for e in btree::node_entries(&buf, BTreeSubtype::FsTree) {
233 visit(e.key, e.value);
234 }
235 return Ok(());
236 }
237
238 // Index node: each value is an 8-byte child *virtual* oid. For a keyed walk,
239 // descend only the children whose key range can cover `target_oid`; a full
240 // walk (target_oid == None) descends every child.
241 let entries = btree::node_entries(&buf, BTreeSubtype::FsTree);
242 for i in 0..entries.len() {
243 if let Some(target) = target_oid {
244 let (sep_oid, _) = decode_jkey(crate::bytes::le_u64(entries[i].key, 0));
245 let next_sep_oid = entries
246 .get(i + 1)
247 .map(|e| decode_jkey(crate::bytes::le_u64(e.key, 0)).0);
248 if !child_may_contain_oid(sep_oid, next_sep_oid, target) {
249 continue;
250 }
251 }
252 let child = crate::bytes::le_u64(entries[i].value, 0);
253 descend_virtual(
254 reader,
255 omap,
256 child,
257 xid,
258 block_size,
259 depth + 1,
260 target_oid,
261 visited,
262 visit,
263 )?;
264 }
265 Ok(())
266}
267
268/// List the directory entries whose parent is `parent_oid`, scanning the volume
269/// fs-tree for `DIR_REC` records.
270///
271/// # Errors
272/// [`crate::ApfsError::OmapUnresolved`] / [`crate::ApfsError::ChecksumMismatch`]
273/// / [`crate::ApfsError::CycleGuard`] / [`crate::ApfsError::Io`] on a
274/// structurally invalid omap/fs-tree or a read failure.
275pub fn list_dir<R: Read + Seek>(
276 reader: &mut R,
277 volume: &ApfsVolume,
278 parent_oid: u64,
279 block_size: usize,
280) -> crate::Result<Vec<DirEntry>> {
281 let mut out = Vec::new();
282 for_each_fs_record_for_oid(reader, volume, block_size, parent_oid, &mut |key, value| {
283 let (oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
284 if ty != Some(RecordType::DirRec) || oid != parent_oid {
285 return;
286 }
287 // Skip a malformed DIR_REC rather than fail the whole listing.
288 let Some(entry) = parse_dir_entry(key, value) else {
289 return; // cov:unreachable: valid DIR_REC keys always decode a name
290 };
291 out.push(entry);
292 })?;
293 Ok(out)
294}
295
296/// Resolve a single path component: look up the `DIR_REC` `(parent_oid, name)` in
297/// the fs-tree and return the child inode number, or `None` if absent.
298///
299/// # Errors
300/// As [`list_dir`].
301pub fn lookup_child<R: Read + Seek>(
302 reader: &mut R,
303 volume: &ApfsVolume,
304 parent_oid: u64,
305 name: &str,
306 block_size: usize,
307) -> crate::Result<Option<u64>> {
308 let mut found = None;
309 for_each_fs_record_for_oid(reader, volume, block_size, parent_oid, &mut |key, value| {
310 if found.is_some() {
311 return;
312 }
313 let (oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
314 if ty != Some(RecordType::DirRec) || oid != parent_oid {
315 return;
316 }
317 // A DIR_REC whose name cannot be decoded is a malformed/hostile record;
318 // skip it rather than fail the whole listing (defensive — real images
319 // always decode, so the skip arm is unreachable on valid data).
320 let Some(entry) = parse_dir_entry(key, value) else {
321 return; // cov:unreachable: valid DIR_REC keys always decode a name
322 };
323 if entry.name == name {
324 found = Some(entry.file_id);
325 }
326 })?;
327 Ok(found)
328}
329
330/// Load the inode (`INODE` record) for `oid` from the volume fs-tree.
331///
332/// # Errors
333/// [`crate::ApfsError::OmapUnresolved`] if the inode record is not present (a
334/// loud per-item miss), plus the structural errors of [`list_dir`].
335pub fn load_inode<R: Read + Seek>(
336 reader: &mut R,
337 volume: &ApfsVolume,
338 oid: u64,
339 block_size: usize,
340) -> crate::Result<Inode> {
341 let mut value: Option<Vec<u8>> = None;
342 for_each_fs_record_for_oid(reader, volume, block_size, oid, &mut |key, val| {
343 if value.is_some() {
344 return;
345 }
346 let (k_oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
347 if ty == Some(RecordType::Inode) && k_oid == oid {
348 value = Some(val.to_vec());
349 }
350 })?;
351 let value = value.ok_or(crate::ApfsError::OmapUnresolved {
352 oid,
353 xid: volume.xid(),
354 })?;
355 Inode::parse(oid, &value)
356}
357
358/// Resolve a `/`-separated path to an [`Inode`] by descending the fs-tree from
359/// the root directory (`ROOT_DIR_INO_NUM`). Empty components (from a leading,
360/// trailing, or doubled `/`) are skipped; `"/"` resolves to the root inode.
361///
362/// # Errors
363/// [`crate::ApfsError::OmapUnresolved`] if any path component is not found or the
364/// final inode record is absent (a loud per-item miss), plus the structural
365/// errors of [`list_dir`].
366pub fn open_path<R: Read + Seek>(
367 reader: &mut R,
368 volume: &ApfsVolume,
369 path: &str,
370 block_size: usize,
371) -> crate::Result<Inode> {
372 let mut current = ROOT_DIR_INO_NUM;
373 for component in path.split('/').filter(|c| !c.is_empty()) {
374 match lookup_child(reader, volume, current, component, block_size)? {
375 Some(child) => current = child,
376 None => {
377 return Err(crate::ApfsError::OmapUnresolved {
378 oid: current,
379 xid: volume.xid(),
380 });
381 }
382 }
383 }
384 load_inode(reader, volume, current, block_size)
385}
386
387/// Decode a NUL-terminated UTF-8 byte string. Bytes after the first NUL are
388/// dropped; invalid UTF-8 is replaced (never panics).
389fn decode_cstr(data: &[u8]) -> String {
390 let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
391 String::from_utf8_lossy(&data[..end]).into_owned()
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 /// Build a `j_key` header word from a 4-bit type and a 60-bit oid.
399 fn jkey(ty: u64, oid: u64) -> [u8; 8] {
400 ((ty << 60) | oid).to_le_bytes()
401 }
402
403 #[test]
404 fn decode_hashed_drec_name() {
405 // 8-byte j_key header (DIR_REC, parent 2), then name_len_and_hash u32 with
406 // low-10-bit length = 5 ("abcd\0"), then the name at offset 12.
407 let mut key = Vec::new();
408 key.extend_from_slice(&jkey(9, 2));
409 key.extend_from_slice(&5u32.to_le_bytes()); // len=5 in low 10 bits, hash 0
410 key.extend_from_slice(b"abcd\0");
411 assert_eq!(decode_drec_name(&key).as_deref(), Some("abcd"));
412 }
413
414 #[test]
415 fn child_pruning_selects_only_covering_subtrees() {
416 // Index node with separators [oid 0, oid 10, oid 20]; child i covers
417 // [sep_i, sep_{i+1}). Records for one oid form a contiguous key range.
418 // target oid 15 lives only under child[1] (covers [10, 20)).
419 assert!(
420 !child_may_contain_oid(0, Some(10), 15),
421 "child [0,10) excludes 15"
422 );
423 assert!(
424 child_may_contain_oid(10, Some(20), 15),
425 "child [10,20) covers 15"
426 );
427 assert!(
428 !child_may_contain_oid(20, None, 15),
429 "child [20,inf) excludes 15"
430 );
431
432 // Boundary spill: a separator is the next child's FIRST FULL key, so a
433 // record (target, low_type) below it can sit at the END of THIS child.
434 // Hence next_sep == target MUST still descend.
435 assert!(
436 child_may_contain_oid(5, Some(10), 10),
437 "next_sep == target must descend (record may trail in this child)"
438 );
439 // sep == target obviously descends; last child always covers the high side.
440 assert!(child_may_contain_oid(10, Some(20), 10));
441 assert!(child_may_contain_oid(5, None, 999));
442 // Entirely below the target prunes.
443 assert!(!child_may_contain_oid(0, Some(5), 10));
444 }
445
446 // Real-data cross-check: on the Apple-authored fs-tree, the KEYED walk must
447 // visit exactly the records the FULL (unpruned) walk visits when filtered to
448 // the same object id. The full walk — the same descent with no pruning, whose
449 // results are independently validated against `fls`/`istat` by the dir/inode
450 // integration tests — is the oracle, so this proves the keyed pruning never
451 // drops a covering record.
452 const FSTREE: &[u8] = include_bytes!("../../tests/data/apfs_fstree.bin");
453 const FSTREE_BLOCK_SIZE: usize = 4096;
454 const FSTREE_APSB_BLOCK: usize = 371;
455
456 fn fstree_volume() -> ApfsVolume {
457 let b = &FSTREE
458 [FSTREE_APSB_BLOCK * FSTREE_BLOCK_SIZE..(FSTREE_APSB_BLOCK + 1) * FSTREE_BLOCK_SIZE];
459 ApfsVolume::parse(b).expect("parse APSB")
460 }
461
462 fn keys_for_oid_full(oid: u64) -> Vec<Vec<u8>> {
463 use std::io::Cursor;
464 let mut r = Cursor::new(FSTREE);
465 let vol = fstree_volume();
466 let mut out = Vec::new();
467 // walk_fs_tree(None) is the full, unpruned descent — the oracle.
468 walk_fs_tree(&mut r, &vol, FSTREE_BLOCK_SIZE, None, &mut |k, _| {
469 if decode_jkey(crate::bytes::le_u64(k, 0)).0 == oid {
470 out.push(k.to_vec());
471 }
472 })
473 .expect("full walk");
474 out
475 }
476
477 fn keys_for_oid_keyed(oid: u64) -> Vec<Vec<u8>> {
478 use std::io::Cursor;
479 let mut r = Cursor::new(FSTREE);
480 let vol = fstree_volume();
481 let mut out = Vec::new();
482 for_each_fs_record_for_oid(&mut r, &vol, FSTREE_BLOCK_SIZE, oid, &mut |k, _| {
483 if decode_jkey(crate::bytes::le_u64(k, 0)).0 == oid {
484 out.push(k.to_vec());
485 }
486 })
487 .expect("keyed walk");
488 out
489 }
490
491 #[test]
492 fn keyed_walk_matches_full_walk_on_real_fs_tree() {
493 // Root dir (2), Dir1 (18), and a file inode (22) — each must yield the
494 // same record set keyed as it does via the filtered full walk.
495 for oid in [2u64, 18, 22] {
496 assert_eq!(
497 keys_for_oid_keyed(oid),
498 keys_for_oid_full(oid),
499 "keyed vs full record set for oid {oid}"
500 );
501 }
502 // A non-existent oid yields nothing either way.
503 assert!(keys_for_oid_keyed(999_999).is_empty());
504 assert_eq!(keys_for_oid_keyed(999_999), keys_for_oid_full(999_999));
505 }
506
507 #[test]
508 fn decode_unhashed_drec_name() {
509 // A case-sensitive volume uses j_drec_key_t: name_len u16 @8, name @10.
510 // Force the hashed path to miss (its low-10-bit length must not fit) by
511 // making the u32 length point past the key, then the unhashed fallback
512 // decodes "Xy\0" (len 3) at offset 10.
513 let mut key = Vec::new();
514 key.extend_from_slice(&jkey(9, 2));
515 key.extend_from_slice(&3u16.to_le_bytes()); // name_len = 3
516 key.extend_from_slice(b"Xy\0");
517 // The hashed interpretation reads name_len_and_hash = u32 @8. Here the
518 // upper two name bytes ("Xy") become part of that u32, giving a bogus
519 // hashed length that runs past the key, so the unhashed branch is used.
520 let name = decode_drec_name(&key);
521 assert_eq!(name.as_deref(), Some("Xy"));
522 }
523
524 #[test]
525 fn decode_drec_name_rejects_overlong_length() {
526 // A key claiming a name longer than its bytes yields None (no over-read).
527 let mut key = Vec::new();
528 key.extend_from_slice(&jkey(9, 2));
529 key.extend_from_slice(&0u32.to_le_bytes()); // hashed len 0
530 // no name bytes; unhashed len also reads 0 -> None
531 assert_eq!(decode_drec_name(&key), None);
532 }
533
534 #[test]
535 fn decode_drec_name_unhashed_length_past_key_is_none() {
536 // unhashed_len > 0 but the name slice runs past the key, AND the hashed
537 // interpretation's length also doesn't fit: both branches miss -> None
538 // (exercises the unhashed `key.get(..)` None arm — no over-read).
539 let mut key = Vec::new();
540 key.extend_from_slice(&jkey(9, 2));
541 // name_len u16 @8 = 200 (way past the key); the hashed u32 @8 low-10-bit
542 // length is also 200, whose name@12 slice does not fit either.
543 key.extend_from_slice(&200u16.to_le_bytes());
544 key.extend_from_slice(b"z"); // only one trailing byte
545 assert_eq!(decode_drec_name(&key), None);
546 }
547
548 #[test]
549 fn parse_dir_entry_decodes_value() {
550 let mut key = Vec::new();
551 key.extend_from_slice(&jkey(9, 2));
552 key.extend_from_slice(&5u32.to_le_bytes());
553 key.extend_from_slice(b"abcd\0");
554 let mut value = Vec::new();
555 value.extend_from_slice(&42u64.to_le_bytes()); // file_id
556 value.extend_from_slice(&1234u64.to_le_bytes()); // date_added
557 value.extend_from_slice(&7u16.to_le_bytes()); // flags
558 let e = parse_dir_entry(&key, &value).expect("parse drec");
559 assert_eq!(e.name, "abcd");
560 assert_eq!(e.file_id, 42);
561 assert_eq!(e.date_added, 1234);
562 assert_eq!(e.flags, 7);
563 }
564
565 #[test]
566 fn parse_dir_entry_rejects_unnamed_key() {
567 // A key with no decodable name yields None (the record is skipped).
568 let mut key = Vec::new();
569 key.extend_from_slice(&jkey(9, 2));
570 key.extend_from_slice(&0u32.to_le_bytes());
571 assert!(parse_dir_entry(&key, &[0u8; 18]).is_none());
572 }
573}