Skip to main content

apfs_core/
extent.rs

1//! File extents (`APFS_TYPE_FILE_EXTENT 8`, value `j_file_extent_val_t`) and
2//! file byte assembly.
3//!
4//! A file's content is described by a data stream (the inode's `private_id`,
5//! also surfaced as the `INO_EXT_TYPE_DSTREAM` xfield giving the logical `size`)
6//! plus a series of `FILE_EXTENT` records keyed by `(private_id, FILE_EXTENT,
7//! logical_offset)`. Each `j_file_extent_val_t { u64 len_and_flags; u64
8//! phys_block_num; u64 crypto_id }` (Apple *APFS Reference*) carries the extent
9//! length (`len_and_flags & J_FILE_EXTENT_LEN_MASK 0x00ffffffffffffff`, a
10//! multiple of the block size) and the starting physical block. A
11//! `phys_block_num` of 0 is a **sparse hole** — it reads back as zeroes.
12//!
13//! [`read_data`] assembles extents in logical order into plaintext bytes,
14//! truncating to the inode's DSTREAM `size`; if the inode carries a
15//! `com.apple.decmpfs` xattr, [`crate::compression`] is applied transparently
16//! over the decmpfs payload (inline xattr or `com.apple.ResourceFork` stream)
17//! instead of the regular extent stream.
18
19use std::io::{Read, Seek};
20
21use crate::dir::for_each_fs_record_for_oid;
22use crate::fsrecord::{decode_jkey, RecordType};
23use crate::inode::Inode;
24use crate::volume::ApfsVolume;
25
26/// Mask for the extent length within `len_and_flags` (`J_FILE_EXTENT_LEN_MASK`).
27pub const J_FILE_EXTENT_LEN_MASK: u64 = 0x00ff_ffff_ffff_ffff;
28
29// j_file_extent_key_t: the logical offset is the second u64 of the key, after
30// the 8-byte j_key header.
31const OFF_FEXT_KEY_LOGICAL: usize = 8;
32// j_file_extent_val_t field offsets.
33const OFF_FEXT_LEN_AND_FLAGS: usize = 0;
34const OFF_FEXT_PHYS_BLOCK: usize = 8;
35// j_file_extent_val_t.crypto_id -- the AES-XTS tweak for this extent's data on
36// an encrypted volume. It is STORED rather than derived from position because
37// APFS may relocate an extent without re-encrypting it, at which point the
38// block address stops predicting the tweak.
39const OFF_FEXT_CRYPTO_ID: usize = 16;
40
41/// Hard cap on assembled file size (allocation-bomb defense). A single image we
42/// would read in memory cannot legitimately exceed this; a DSTREAM `size` or an
43/// extent run beyond it is rejected loudly rather than allocated.
44const MAX_FILE_BYTES: u64 = 1 << 34; // 16 GiB
45
46/// One file extent (a `j_file_extent_val_t` plus its logical offset).
47#[derive(Debug, Clone, Copy)]
48#[non_exhaustive]
49pub struct FileExtent {
50    /// Logical offset within the file (from the record key).
51    pub logical_offset: u64,
52    /// Length in bytes (`len_and_flags & J_FILE_EXTENT_LEN_MASK`).
53    pub len: u64,
54    /// Starting physical block (0 = sparse hole).
55    pub phys_block_num: u64,
56    /// `crypto_id` — the AES-XTS tweak of this extent's first block on an
57    /// encrypted volume. Zero means the data is not encrypted.
58    pub crypto_id: u64,
59}
60
61/// List the `FILE_EXTENT` records of a data stream (`stream_oid`, an inode's
62/// `private_id` for the main fork or a resource-fork dstream id), sorted by
63/// logical offset.
64///
65/// A `phys_block_num` of 0 is preserved as a sparse hole; callers zero-fill it.
66///
67/// # Errors
68/// [`crate::ApfsError::OmapUnresolved`] / [`crate::ApfsError::ChecksumMismatch`]
69/// / [`crate::ApfsError::CycleGuard`] / [`crate::ApfsError::Io`] on a
70/// structurally invalid omap/fs-tree or a read failure.
71pub fn list_extents<R: Read + Seek>(
72    reader: &mut R,
73    volume: &ApfsVolume,
74    stream_oid: u64,
75    block_size: usize,
76) -> crate::Result<Vec<FileExtent>> {
77    let mut out = Vec::new();
78    for_each_fs_record_for_oid(reader, volume, block_size, stream_oid, &mut |key, value| {
79        let (oid, ty) = decode_jkey(crate::bytes::le_u64(key, 0));
80        if ty != Some(RecordType::FileExtent) || oid != stream_oid {
81            return;
82        }
83        let logical_offset = crate::bytes::le_u64(key, OFF_FEXT_KEY_LOGICAL);
84        let len = crate::bytes::le_u64(value, OFF_FEXT_LEN_AND_FLAGS) & J_FILE_EXTENT_LEN_MASK;
85        let phys_block_num = crate::bytes::le_u64(value, OFF_FEXT_PHYS_BLOCK);
86        out.push(FileExtent {
87            logical_offset,
88            len,
89            phys_block_num,
90            crypto_id: crate::bytes::le_u64(value, OFF_FEXT_CRYPTO_ID),
91        });
92    })?;
93    out.sort_by_key(|e| e.logical_offset);
94    Ok(out)
95}
96
97/// Assemble the raw extent stream of `stream_oid` into a byte buffer, zero-filling
98/// sparse holes, then truncate/zero-extend to exactly `logical_size` bytes.
99///
100/// This is the *physical* read: it does not apply decmpfs. [`read_data`] uses it
101/// for an ordinary file and to fetch a decmpfs resource-fork stream.
102///
103/// # Errors
104/// [`crate::ApfsError::FieldOutOfRange`] if `logical_size` or an extent block
105/// number exceeds the sanity caps / the image, plus the structural errors of
106/// [`list_extents`].
107pub fn read_stream<R: Read + Seek>(
108    reader: &mut R,
109    volume: &ApfsVolume,
110    stream_oid: u64,
111    logical_size: u64,
112    block_size: usize,
113) -> crate::Result<Vec<u8>> {
114    if logical_size > MAX_FILE_BYTES {
115        return Err(crate::ApfsError::FieldOutOfRange {
116            structure: "j_dstream",
117            field: "size",
118            value: logical_size,
119            cap: MAX_FILE_BYTES,
120        });
121    }
122    let extents = list_extents(reader, volume, stream_oid, block_size)?;
123
124    let size = logical_size as usize;
125    let mut out = vec![0u8; size];
126    for ext in &extents {
127        // Only fill within the logical size; extents past EOF (alloced tail) are
128        // ignored, holes leave the pre-zeroed region untouched.
129        let start = ext.logical_offset as usize;
130        if start >= size || ext.phys_block_num == 0 {
131            continue;
132        }
133        let avail = size - start;
134        let copy_len = (ext.len as usize).min(avail);
135        // Range-check the physical span against the image before reading.
136        let byte_off = ext.phys_block_num.checked_mul(block_size as u64).ok_or(
137            crate::ApfsError::FieldOutOfRange {
138                structure: "j_file_extent",
139                field: "phys_block_num",
140                value: ext.phys_block_num,
141                cap: u64::MAX / block_size as u64,
142            },
143        )?;
144        reader.seek(std::io::SeekFrom::Start(byte_off))?;
145
146        match volume.vek() {
147            Some(vek) if ext.crypto_id != 0 => {
148                // AES-XTS is a SECTOR transform: block j within a sector is
149                // whitened by tweak * alpha^j, so a partial sector cannot be
150                // decrypted on its own. Reading only the logical bytes -- a
151                // sound optimisation for plaintext -- silently yields the first
152                // 16 bytes correct and garbage after, because the short tail
153                // takes the ciphertext-stealing path. Read WHOLE blocks, then
154                // slice.
155                let nblocks = copy_len.div_ceil(block_size);
156                let mut tmp = vec![0u8; nblocks * block_size];
157                reader.read_exact(&mut tmp)?;
158                // The tweak advances one per BLOCK across the extent, so each
159                // block is decrypted with crypto_id + its index.
160                for (i, blk) in tmp.chunks_mut(block_size).enumerate() {
161                    crate::encryption::decrypt_volume_area(
162                        blk,
163                        vek,
164                        ext.crypto_id.saturating_add(i as u64),
165                        block_size,
166                    );
167                }
168                out[start..start + copy_len].copy_from_slice(&tmp[..copy_len]);
169            }
170            _ => reader.read_exact(&mut out[start..start + copy_len])?,
171        }
172    }
173    Ok(out)
174}
175
176/// Assemble a file's full byte content, applying transparent decmpfs compression
177/// when the inode carries a `com.apple.decmpfs` xattr.
178///
179/// For an ordinary file, the data stream (`inode.private_id`) is read extent by
180/// extent (sparse holes zero-filled) and truncated to the inode's logical
181/// `size`. For a transparently-compressed file, the decmpfs payload (inline in
182/// the xattr or in the `com.apple.ResourceFork` stream) is decoded via
183/// [`crate::compression`] — never the raw extent bytes.
184///
185/// # Errors
186/// [`crate::ApfsError::Decmpfs`] if a decmpfs file's payload cannot be decoded
187/// (a named codec/format error — never fabricated bytes); the structural errors
188/// of [`read_stream`] otherwise.
189pub fn read_data<R: Read + Seek>(
190    reader: &mut R,
191    volume: &ApfsVolume,
192    inode: &Inode,
193    block_size: usize,
194) -> crate::Result<Vec<u8>> {
195    // A compressed file is identified by a `com.apple.decmpfs` xattr.
196    if let Some(header) = crate::xattr::decmpfs_header(reader, volume, inode.oid, block_size)? {
197        return crate::compression::read_compressed(reader, volume, inode, &header, block_size);
198    }
199
200    let size = inode.size.unwrap_or(0);
201    read_stream(reader, volume, inode.private_id, size, block_size)
202}