Skip to main content

apfs_core/
snapshot.rs

1//! Snapshots: the snapshot-metadata tree, the snapshot-name tree, and the
2//! point-in-time volume view.
3//!
4//! A volume's snapshots live in a single B-tree located by a **physical** block
5//! number in the APSB (`apfs_snap_meta_tree_oid`, offset 152 — despite the
6//! `_oid` name it is a block address; libfsapfs names the field
7//! `snapshot_metadata_tree_block_number` and the tree's `o_subtype` is
8//! `SNAPMETATREE 0x10`, stored physically `0x40000002`).
9//!
10//! `j_snap_metadata_val_t` field offsets within the value (verified verbatim
11//! against libfsapfs `fsapfs_snapshot_metadata_btree_value` *and* dissect.apfs's
12//! `j_snap_metadata_val`, which agree exactly):
13//!
14//! | off | size | field                 |
15//! |-----|------|-----------------------|
16//! | 0   | 8    | `extentref_tree_oid`  |
17//! | 8   | 8    | `sblock_oid` (volume superblock block number) |
18//! | 16  | 8    | `create_time` (ns since 1970-01-01 UTC) |
19//! | 24  | 8    | `change_time`         |
20//! | 32  | 8    | `inum`                |
21//! | 40  | 4    | `extentref_tree_type` |
22//! | 44  | 4    | `flags`               |
23//! | 48  | 2    | `name_len` (incl. trailing NUL) |
24//! | 50  | …    | `name[name_len]`      |
25
26use std::io::{Read, Seek};
27
28use crate::btree::{self, BTreeSubtype};
29use crate::fsrecord::{decode_jkey, RecordType};
30use crate::object::{fletcher64_checksum, fletcher64_stored, ObjPhys};
31use crate::omap::ObjectMap;
32use crate::volume::ApfsVolume;
33
34/// `o_subtype` of the snapshot-metadata tree (`OBJECT_TYPE_SNAPMETATREE`).
35const OBJECT_SUBTYPE_SNAPMETATREE: u32 = 0x10;
36
37// `j_snap_name_key_t`: u16 name_len @8 (after the 8-byte j_key), name @10.
38const OFF_SNAP_NAME_KEY_LEN: usize = 8;
39const OFF_SNAP_NAME_KEY_NAME: usize = 10;
40
41// `j_snap_metadata_val_t` field offsets within the value.
42const OFF_SNAP_EXTENTREF_TREE_OID: usize = 0;
43const OFF_SNAP_SBLOCK_OID: usize = 8;
44const OFF_SNAP_CREATE_TIME: usize = 16;
45const OFF_SNAP_CHANGE_TIME: usize = 24;
46const OFF_SNAP_INUM: usize = 32;
47const OFF_SNAP_FLAGS: usize = 44;
48const OFF_SNAP_NAME_LEN: usize = 48;
49const OFF_SNAP_NAME: usize = 50;
50
51/// Depth cap on a snapshot-metadata-tree descent (cyclic-oid guard).
52const MAX_SNAP_TREE_DEPTH: usize = 64;
53
54/// Hard cap on a snapshot name length (incl. NUL) — a snapshot name is bounded;
55/// cap well above any legal name to reject an allocation-bomb `name_len`.
56const MAX_SNAP_NAME_LEN: usize = 4096;
57
58/// A parsed snapshot (one `APFS_TYPE_SNAP_METADATA` record).
59#[derive(Debug, Clone)]
60#[non_exhaustive]
61pub struct Snapshot {
62    /// The snapshot's transaction id (the snap-metadata key's low 60 bits).
63    pub xid: u64,
64    /// The snapshot name (`j_snap_metadata_val_t.name`, NUL-terminated).
65    pub name: String,
66    /// `create_time` (ns since 1970-01-01 UTC).
67    pub create_time: u64,
68    /// `change_time` (ns since 1970-01-01 UTC).
69    pub change_time: u64,
70    /// `sblock_oid` — the block address of the volume superblock (APSB) frozen at
71    /// this snapshot.
72    pub sblock_oid: u64,
73    /// `extentref_tree_oid` — the snapshot's extent-reference tree oid.
74    pub extentref_tree_oid: u64,
75    /// `inum` — the snapshot's `inum` field.
76    pub inum: u64,
77    /// `flags` (`j_snap_metadata_val_t.flags`).
78    pub flags: u32,
79}
80
81/// Decode a `j_snap_metadata_val_t` value for a snapshot at `xid`. Bounds-checked:
82/// missing fields read as 0, an over-long `name_len` is clamped, and the name is
83/// taken only from bytes that fit (never panics, never over-reads).
84fn parse_snap_metadata(xid: u64, value: &[u8]) -> Snapshot {
85    let name_len = (crate::bytes::le_u16(value, OFF_SNAP_NAME_LEN) as usize).min(MAX_SNAP_NAME_LEN);
86    let name = value
87        .get(OFF_SNAP_NAME..OFF_SNAP_NAME + name_len)
88        .map_or_else(String::new, decode_cstr);
89    Snapshot {
90        xid,
91        name,
92        create_time: crate::bytes::le_u64(value, OFF_SNAP_CREATE_TIME),
93        change_time: crate::bytes::le_u64(value, OFF_SNAP_CHANGE_TIME),
94        sblock_oid: crate::bytes::le_u64(value, OFF_SNAP_SBLOCK_OID),
95        extentref_tree_oid: crate::bytes::le_u64(value, OFF_SNAP_EXTENTREF_TREE_OID),
96        inum: crate::bytes::le_u64(value, OFF_SNAP_INUM),
97        flags: crate::bytes::le_u32(value, OFF_SNAP_FLAGS),
98    }
99}
100
101/// Enumerate a volume's snapshots from the snapshot-metadata tree, sorted by xid.
102///
103/// # Errors
104/// [`crate::ApfsError::ChecksumMismatch`] / [`crate::ApfsError::CycleGuard`] /
105/// [`crate::ApfsError::OmapUnresolved`] / [`crate::ApfsError::Io`] on a
106/// structurally invalid tree or a read failure.
107pub fn list_snapshots<R: Read + Seek>(
108    reader: &mut R,
109    volume: &ApfsVolume,
110    block_size: usize,
111) -> crate::Result<Vec<Snapshot>> {
112    let mut out = Vec::new();
113    for_each_snap_record(reader, volume, block_size, &mut |key, value| {
114        let (xid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
115        if ty == Some(RecordType::SnapMetadata) {
116            out.push(parse_snap_metadata(xid, value));
117        }
118    })?;
119    out.sort_by_key(|s| s.xid);
120    Ok(out)
121}
122
123/// Resolve a snapshot **name** to its xid via the snapshot-name tree records
124/// (`APFS_TYPE_SNAP_NAME`, value `j_snap_name_val_t { xid_t snap_xid }`). `None`
125/// if no snapshot of that name exists.
126///
127/// # Errors
128/// As [`list_snapshots`].
129pub fn resolve_snapshot_xid<R: Read + Seek>(
130    reader: &mut R,
131    volume: &ApfsVolume,
132    name: &str,
133    block_size: usize,
134) -> crate::Result<Option<u64>> {
135    let mut found = None;
136    for_each_snap_record(reader, volume, block_size, &mut |key, value| {
137        if found.is_some() {
138            return;
139        }
140        let (_oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
141        if ty != Some(RecordType::SnapName) {
142            return;
143        }
144        if decode_snap_name_key(key).as_deref() == Some(name) {
145            // j_snap_name_val_t { xid_t snap_xid } — the xid is the whole value.
146            found = Some(crate::bytes::le_u64(value, 0));
147        }
148    })?;
149    Ok(found)
150}
151
152/// Mount a snapshot as a point-in-time [`ApfsVolume`]: read the volume
153/// superblock (APSB) frozen at `snapshot.sblock_oid`, parse it, and graft the
154/// **live** volume's object map onto it.
155///
156/// A snapshot's frozen `apfs_superblock_t` carries `apfs_omap_oid == 0` —
157/// snapshots have no object map of their own; their fs-tree is read through the
158/// containing volume's omap, resolved at the snapshot's `xid` (the omap B-tree
159/// retains the historical `(oid, xid) → paddr` mappings a snapshot pins). The
160/// returned volume therefore keeps the frozen superblock's `root_tree_oid` and
161/// `xid` but borrows `live_volume`'s `omap_oid`, so the existing [`crate::dir`]
162/// / [`crate::extent`] navigation reads the volume exactly as it stood at
163/// snapshot time. (Without the graft, navigation would read the snapshot's
164/// `omap_oid == 0`, i.e. block 0 — the container superblock — and fail.)
165///
166/// # Errors
167/// [`crate::ApfsError::UnexpectedObjectType`] if `sblock_oid` does not point at a
168/// volume superblock; [`crate::ApfsError::ChecksumMismatch`] on a Fletcher-64
169/// failure; [`crate::ApfsError::Io`] on a read/seek failure.
170pub fn mount_snapshot<R: Read + Seek>(
171    reader: &mut R,
172    live_volume: &ApfsVolume,
173    snapshot: &Snapshot,
174    block_size: usize,
175) -> crate::Result<ApfsVolume> {
176    let mut buf = vec![0u8; block_size];
177    let offset = snapshot.sblock_oid.saturating_mul(block_size as u64);
178    reader.seek(std::io::SeekFrom::Start(offset))?;
179    reader.read_exact(&mut buf)?;
180    Ok(ApfsVolume::parse(&buf)?.with_omap_oid(live_volume.omap_oid()))
181}
182
183/// Walk the snapshot-metadata tree, invoking `visit(key, value)` for every leaf
184/// record (both `SNAP_METADATA` and `SNAP_NAME`). The root is read by its
185/// physical block address ([`ApfsVolume::snap_meta_tree_oid`]); index-node
186/// children are *virtual* oids resolved through the volume omap at the volume's
187/// xid (libfsapfs resolves snap-meta-tree sub-nodes via the object map),
188/// mirroring [`crate::dir`]'s fs-tree walk. Each node's Fletcher-64 checksum is
189/// verified before its TOC is trusted, the descent depth is capped, and a
190/// visited-set guards against cyclic node oids.
191fn for_each_snap_record<R, F>(
192    reader: &mut R,
193    volume: &ApfsVolume,
194    block_size: usize,
195    visit: &mut F,
196) -> crate::Result<()>
197where
198    R: Read + Seek,
199    F: FnMut(&[u8], &[u8]),
200{
201    // Read the volume omap header (a physical object at apfs_omap_oid) — needed
202    // to resolve any virtual sub-node oids of the snap-meta tree.
203    let mut buf = vec![0u8; block_size];
204    let omap_off = volume.omap_oid().saturating_mul(block_size as u64);
205    reader.seek(std::io::SeekFrom::Start(omap_off))?;
206    reader.read_exact(&mut buf)?;
207    let omap = ObjectMap::parse(&buf)?;
208
209    let mut visited = std::collections::HashSet::new();
210    descend_snap(
211        reader,
212        &omap,
213        volume.snap_meta_tree_oid(),
214        true,
215        volume.xid(),
216        block_size,
217        0,
218        &mut visited,
219        visit,
220    )
221}
222
223#[allow(clippy::too_many_arguments)]
224fn descend_snap<R, F>(
225    reader: &mut R,
226    omap: &ObjectMap,
227    node_oid: u64,
228    is_root: bool,
229    xid: u64,
230    block_size: usize,
231    depth: usize,
232    visited: &mut std::collections::HashSet<u64>,
233    visit: &mut F,
234) -> crate::Result<()>
235where
236    R: Read + Seek,
237    F: FnMut(&[u8], &[u8]),
238{
239    let cycle = || crate::ApfsError::CycleGuard {
240        cap: MAX_SNAP_TREE_DEPTH,
241    };
242    // The visited-set guard below dominates any realizable cycle; this depth cap
243    // is defense-in-depth against a pathological deep acyclic tree.
244    if depth >= MAX_SNAP_TREE_DEPTH {
245        return Err(cycle()); // cov:unreachable: visited-set guard dominates any realizable cycle
246    }
247    if !visited.insert(node_oid) {
248        return Err(cycle());
249    }
250
251    // The root is a direct block address; a sub-node is a virtual oid resolved
252    // through the volume omap.
253    let paddr = if is_root {
254        node_oid
255    } else {
256        omap.resolve(reader, node_oid, xid, block_size)?.paddr
257    };
258
259    let mut buf = vec![0u8; block_size];
260    let offset = paddr.saturating_mul(block_size as u64);
261    reader.seek(std::io::SeekFrom::Start(offset))?;
262    reader.read_exact(&mut buf)?;
263
264    // Checksum-before-trust.
265    let stored = fletcher64_stored(&buf);
266    let computed = fletcher64_checksum(&buf);
267    if stored != computed {
268        let block = ObjPhys::parse(&buf).map_or(paddr, |h| h.oid);
269        return Err(crate::ApfsError::ChecksumMismatch {
270            block,
271            stored,
272            computed,
273        });
274    }
275
276    let Some(hdr) = btree::parse_node_header(&buf) else {
277        return Ok(()); // cov:unreachable: buf is block_size >= node header length
278    };
279
280    // The snap-meta tree is a variable-KV tree (variable keys: 8-byte metadata
281    // key vs name key); its layout matches the fs-tree, so BTreeSubtype::FsTree
282    // supplies the right (variable-KV, 8-byte branch) entry geometry.
283    if hdr.is_leaf() {
284        for e in btree::node_entries(&buf, BTreeSubtype::FsTree) {
285            visit(e.key, e.value);
286        }
287        return Ok(());
288    }
289
290    // Index node: each value is an 8-byte child *virtual* oid; descend each.
291    let children: Vec<u64> = btree::node_entries(&buf, BTreeSubtype::FsTree)
292        .iter()
293        .map(|e| crate::bytes::le_u64(e.value, 0))
294        .collect();
295    for child in children {
296        descend_snap(
297            reader,
298            omap,
299            child,
300            false,
301            xid,
302            block_size,
303            depth + 1,
304            visited,
305            visit,
306        )?;
307    }
308    Ok(())
309}
310
311/// Decode a `j_snap_name_key_t` name from a snap-name record key (u16 `name_len`
312/// @8, name @10). `None` if the length is zero or runs past the key (never
313/// over-reads).
314fn decode_snap_name_key(key: &[u8]) -> Option<String> {
315    let name_len =
316        (crate::bytes::le_u16(key, OFF_SNAP_NAME_KEY_LEN) as usize).min(MAX_SNAP_NAME_LEN);
317    if name_len == 0 {
318        return None;
319    }
320    key.get(OFF_SNAP_NAME_KEY_NAME..OFF_SNAP_NAME_KEY_NAME + name_len)
321        .map(decode_cstr)
322}
323
324/// Decode a NUL-terminated UTF-8 byte string (the snapshot name form).
325fn decode_cstr(data: &[u8]) -> String {
326    let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
327    String::from_utf8_lossy(&data[..end]).into_owned()
328}
329
330/// The snapshot-metadata tree's `o_subtype` (`OBJECT_TYPE_SNAPMETATREE`).
331#[must_use]
332pub fn snap_meta_tree_subtype() -> u32 {
333    OBJECT_SUBTYPE_SNAPMETATREE
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn parse_snap_metadata_value_decodes_all_fields() {
342        let mut v = Vec::new();
343        v.extend_from_slice(&0x11u64.to_le_bytes()); // extentref_tree_oid @0
344        v.extend_from_slice(&0x22u64.to_le_bytes()); // sblock_oid @8
345        v.extend_from_slice(&1000u64.to_le_bytes()); // create_time @16
346        v.extend_from_slice(&2000u64.to_le_bytes()); // change_time @24
347        v.extend_from_slice(&99u64.to_le_bytes()); // inum @32
348        v.extend_from_slice(&0x4000_0002u32.to_le_bytes()); // extentref_tree_type @40
349        v.extend_from_slice(&0x1u32.to_le_bytes()); // flags @44
350        v.extend_from_slice(&6u16.to_le_bytes()); // name_len @48 ("snap1\0")
351        v.extend_from_slice(b"snap1\0"); // name @50
352
353        let s = parse_snap_metadata(42, &v);
354        assert_eq!(s.xid, 42);
355        assert_eq!(s.extentref_tree_oid, 0x11);
356        assert_eq!(s.sblock_oid, 0x22);
357        assert_eq!(s.create_time, 1000);
358        assert_eq!(s.change_time, 2000);
359        assert_eq!(s.inum, 99);
360        assert_eq!(s.flags, 0x1);
361        assert_eq!(s.name, "snap1");
362    }
363
364    #[test]
365    fn parse_snap_metadata_clamps_overlong_name() {
366        let mut v = vec![0u8; OFF_SNAP_NAME];
367        v[OFF_SNAP_NAME_LEN..OFF_SNAP_NAME_LEN + 2].copy_from_slice(&50000u16.to_le_bytes());
368        let s = parse_snap_metadata(1, &v);
369        assert_eq!(s.name, "");
370    }
371
372    #[test]
373    fn parse_snap_metadata_truncated_value_reads_zero() {
374        let s = parse_snap_metadata(7, &[0u8; 4]);
375        assert_eq!(s.sblock_oid, 0);
376        assert_eq!(s.create_time, 0);
377        assert_eq!(s.name, "");
378    }
379
380    #[test]
381    fn snap_meta_tree_subtype_is_snapmetatree() {
382        assert_eq!(snap_meta_tree_subtype(), 0x10);
383    }
384
385    /// Build an 8-byte `j_key` header word from a 4-bit type and a 60-bit oid.
386    fn jkey(ty: u64, oid: u64) -> [u8; 8] {
387        ((ty << 60) | oid).to_le_bytes()
388    }
389
390    #[test]
391    fn decode_snap_name_key_reads_name() {
392        // j_snap_name_key_t: j_key (SNAP_NAME=11) + name_len u16 @8 + name @10.
393        let mut key = Vec::new();
394        key.extend_from_slice(&jkey(11, 0)); // snap-name keys carry oid 0
395        key.extend_from_slice(&6u16.to_le_bytes()); // name_len = 6 ("snap1\0")
396        key.extend_from_slice(b"snap1\0");
397        assert_eq!(decode_snap_name_key(&key).as_deref(), Some("snap1"));
398    }
399
400    #[test]
401    fn decode_snap_name_key_rejects_zero_and_overlong() {
402        // name_len 0 -> None; name_len past the key -> None (no over-read).
403        let mut zero = Vec::new();
404        zero.extend_from_slice(&jkey(11, 0));
405        zero.extend_from_slice(&0u16.to_le_bytes());
406        assert_eq!(decode_snap_name_key(&zero), None);
407
408        let mut overlong = Vec::new();
409        overlong.extend_from_slice(&jkey(11, 0));
410        overlong.extend_from_slice(&200u16.to_le_bytes());
411        overlong.extend_from_slice(b"z");
412        assert_eq!(decode_snap_name_key(&overlong), None);
413    }
414
415    // The point-in-time seam: a snapshot's sblock_oid is a *physical* APSB block
416    // number, so mount_snapshot must read that block and parse it as an
417    // ApfsVolume identical to parsing the block directly. Validated against the
418    // real Apple-minted P4 fixture's live APSB (block 438) — a Snapshot whose
419    // sblock_oid points at it must "mount" to the same volume the existing
420    // navigation already reads.
421    const P4_CONTENT: &[u8] = include_bytes!("../../tests/data/apfs_content.bin");
422    const P4_BLOCK_SIZE: usize = 4096;
423    const P4_APSB_BLOCK: u64 = 438;
424
425    fn snapshot_pointing_at(sblock_oid: u64) -> Snapshot {
426        Snapshot {
427            xid: 0,
428            name: "synthetic-pointer".to_string(),
429            create_time: 0,
430            change_time: 0,
431            sblock_oid,
432            extentref_tree_oid: 0,
433            inum: 0,
434            flags: 0,
435        }
436    }
437
438    #[test]
439    fn mount_snapshot_reads_sblock_as_volume() {
440        use std::io::Cursor;
441        let mut r = Cursor::new(P4_CONTENT);
442        // The live volume is block 438; a snapshot whose sblock_oid is also 438
443        // mounts to the same superblock with the live omap grafted on.
444        let start = P4_APSB_BLOCK as usize * P4_BLOCK_SIZE;
445        let live =
446            ApfsVolume::parse(&P4_CONTENT[start..start + P4_BLOCK_SIZE]).expect("parse live APSB");
447        let snap = snapshot_pointing_at(P4_APSB_BLOCK);
448        let mounted = mount_snapshot(&mut r, &live, &snap, P4_BLOCK_SIZE).expect("mount snapshot");
449
450        // The frozen superblock supplies oid/xid/root-tree/name; the live omap is
451        // grafted on (here identical to the block's own, since live == the block).
452        assert_eq!(mounted.oid(), live.oid());
453        assert_eq!(mounted.xid(), live.xid());
454        assert_eq!(mounted.omap_oid(), live.omap_oid());
455        assert_eq!(mounted.root_tree_oid(), live.root_tree_oid());
456        assert_eq!(mounted.name(), live.name());
457        assert_eq!(mounted.name(), "APFSP4");
458    }
459
460    #[test]
461    fn mount_snapshot_grafts_live_omap_over_snapshot_zero() {
462        use std::io::Cursor;
463        // A real snapshot superblock has apfs_omap_oid == 0; mount_snapshot must
464        // graft the live volume's omap so the point-in-time view is navigable
465        // (resolving block 0 — the container superblock — would fail). Block 438
466        // has a non-zero omap; grafting a distinct live omap must take effect.
467        let mut r = Cursor::new(P4_CONTENT);
468        let start = P4_APSB_BLOCK as usize * P4_BLOCK_SIZE;
469        let block438 =
470            ApfsVolume::parse(&P4_CONTENT[start..start + P4_BLOCK_SIZE]).expect("parse APSB");
471        let live = block438.clone().with_omap_oid(999_111);
472        let snap = snapshot_pointing_at(P4_APSB_BLOCK);
473        let mounted = mount_snapshot(&mut r, &live, &snap, P4_BLOCK_SIZE).expect("mount snapshot");
474        assert_eq!(
475            mounted.omap_oid(),
476            999_111,
477            "the live volume's omap must be grafted onto the snapshot view"
478        );
479        // The frozen identity fields still come from the snapshot's own block.
480        assert_eq!(mounted.xid(), block438.xid());
481        assert_eq!(mounted.root_tree_oid(), block438.root_tree_oid());
482    }
483
484    #[test]
485    fn mount_snapshot_rejects_non_apsb_block() {
486        use std::io::Cursor;
487        let mut r = Cursor::new(P4_CONTENT);
488        let start = P4_APSB_BLOCK as usize * P4_BLOCK_SIZE;
489        let live =
490            ApfsVolume::parse(&P4_CONTENT[start..start + P4_BLOCK_SIZE]).expect("parse live APSB");
491        // Block 0 is the container superblock (NXSB), not a volume APSB; mounting
492        // it must fail loudly with UnexpectedObjectType, never silently succeed.
493        let snap = snapshot_pointing_at(0);
494        let err = mount_snapshot(&mut r, &live, &snap, P4_BLOCK_SIZE).unwrap_err();
495        assert!(
496            matches!(err, crate::ApfsError::UnexpectedObjectType { .. }),
497            "mounting a non-APSB block must fail loudly, got {err:?}"
498        );
499    }
500
501    // ---------------------------------------------------------------------
502    // Synthetic-image walk tests.
503    //
504    // The real P4 fixture validates tree *location* + the *empty* case on
505    // Apple-authored bytes; the populated-tree paths (leaf record dispatch,
506    // index-node virtual descent, checksum/cycle guards) need a tree that
507    // actually carries records. Minting a populated snapshot tree on this host
508    // is blocked by SIP (fs_snapshot_create requires an entitlement — see
509    // docs/validation.md), so the *walk algorithm* is exercised here against a
510    // hand-built, spec-faithful APFS micro-image: real obj_phys headers, real
511    // Fletcher-64 checksums, real variable-KV btree TOC/key/value layout
512    // (verified vs btree.rs + the Apple reference). This is a Tier-3 vector for
513    // the walk control flow only; every on-disk *offset/decode* it relies on is
514    // independently validated on real data by the P1–P5 fixtures.
515    const BS: usize = 4096;
516
517    /// Stamp a valid Fletcher-64 `o_cksum` into the first 8 bytes of `block`.
518    fn seal(block: &mut [u8]) {
519        let c = fletcher64_checksum(block);
520        block[0..8].copy_from_slice(&c.to_le_bytes());
521    }
522
523    /// Build a 32-byte `obj_phys_t` prefix into `block` (cksum left for `seal`).
524    fn obj_hdr(block: &mut [u8], oid: u64, xid: u64, o_type: u32, o_subtype: u32) {
525        block[8..16].copy_from_slice(&oid.to_le_bytes());
526        block[16..24].copy_from_slice(&xid.to_le_bytes());
527        block[24..28].copy_from_slice(&o_type.to_le_bytes());
528        block[28..32].copy_from_slice(&o_subtype.to_le_bytes());
529    }
530
531    /// Build a variable-KV B-tree node block (root) from `(key, value)` records.
532    /// `is_leaf` selects `BTNODE_LEAF`; a non-leaf node's values are 8-byte child
533    /// block numbers. Layout matches `btree::node_entries` exactly: a TOC of
534    /// 8-byte `kvloc_t` entries at the start of `btn_data`, keys growing forward
535    /// from the end of the TOC, values growing backward from the footer.
536    fn btree_node(oid: u64, xid: u64, is_leaf: bool, records: &[(Vec<u8>, Vec<u8>)]) -> Vec<u8> {
537        const FOOTER: usize = 40;
538        let mut b = vec![0u8; BS];
539        // btn_flags: ROOT(0x1) | (LEAF 0x2 if leaf). Variable-KV (no FIXED bit).
540        let flags: u16 = 0x1 | if is_leaf { 0x2 } else { 0 };
541        b[32..34].copy_from_slice(&flags.to_le_bytes());
542        let level: u16 = u16::from(!is_leaf);
543        b[34..36].copy_from_slice(&level.to_le_bytes());
544        b[36..40].copy_from_slice(&(records.len() as u32).to_le_bytes());
545
546        let toc_len = records.len() * 8;
547        // btn_table_space nloc: off (relative to btn_data @56) = 0, len = toc_len.
548        b[40..42].copy_from_slice(&0u16.to_le_bytes());
549        b[42..44].copy_from_slice(&(toc_len as u16).to_le_bytes());
550
551        let toc_start = 56; // btn_data + 0
552        let key_area = toc_start + toc_len;
553        let val_base = BS - FOOTER; // root: values reversed from footer start
554
555        let mut key_off = 0usize; // forward from key_area
556        let mut val_off = 0usize; // backward from val_base
557        for (i, (k, v)) in records.iter().enumerate() {
558            let e = toc_start + i * 8;
559            b[e..e + 2].copy_from_slice(&(key_off as u16).to_le_bytes());
560            b[e + 2..e + 4].copy_from_slice(&(k.len() as u16).to_le_bytes());
561            // value_offs is the reversed distance from val_base to the value end.
562            let this_val = v.len();
563            let v_reversed = val_off + this_val;
564            b[e + 4..e + 6].copy_from_slice(&(v_reversed as u16).to_le_bytes());
565            b[e + 6..e + 8].copy_from_slice(&(this_val as u16).to_le_bytes());
566
567            let ks = key_area + key_off;
568            b[ks..ks + k.len()].copy_from_slice(k);
569            let vs = val_base - v_reversed;
570            b[vs..vs + this_val].copy_from_slice(v);
571
572            key_off += k.len();
573            val_off += this_val;
574        }
575        obj_hdr(&mut b, oid, xid, 0x4000_0002, 0x10); // PHYSICAL|BTREE, SNAPMETATREE
576        seal(&mut b);
577        b
578    }
579
580    /// `j_key` header for a snap record: top-4 type, low-60 oid/xid.
581    fn snap_jkey(ty: u64, id: u64) -> Vec<u8> {
582        ((ty << 60) | id).to_le_bytes().to_vec()
583    }
584
585    /// A `j_snap_metadata_val_t` with a given `sblock_oid`, name, `create_time`.
586    fn snap_meta_val(sblock: u64, create: u64, name: &str) -> Vec<u8> {
587        let mut v = vec![0u8; OFF_SNAP_NAME];
588        v[OFF_SNAP_SBLOCK_OID..OFF_SNAP_SBLOCK_OID + 8].copy_from_slice(&sblock.to_le_bytes());
589        v[OFF_SNAP_CREATE_TIME..OFF_SNAP_CREATE_TIME + 8].copy_from_slice(&create.to_le_bytes());
590        let mut name_b = name.as_bytes().to_vec();
591        name_b.push(0);
592        v[OFF_SNAP_NAME_LEN..OFF_SNAP_NAME_LEN + 2]
593            .copy_from_slice(&(name_b.len() as u16).to_le_bytes());
594        v.extend_from_slice(&name_b);
595        v
596    }
597
598    /// A `j_snap_name_key_t` (name) record key.
599    fn snap_name_key(name: &str) -> Vec<u8> {
600        let mut k = snap_jkey(11, 0);
601        let mut name_b = name.as_bytes().to_vec();
602        name_b.push(0);
603        k.extend_from_slice(&(name_b.len() as u16).to_le_bytes());
604        k.extend_from_slice(&name_b);
605        k
606    }
607
608    /// Build a minimal valid APSB at `oid`/`xid` referencing `omap_oid` +
609    /// `snap_meta_tree_oid`.
610    fn apsb(oid: u64, xid: u64, omap_oid: u64, snap_meta_oid: u64) -> Vec<u8> {
611        let mut b = vec![0u8; BS];
612        b[32..36].copy_from_slice(&0x4253_5041u32.to_le_bytes()); // "APSB"
613        b[128..136].copy_from_slice(&omap_oid.to_le_bytes()); // apfs_omap_oid
614        b[152..160].copy_from_slice(&snap_meta_oid.to_le_bytes()); // snap_meta_tree
615        obj_hdr(&mut b, oid, xid, 0x0d, 0); // OBJECT_TYPE_FS
616        seal(&mut b);
617        b
618    }
619
620    /// Build a minimal volume omap (`omap_phys`) whose btree (a single physical
621    /// fixed-KV leaf at `tree_block`) maps `(virt_oid, xid) -> phys`.
622    fn omap_block(oid: u64, tree_block: u64) -> Vec<u8> {
623        let mut b = vec![0u8; BS];
624        b[40..44].copy_from_slice(&0x4000_0002u32.to_le_bytes()); // om_tree_type
625        b[48..56].copy_from_slice(&tree_block.to_le_bytes()); // om_tree_oid (physical)
626        obj_hdr(&mut b, oid, 0, 0x0b, 0); // OBJECT_TYPE_OMAP
627        seal(&mut b);
628        b
629    }
630
631    /// Build a fixed-KV omap btree leaf mapping each `(virt, xid) -> phys`.
632    fn omap_leaf(oid: u64, entries: &[(u64, u64, u64)]) -> Vec<u8> {
633        const FOOTER: usize = 40;
634        let mut b = vec![0u8; BS];
635        let flags: u16 = 0x1 | 0x2 | 0x4; // ROOT | LEAF | FIXED_KV_SIZE
636        b[32..34].copy_from_slice(&flags.to_le_bytes());
637        b[36..40].copy_from_slice(&(entries.len() as u32).to_le_bytes());
638        let toc_len = entries.len() * 4; // fixed TOC: key_offs,value_offs (u16,u16)
639        b[40..42].copy_from_slice(&0u16.to_le_bytes());
640        b[42..44].copy_from_slice(&(toc_len as u16).to_le_bytes());
641        let toc_start = 56;
642        let key_area = toc_start + toc_len;
643        let val_base = BS - FOOTER;
644        let mut key_off = 0usize;
645        let mut val_off = 0usize;
646        for (i, (virt, xid, phys)) in entries.iter().enumerate() {
647            let e = toc_start + i * 4;
648            b[e..e + 2].copy_from_slice(&(key_off as u16).to_le_bytes());
649            // omap_key { ok_oid u64, ok_xid u64 } = 16 bytes
650            let mut k = vec![0u8; 16];
651            k[0..8].copy_from_slice(&virt.to_le_bytes());
652            k[8..16].copy_from_slice(&xid.to_le_bytes());
653            let ks = key_area + key_off;
654            b[ks..ks + 16].copy_from_slice(&k);
655            // omap_val { ov_flags u32, ov_size u32, ov_paddr u64 } = 16 bytes
656            let mut v = vec![0u8; 16];
657            v[8..16].copy_from_slice(&phys.to_le_bytes());
658            let v_reversed = val_off + 16;
659            b[e + 2..e + 4].copy_from_slice(&(v_reversed as u16).to_le_bytes());
660            let vs = val_base - v_reversed;
661            b[vs..vs + 16].copy_from_slice(&v);
662            key_off += 16;
663            val_off += 16;
664        }
665        obj_hdr(&mut b, oid, 0, 0x4000_0002, 0x0b); // PHYSICAL|BTREE, omap subtype
666        seal(&mut b);
667        b
668    }
669
670    /// Assemble a block image (`Vec` of `(block_index, bytes)`) into one buffer.
671    fn image(blocks: &[(u64, Vec<u8>)]) -> Vec<u8> {
672        let max = blocks.iter().map(|(i, _)| *i).max().unwrap_or(0) as usize;
673        let mut buf = vec![0u8; (max + 1) * BS];
674        for (i, b) in blocks {
675            let off = *i as usize * BS;
676            buf[off..off + BS].copy_from_slice(b);
677        }
678        buf
679    }
680
681    /// A volume whose snap-meta tree root is a single leaf at `snap_tree_block`,
682    /// with `omap` at `omap_block_idx` (empty map — not needed for a leaf root).
683    fn single_leaf_volume(snap_records: &[(Vec<u8>, Vec<u8>)]) -> (Vec<u8>, ApfsVolume) {
684        let snap_leaf = btree_node(50, 7, true, snap_records);
685        let omap = omap_block(40, 41);
686        let omap_tree = omap_leaf(41, &[]); // no virtual nodes to resolve for a leaf root
687        let apsb_b = apsb(1026, 7, 40, 50);
688        let buf = image(&[(40, omap), (41, omap_tree), (50, snap_leaf), (1026, apsb_b)]);
689        let vol = ApfsVolume::parse(&buf[1026 * BS..1027 * BS]).expect("parse synth APSB");
690        (buf, vol)
691    }
692
693    #[test]
694    fn lists_snapshots_from_populated_leaf() {
695        use std::io::Cursor;
696        // A snap-meta leaf with two SNAP_METADATA records (xid 5, xid 9) plus a
697        // SNAP_NAME record. list_snapshots returns the two metadata records,
698        // sorted by xid; the name record is ignored by list_snapshots.
699        let records = vec![
700            (snap_jkey(1, 5), snap_meta_val(0x200, 1000, "snapA")),
701            (snap_jkey(1, 9), snap_meta_val(0x300, 2000, "snapB")),
702            (snap_name_key("snapA"), 5u64.to_le_bytes().to_vec()),
703        ];
704        let (buf, vol) = single_leaf_volume(&records);
705        let mut r = Cursor::new(buf);
706        let snaps = list_snapshots(&mut r, &vol, BS).expect("list");
707        assert_eq!(snaps.len(), 2);
708        assert_eq!(snaps[0].xid, 5);
709        assert_eq!(snaps[0].name, "snapA");
710        assert_eq!(snaps[0].sblock_oid, 0x200);
711        assert_eq!(snaps[0].create_time, 1000);
712        assert_eq!(snaps[1].xid, 9);
713        assert_eq!(snaps[1].name, "snapB");
714    }
715
716    #[test]
717    fn resolves_snap_name_from_populated_leaf() {
718        use std::io::Cursor;
719        let records = vec![
720            (snap_jkey(1, 5), snap_meta_val(0x200, 1000, "snapA")),
721            (snap_name_key("snapA"), 5u64.to_le_bytes().to_vec()),
722            (snap_name_key("snapB"), 9u64.to_le_bytes().to_vec()),
723        ];
724        let (buf, vol) = single_leaf_volume(&records);
725        let mut r = Cursor::new(buf);
726        assert_eq!(
727            resolve_snapshot_xid(&mut r, &vol, "snapB", BS).expect("resolve"),
728            Some(9)
729        );
730        assert_eq!(
731            resolve_snapshot_xid(&mut r, &vol, "snapA", BS).expect("resolve"),
732            Some(5)
733        );
734        assert_eq!(
735            resolve_snapshot_xid(&mut r, &vol, "absent", BS).expect("resolve"),
736            None
737        );
738    }
739
740    #[test]
741    fn walks_index_node_resolving_child_virtually() {
742        use std::io::Cursor;
743        // Snap-meta tree ROOT is an INDEX node (level 1) whose single child is a
744        // *virtual* oid (1500) resolved through the volume omap to a physical leaf
745        // block (60). This exercises the virtual sub-node resolve + descent path.
746        let leaf = btree_node(
747            60,
748            7,
749            true,
750            &[(snap_jkey(1, 3), snap_meta_val(0x99, 500, "s"))],
751        );
752        // Index root: one record whose value is the 8-byte child virtual oid 1500.
753        let index = btree_node(
754            50,
755            7,
756            false,
757            &[(snap_jkey(1, 3), 1500u64.to_le_bytes().to_vec())],
758        );
759        let omap = omap_block(40, 41);
760        // omap maps (virtual 1500, xid 7) -> physical block 60.
761        let omap_tree = omap_leaf(41, &[(1500, 7, 60)]);
762        let apsb_b = apsb(1026, 7, 40, 50);
763        let buf = image(&[
764            (40, omap),
765            (41, omap_tree),
766            (50, index),
767            (60, leaf),
768            (1026, apsb_b),
769        ]);
770        let vol = ApfsVolume::parse(&buf[1026 * BS..1027 * BS]).expect("parse APSB");
771        let mut r = Cursor::new(buf);
772        let snaps = list_snapshots(&mut r, &vol, BS).expect("list via index");
773        assert_eq!(snaps.len(), 1);
774        assert_eq!(snaps[0].xid, 3);
775        assert_eq!(snaps[0].sblock_oid, 0x99);
776    }
777
778    #[test]
779    fn snap_tree_checksum_mismatch_is_loud() {
780        use std::io::Cursor;
781        let records = vec![(snap_jkey(1, 5), snap_meta_val(0x200, 1000, "snapA"))];
782        let (mut buf, vol) = single_leaf_volume(&records);
783        // Corrupt the snap-meta leaf (block 50) body after its checksum was sealed.
784        buf[50 * BS + 100] ^= 0xff;
785        let mut r = Cursor::new(buf);
786        let err = list_snapshots(&mut r, &vol, BS).unwrap_err();
787        assert!(
788            matches!(err, crate::ApfsError::ChecksumMismatch { .. }),
789            "a corrupted snap-meta node must fail loudly, got {err:?}"
790        );
791    }
792
793    #[test]
794    fn snap_tree_cycle_is_rejected() {
795        use std::io::Cursor;
796        // An index root (block 50, virtual oid 50) whose child resolves back to
797        // block 50 — a cycle. The visited-set guard must reject it.
798        let index = btree_node(
799            50,
800            7,
801            false,
802            &[(snap_jkey(1, 3), 1500u64.to_le_bytes().to_vec())],
803        );
804        let omap = omap_block(40, 41);
805        // virtual 1500 -> physical 50 (the root again) => revisiting block oid 50.
806        let omap_tree = omap_leaf(41, &[(1500, 7, 50)]);
807        let apsb_b = apsb(1026, 7, 40, 50);
808        let buf = image(&[(40, omap), (41, omap_tree), (50, index), (1026, apsb_b)]);
809        let vol = ApfsVolume::parse(&buf[1026 * BS..1027 * BS]).expect("parse APSB");
810        let mut r = Cursor::new(buf);
811        let err = list_snapshots(&mut r, &vol, BS).unwrap_err();
812        assert!(
813            matches!(err, crate::ApfsError::CycleGuard { .. }),
814            "a cyclic snap-meta tree must be rejected, got {err:?}"
815        );
816    }
817}