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}