Skip to main content

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.vek(),
156        volume.root_tree_oid(),
157        xid,
158        block_size,
159        0,
160        target_oid,
161        &mut visited,
162        visit,
163    )
164}
165
166/// Whether an index-node child whose subtree covers keys `[sep, next_sep)` can
167/// contain a record with object id `target`. `next_sep` is the next separator's
168/// object id, or `None` for the last child (its subtree extends upward without
169/// bound). Records for one object id form a contiguous key range, so the child
170/// is relevant iff its low bound is ≤ `target` and its high bound is ≥ `target`.
171///
172/// The `next_sep == target` boundary **must** descend: a separator is the first
173/// *full* key of the next child, so a record `(target, low_type)` smaller than
174/// that separator can still live at the end of *this* child.
175fn child_may_contain_oid(sep_oid: u64, next_sep_oid: Option<u64>, target: u64) -> bool {
176    sep_oid <= target && next_sep_oid.is_none_or(|next| next >= target)
177}
178
179#[allow(clippy::too_many_arguments)]
180fn descend_virtual<R, F>(
181    reader: &mut R,
182    omap: &ObjectMap,
183    // Volume key, when the caller holds one; None leaves ciphertext alone so a
184    // locked volume fails its checksum instead of yielding garbage.
185    vek: Option<&[u8; 32]>,
186    node_oid: u64,
187    xid: u64,
188    block_size: usize,
189    depth: usize,
190    target_oid: Option<u64>,
191    visited: &mut std::collections::HashSet<u64>,
192    visit: &mut F,
193) -> crate::Result<()>
194where
195    R: Read + Seek,
196    F: FnMut(&[u8], &[u8]),
197{
198    let cycle = || crate::ApfsError::CycleGuard {
199        cap: MAX_FSTREE_DEPTH,
200    };
201    // The visited-set guard below dominates — any cycle repeats a node oid
202    // (tripping it) before a legal tree reaches depth 64; this depth cap is
203    // defense-in-depth against a pathological deep acyclic tree.
204    if depth >= MAX_FSTREE_DEPTH {
205        return Err(cycle()); // cov:unreachable: visited-set guard dominates any realizable cycle
206    }
207    if !visited.insert(node_oid) {
208        return Err(cycle());
209    }
210
211    // Resolve this node's virtual oid to a physical block via the omap.
212    let entry = omap.resolve(reader, node_oid, xid, block_size)?;
213
214    let mut buf = vec![0u8; block_size];
215    let offset = entry.paddr.saturating_mul(block_size as u64);
216    reader.seek(std::io::SeekFrom::Start(offset))?;
217    reader.read_exact(&mut buf)?;
218
219    // On an encrypted volume the tree itself is ciphertext. The omap entry says
220    // so per node, and the tweak is that node's own block address. Decrypt
221    // BEFORE the checksum: Fletcher-64 covers plaintext, so on ciphertext it
222    // would fail and the volume would look corrupt rather than locked.
223    if entry.flags & crate::encryption::OMAP_VAL_ENCRYPTED != 0 {
224        if let Some(vek) = vek {
225            crate::encryption::decrypt_volume_area(&mut buf, vek, entry.paddr, block_size);
226        }
227    }
228
229    // Checksum-before-trust.
230    let stored = fletcher64_stored(&buf);
231    let computed = fletcher64_checksum(&buf);
232    if stored != computed {
233        let block = ObjPhys::parse(&buf).map_or(entry.paddr, |h| h.oid);
234        return Err(crate::ApfsError::ChecksumMismatch {
235            block,
236            stored,
237            computed,
238        });
239    }
240
241    let Some(hdr) = btree::parse_node_header(&buf) else {
242        return Ok(()); // cov:unreachable: buf is block_size >= node header length
243    };
244
245    if hdr.is_leaf() {
246        for e in btree::node_entries(&buf, BTreeSubtype::FsTree) {
247            visit(e.key, e.value);
248        }
249        return Ok(());
250    }
251
252    // Index node: each value is an 8-byte child *virtual* oid. For a keyed walk,
253    // descend only the children whose key range can cover `target_oid`; a full
254    // walk (target_oid == None) descends every child.
255    let entries = btree::node_entries(&buf, BTreeSubtype::FsTree);
256    for i in 0..entries.len() {
257        if let Some(target) = target_oid {
258            let (sep_oid, _) = decode_jkey(crate::bytes::le_u64(entries[i].key, 0));
259            let next_sep_oid = entries
260                .get(i + 1)
261                .map(|e| decode_jkey(crate::bytes::le_u64(e.key, 0)).0);
262            if !child_may_contain_oid(sep_oid, next_sep_oid, target) {
263                continue;
264            }
265        }
266        let child = crate::bytes::le_u64(entries[i].value, 0);
267        descend_virtual(
268            reader,
269            omap,
270            vek,
271            child,
272            xid,
273            block_size,
274            depth + 1,
275            target_oid,
276            visited,
277            visit,
278        )?;
279    }
280    Ok(())
281}
282
283/// List the directory entries whose parent is `parent_oid`, scanning the volume
284/// fs-tree for `DIR_REC` records.
285///
286/// # Errors
287/// [`crate::ApfsError::OmapUnresolved`] / [`crate::ApfsError::ChecksumMismatch`]
288/// / [`crate::ApfsError::CycleGuard`] / [`crate::ApfsError::Io`] on a
289/// structurally invalid omap/fs-tree or a read failure.
290pub fn list_dir<R: Read + Seek>(
291    reader: &mut R,
292    volume: &ApfsVolume,
293    parent_oid: u64,
294    block_size: usize,
295) -> crate::Result<Vec<DirEntry>> {
296    let mut out = Vec::new();
297    for_each_fs_record_for_oid(reader, volume, block_size, parent_oid, &mut |key, value| {
298        let (oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
299        if ty != Some(RecordType::DirRec) || oid != parent_oid {
300            return;
301        }
302        // Skip a malformed DIR_REC rather than fail the whole listing.
303        let Some(entry) = parse_dir_entry(key, value) else {
304            return; // cov:unreachable: valid DIR_REC keys always decode a name
305        };
306        out.push(entry);
307    })?;
308    Ok(out)
309}
310
311/// Resolve a single path component: look up the `DIR_REC` `(parent_oid, name)` in
312/// the fs-tree and return the child inode number, or `None` if absent.
313///
314/// # Errors
315/// As [`list_dir`].
316pub fn lookup_child<R: Read + Seek>(
317    reader: &mut R,
318    volume: &ApfsVolume,
319    parent_oid: u64,
320    name: &str,
321    block_size: usize,
322) -> crate::Result<Option<u64>> {
323    let mut found = None;
324    for_each_fs_record_for_oid(reader, volume, block_size, parent_oid, &mut |key, value| {
325        if found.is_some() {
326            return;
327        }
328        let (oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
329        if ty != Some(RecordType::DirRec) || oid != parent_oid {
330            return;
331        }
332        // A DIR_REC whose name cannot be decoded is a malformed/hostile record;
333        // skip it rather than fail the whole listing (defensive — real images
334        // always decode, so the skip arm is unreachable on valid data).
335        let Some(entry) = parse_dir_entry(key, value) else {
336            return; // cov:unreachable: valid DIR_REC keys always decode a name
337        };
338        if entry.name == name {
339            found = Some(entry.file_id);
340        }
341    })?;
342    Ok(found)
343}
344
345/// Load the inode (`INODE` record) for `oid` from the volume fs-tree.
346///
347/// # Errors
348/// [`crate::ApfsError::OmapUnresolved`] if the inode record is not present (a
349/// loud per-item miss), plus the structural errors of [`list_dir`].
350pub fn load_inode<R: Read + Seek>(
351    reader: &mut R,
352    volume: &ApfsVolume,
353    oid: u64,
354    block_size: usize,
355) -> crate::Result<Inode> {
356    let mut value: Option<Vec<u8>> = None;
357    for_each_fs_record_for_oid(reader, volume, block_size, oid, &mut |key, val| {
358        if value.is_some() {
359            return;
360        }
361        let (k_oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
362        if ty == Some(RecordType::Inode) && k_oid == oid {
363            value = Some(val.to_vec());
364        }
365    })?;
366    let value = value.ok_or(crate::ApfsError::OmapUnresolved {
367        oid,
368        xid: volume.xid(),
369    })?;
370    Inode::parse(oid, &value)
371}
372
373/// Resolve a `/`-separated path to an [`Inode`] by descending the fs-tree from
374/// the root directory (`ROOT_DIR_INO_NUM`). Empty components (from a leading,
375/// trailing, or doubled `/`) are skipped; `"/"` resolves to the root inode.
376///
377/// # Errors
378/// [`crate::ApfsError::OmapUnresolved`] if any path component is not found or the
379/// final inode record is absent (a loud per-item miss), plus the structural
380/// errors of [`list_dir`].
381pub fn open_path<R: Read + Seek>(
382    reader: &mut R,
383    volume: &ApfsVolume,
384    path: &str,
385    block_size: usize,
386) -> crate::Result<Inode> {
387    let mut current = ROOT_DIR_INO_NUM;
388    for component in path.split('/').filter(|c| !c.is_empty()) {
389        match lookup_child(reader, volume, current, component, block_size)? {
390            Some(child) => current = child,
391            None => {
392                return Err(crate::ApfsError::OmapUnresolved {
393                    oid: current,
394                    xid: volume.xid(),
395                });
396            }
397        }
398    }
399    load_inode(reader, volume, current, block_size)
400}
401
402/// Decode a NUL-terminated UTF-8 byte string. Bytes after the first NUL are
403/// dropped; invalid UTF-8 is replaced (never panics).
404fn decode_cstr(data: &[u8]) -> String {
405    let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
406    String::from_utf8_lossy(&data[..end]).into_owned()
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    /// Build a `j_key` header word from a 4-bit type and a 60-bit oid.
414    fn jkey(ty: u64, oid: u64) -> [u8; 8] {
415        ((ty << 60) | oid).to_le_bytes()
416    }
417
418    #[test]
419    fn decode_hashed_drec_name() {
420        // 8-byte j_key header (DIR_REC, parent 2), then name_len_and_hash u32 with
421        // low-10-bit length = 5 ("abcd\0"), then the name at offset 12.
422        let mut key = Vec::new();
423        key.extend_from_slice(&jkey(9, 2));
424        key.extend_from_slice(&5u32.to_le_bytes()); // len=5 in low 10 bits, hash 0
425        key.extend_from_slice(b"abcd\0");
426        assert_eq!(decode_drec_name(&key).as_deref(), Some("abcd"));
427    }
428
429    #[test]
430    fn child_pruning_selects_only_covering_subtrees() {
431        // Index node with separators [oid 0, oid 10, oid 20]; child i covers
432        // [sep_i, sep_{i+1}). Records for one oid form a contiguous key range.
433        // target oid 15 lives only under child[1] (covers [10, 20)).
434        assert!(
435            !child_may_contain_oid(0, Some(10), 15),
436            "child [0,10) excludes 15"
437        );
438        assert!(
439            child_may_contain_oid(10, Some(20), 15),
440            "child [10,20) covers 15"
441        );
442        assert!(
443            !child_may_contain_oid(20, None, 15),
444            "child [20,inf) excludes 15"
445        );
446
447        // Boundary spill: a separator is the next child's FIRST FULL key, so a
448        // record (target, low_type) below it can sit at the END of THIS child.
449        // Hence next_sep == target MUST still descend.
450        assert!(
451            child_may_contain_oid(5, Some(10), 10),
452            "next_sep == target must descend (record may trail in this child)"
453        );
454        // sep == target obviously descends; last child always covers the high side.
455        assert!(child_may_contain_oid(10, Some(20), 10));
456        assert!(child_may_contain_oid(5, None, 999));
457        // Entirely below the target prunes.
458        assert!(!child_may_contain_oid(0, Some(5), 10));
459    }
460
461    // Real-data cross-check: on the Apple-authored fs-tree, the KEYED walk must
462    // visit exactly the records the FULL (unpruned) walk visits when filtered to
463    // the same object id. The full walk — the same descent with no pruning, whose
464    // results are independently validated against `fls`/`istat` by the dir/inode
465    // integration tests — is the oracle, so this proves the keyed pruning never
466    // drops a covering record.
467    const FSTREE: &[u8] = include_bytes!("../../tests/data/apfs_fstree.bin");
468    const FSTREE_BLOCK_SIZE: usize = 4096;
469    const FSTREE_APSB_BLOCK: usize = 371;
470
471    fn fstree_volume() -> ApfsVolume {
472        let b = &FSTREE
473            [FSTREE_APSB_BLOCK * FSTREE_BLOCK_SIZE..(FSTREE_APSB_BLOCK + 1) * FSTREE_BLOCK_SIZE];
474        ApfsVolume::parse(b).expect("parse APSB")
475    }
476
477    fn keys_for_oid_full(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        // walk_fs_tree(None) is the full, unpruned descent — the oracle.
483        walk_fs_tree(&mut r, &vol, FSTREE_BLOCK_SIZE, None, &mut |k, _| {
484            if decode_jkey(crate::bytes::le_u64(k, 0)).0 == oid {
485                out.push(k.to_vec());
486            }
487        })
488        .expect("full walk");
489        out
490    }
491
492    fn keys_for_oid_keyed(oid: u64) -> Vec<Vec<u8>> {
493        use std::io::Cursor;
494        let mut r = Cursor::new(FSTREE);
495        let vol = fstree_volume();
496        let mut out = Vec::new();
497        for_each_fs_record_for_oid(&mut r, &vol, FSTREE_BLOCK_SIZE, oid, &mut |k, _| {
498            if decode_jkey(crate::bytes::le_u64(k, 0)).0 == oid {
499                out.push(k.to_vec());
500            }
501        })
502        .expect("keyed walk");
503        out
504    }
505
506    #[test]
507    fn keyed_walk_matches_full_walk_on_real_fs_tree() {
508        // Root dir (2), Dir1 (18), and a file inode (22) — each must yield the
509        // same record set keyed as it does via the filtered full walk.
510        for oid in [2u64, 18, 22] {
511            assert_eq!(
512                keys_for_oid_keyed(oid),
513                keys_for_oid_full(oid),
514                "keyed vs full record set for oid {oid}"
515            );
516        }
517        // A non-existent oid yields nothing either way.
518        assert!(keys_for_oid_keyed(999_999).is_empty());
519        assert_eq!(keys_for_oid_keyed(999_999), keys_for_oid_full(999_999));
520    }
521
522    #[test]
523    fn decode_unhashed_drec_name() {
524        // A case-sensitive volume uses j_drec_key_t: name_len u16 @8, name @10.
525        // Force the hashed path to miss (its low-10-bit length must not fit) by
526        // making the u32 length point past the key, then the unhashed fallback
527        // decodes "Xy\0" (len 3) at offset 10.
528        let mut key = Vec::new();
529        key.extend_from_slice(&jkey(9, 2));
530        key.extend_from_slice(&3u16.to_le_bytes()); // name_len = 3
531        key.extend_from_slice(b"Xy\0");
532        // The hashed interpretation reads name_len_and_hash = u32 @8. Here the
533        // upper two name bytes ("Xy") become part of that u32, giving a bogus
534        // hashed length that runs past the key, so the unhashed branch is used.
535        let name = decode_drec_name(&key);
536        assert_eq!(name.as_deref(), Some("Xy"));
537    }
538
539    #[test]
540    fn decode_drec_name_rejects_overlong_length() {
541        // A key claiming a name longer than its bytes yields None (no over-read).
542        let mut key = Vec::new();
543        key.extend_from_slice(&jkey(9, 2));
544        key.extend_from_slice(&0u32.to_le_bytes()); // hashed len 0
545                                                    // no name bytes; unhashed len also reads 0 -> None
546        assert_eq!(decode_drec_name(&key), None);
547    }
548
549    #[test]
550    fn decode_drec_name_unhashed_length_past_key_is_none() {
551        // unhashed_len > 0 but the name slice runs past the key, AND the hashed
552        // interpretation's length also doesn't fit: both branches miss -> None
553        // (exercises the unhashed `key.get(..)` None arm — no over-read).
554        let mut key = Vec::new();
555        key.extend_from_slice(&jkey(9, 2));
556        // name_len u16 @8 = 200 (way past the key); the hashed u32 @8 low-10-bit
557        // length is also 200, whose name@12 slice does not fit either.
558        key.extend_from_slice(&200u16.to_le_bytes());
559        key.extend_from_slice(b"z"); // only one trailing byte
560        assert_eq!(decode_drec_name(&key), None);
561    }
562
563    #[test]
564    fn parse_dir_entry_decodes_value() {
565        let mut key = Vec::new();
566        key.extend_from_slice(&jkey(9, 2));
567        key.extend_from_slice(&5u32.to_le_bytes());
568        key.extend_from_slice(b"abcd\0");
569        let mut value = Vec::new();
570        value.extend_from_slice(&42u64.to_le_bytes()); // file_id
571        value.extend_from_slice(&1234u64.to_le_bytes()); // date_added
572        value.extend_from_slice(&7u16.to_le_bytes()); // flags
573        let e = parse_dir_entry(&key, &value).expect("parse drec");
574        assert_eq!(e.name, "abcd");
575        assert_eq!(e.file_id, 42);
576        assert_eq!(e.date_added, 1234);
577        assert_eq!(e.flags, 7);
578    }
579
580    #[test]
581    fn parse_dir_entry_rejects_unnamed_key() {
582        // A key with no decodable name yields None (the record is skipped).
583        let mut key = Vec::new();
584        key.extend_from_slice(&jkey(9, 2));
585        key.extend_from_slice(&0u32.to_le_bytes());
586        assert!(parse_dir_entry(&key, &[0u8; 18]).is_none());
587    }
588}