Skip to main content

apfs_core/
compression.rs

1//! Transparent decmpfs decompression (REUSE — not reinvented).
2//!
3//! APFS reuses HFS+'s `com.apple.decmpfs` scheme verbatim: a 16-byte header
4//! (`MAGIC 0x636d_7066` "cmpf", a compression-type byte, uncompressed size),
5//! `CHUNK_SIZE 65536`, payload either embedded in the xattr (odd types) or in the
6//! `com.apple.ResourceFork` stream (even types). The fleet already solved and
7//! validated this against real macOS in `hfsplus-forensic`; this module is the
8//! thin APFS-side glue over the same codec stack:
9//!
10//! - **type→algorithm/storage map**: [`forensicnomicon::decmpfs::classify`] —
11//!   used here, never re-defined.
12//! - **zlib/DEFLATE (types 3/4)**: `flate2`.
13//! - **LZVN (types 7/8)**: our length-tolerant `lzvn` crate (`lzvn-core`) — real
14//!   decmpfs LZVN blocks carry trailing bytes after the end-of-stream opcode that
15//!   `lzfse_rust`'s strict path rejects (validated 25/25 on macOS Tahoe by
16//!   hfsplus-forensic; the bug that motivated the length-tolerant decoder).
17//! - **LZFSE (types 11/12)**: `lzfse_rust`.
18//!
19//! All pure-Rust, preserving `unsafe_code = "forbid"`. The only APFS-specific
20//! part is locating the decmpfs payload (xattr vs resource-fork stream). Any
21//! decode failure is a **named refusal** ([`crate::ApfsError::Decmpfs`]) — never
22//! fabricated plaintext (fail-loud: fabricating file content is the worst bug in
23//! a forensic tool).
24
25use std::io::{Read, Seek};
26
27use forensicnomicon::decmpfs::{
28    self, Algorithm, Storage, CHUNK_SIZE, COMPRESSION_TYPE_OFFSET, HEADER_LEN, MAGIC,
29    UNCOMPRESSED_SIZE_OFFSET,
30};
31
32use crate::inode::Inode;
33use crate::volume::ApfsVolume;
34use crate::ApfsError;
35
36/// Read a transparently-compressed file's content: decode the decmpfs payload
37/// (inline in the `header` xattr, or in the `com.apple.ResourceFork` stream)
38/// rather than the raw extent bytes.
39///
40/// # Errors
41/// [`ApfsError::Decmpfs`] on any malformed header, unknown/unsupported
42/// compression type, missing resource fork, codec failure, or output-length
43/// mismatch (the decode never returns a partial buffer as success); the
44/// structural errors of [`crate::xattr::resource_fork`] when fetching the fork.
45pub fn read_compressed<R: Read + Seek>(
46    reader: &mut R,
47    volume: &ApfsVolume,
48    inode: &Inode,
49    header: &[u8],
50    block_size: usize,
51) -> crate::Result<Vec<u8>> {
52    // Resolve the compression type to decide whether a resource fork is needed
53    // before paying to read it.
54    let compression_type = le_u32(header, COMPRESSION_TYPE_OFFSET)?;
55    let needs_fork =
56        decmpfs::classify(compression_type).is_some_and(|c| c.storage == Storage::ResourceFork);
57
58    let fork = if needs_fork {
59        crate::xattr::resource_fork(reader, volume, inode.oid, block_size)?
60    } else {
61        None
62    };
63
64    decompress_decmpfs(header, fork.as_deref())
65}
66
67/// Decode a decmpfs payload given its 16-byte header and the file's resource fork
68/// (required only for even/resource-fork compression types; `None` for inline).
69///
70/// Mirrors the validated `hfsplus-forensic` decoder. Returns the original file
71/// bytes, or a named [`ApfsError::Decmpfs`] — never a partially-decoded buffer.
72///
73/// # Errors
74/// [`ApfsError::Decmpfs`] on a truncated/bad-magic header, an unknown or
75/// unsupported `compression_type`, a missing resource fork, a codec rejection, or
76/// a decoded length that disagrees with the header's `uncompressed_size`.
77pub fn decompress_decmpfs(header: &[u8], resource_fork: Option<&[u8]>) -> crate::Result<Vec<u8>> {
78    if header.len() < HEADER_LEN {
79        return Err(ApfsError::Decmpfs(
80            "decmpfs xattr shorter than 16-byte header",
81        ));
82    }
83    let magic = le_u32(header, 0)?;
84    if magic != MAGIC {
85        return Err(ApfsError::Decmpfs("decmpfs bad magic (expected 'cmpf')"));
86    }
87    let compression_type = le_u32(header, COMPRESSION_TYPE_OFFSET)?;
88    let uncompressed_size = le_u64(header, UNCOMPRESSED_SIZE_OFFSET)? as usize;
89
90    let Some(kind) = decmpfs::classify(compression_type) else {
91        return Err(match compression_type {
92            5 => ApfsError::Decmpfs("decmpfs type 5 (de-dup generation store, no payload)"),
93            _ => ApfsError::Decmpfs("decmpfs unknown compression_type"),
94        });
95    };
96    if kind.algorithm == Algorithm::LzBitmap {
97        return Err(ApfsError::Decmpfs("decmpfs LZBitmap (no public spec)"));
98    }
99
100    let out = match kind.storage {
101        Storage::Inline => {
102            let payload = header
103                .get(HEADER_LEN..)
104                .ok_or(ApfsError::Decmpfs("decmpfs inline payload truncated"))?;
105            decode_inline(kind.algorithm, payload, uncompressed_size, compression_type)?
106        }
107        Storage::ResourceFork => {
108            let fork = resource_fork.ok_or(ApfsError::Decmpfs(
109                "decmpfs resource-fork type but no fork present",
110            ))?;
111            decode_resource_fork(kind.algorithm, fork, uncompressed_size)?
112        }
113    };
114
115    if out.len() != uncompressed_size {
116        return Err(ApfsError::Decmpfs(
117            "decmpfs decoded length != uncompressed_size",
118        ));
119    }
120    Ok(out)
121}
122
123/// Decode an inline (odd-type) payload that follows the 16-byte header.
124///
125/// `compression_type` is threaded in for the two inline-uncompressed types that
126/// share [`Algorithm::Uncompressed`] but differ in framing: type 1 stores its
127/// bytes verbatim, type 9 is marker-prefixed (one leading byte). That is a
128/// documented decmpfs discontinuity (confirmed on real macOS 26.5 type-9 files in
129/// hfsplus-forensic), not a special case.
130fn decode_inline(
131    algorithm: Algorithm,
132    payload: &[u8],
133    uncompressed_size: usize,
134    compression_type: u32,
135) -> crate::Result<Vec<u8>> {
136    match algorithm {
137        Algorithm::Uncompressed => match compression_type {
138            9 => Ok(payload.get(1..).unwrap_or(&[]).to_vec()),
139            _ => Ok(payload.to_vec()),
140        },
141        Algorithm::Zlib => match payload.first() {
142            // A leading 0xFF means the remainder is stored verbatim.
143            Some(0xFF) => Ok(payload.get(1..).unwrap_or(&[]).to_vec()),
144            _ => inflate(payload),
145        },
146        Algorithm::Lzvn => match payload.first() {
147            // A leading 0x06 (the LZVN end-of-stream opcode) marks raw-stored data.
148            Some(0x06) => Ok(payload.get(1..).unwrap_or(&[]).to_vec()),
149            _ => lzvn_decode(payload, uncompressed_size),
150        },
151        Algorithm::Lzfse => lzfse_decode(payload),
152        _ => unreachable_algorithm(), // cov:unreachable: LzBitmap rejected before dispatch
153    }
154}
155
156/// Decode an even-type payload stored across the resource fork.
157fn decode_resource_fork(
158    algorithm: Algorithm,
159    fork: &[u8],
160    uncompressed_size: usize,
161) -> crate::Result<Vec<u8>> {
162    match algorithm {
163        Algorithm::Zlib => decode_zlib_resource_fork(fork, uncompressed_size),
164        Algorithm::Lzvn | Algorithm::Lzfse | Algorithm::Uncompressed => {
165            decode_chunked_resource_fork(algorithm, fork, uncompressed_size)
166        }
167        _ => unreachable_algorithm(), // cov:unreachable: LzBitmap rejected before dispatch
168    }
169}
170
171/// The decmpfs algorithm dispatch arms below are total against the
172/// `#[non_exhaustive]` [`Algorithm`] enum, but `LzBitmap` is rejected before any
173/// dispatch and every other variant is routed, so this arm is unreachable on real
174/// and crafted input alike — kept as defense-in-depth against a future variant.
175#[inline]
176fn unreachable_algorithm() -> crate::Result<Vec<u8>> {
177    // cov:unreachable: LzBitmap rejected pre-dispatch, all other variants routed
178    Err(ApfsError::Decmpfs("decmpfs unsupported algorithm")) // cov:unreachable
179}
180
181/// Zlib resource fork (type 4): classic Resource-Manager header + block table.
182fn decode_zlib_resource_fork(fork: &[u8], uncompressed_size: usize) -> crate::Result<Vec<u8>> {
183    // HFSPlusCmpfRsrcHead: big-endian headerSize, totalSize, dataSize, flags.
184    let header_size = be_u32(fork, 0)? as usize;
185    // At `header_size`: a big-endian total-size prefix (4 bytes), then the block
186    // table — little-endian numBlocks, then numBlocks × (offset, size). Block
187    // offsets are relative to `header_size + 4` (the numBlocks field).
188    let table = header_size.checked_add(4).ok_or(ApfsError::Decmpfs(
189        "decmpfs zlib fork table offset overflow",
190    ))?;
191    let num_blocks = le_u32(fork, table)? as usize;
192    let mut out = Vec::with_capacity(uncompressed_size.min(MAX_DECMPFS_CAP));
193    for i in 0..num_blocks {
194        let entry = table
195            .checked_add(4)
196            .and_then(|b| b.checked_add(i.checked_mul(8)?))
197            .ok_or(ApfsError::Decmpfs(
198                "decmpfs zlib fork entry offset overflow",
199            ))?;
200        let offset = le_u32(fork, entry)? as usize;
201        let size = le_u32(fork, entry + 4)? as usize;
202        let start = table
203            .checked_add(offset)
204            .ok_or(ApfsError::Decmpfs("decmpfs zlib fork block start overflow"))?;
205        let end = start
206            .checked_add(size)
207            .ok_or(ApfsError::Decmpfs("decmpfs zlib fork block end overflow"))?;
208        let block = fork
209            .get(start..end)
210            .ok_or(ApfsError::Decmpfs("decmpfs zlib fork block out of bounds"))?;
211        out.extend_from_slice(&inflate(block)?);
212    }
213    Ok(out)
214}
215
216/// LZVN/LZFSE/uncompressed resource fork (types 8/12/10):
217/// `HFSPlusCmpfLZVNRsrcHead` — little-endian headerSize then chunk end-offsets.
218fn decode_chunked_resource_fork(
219    algorithm: Algorithm,
220    fork: &[u8],
221    uncompressed_size: usize,
222) -> crate::Result<Vec<u8>> {
223    let header_size = le_u32(fork, 0)? as usize;
224    // The header holds headerSize/4 − 1 chunk end-offsets (the first u32 is the
225    // headerSize itself); chunk data begins at `header_size`. The slot count is
226    // an upper bound — the compressor may zero-pad unused slots, so the loop stops
227    // once it has produced `uncompressed_size` bytes (true count = ceil(size /
228    // CHUNK_SIZE)).
229    let n_slots = (header_size / 4)
230        .checked_sub(1)
231        .ok_or(ApfsError::Decmpfs("decmpfs chunked fork header too small"))?;
232    let mut out = Vec::with_capacity(uncompressed_size.min(MAX_DECMPFS_CAP));
233    let mut src = header_size;
234    for i in 0..n_slots {
235        if out.len() >= uncompressed_size {
236            break;
237        }
238        let end = le_u32(fork, 4 + i * 4)? as usize;
239        if end < src {
240            return Err(ApfsError::Decmpfs(
241                "decmpfs chunked fork end-offset goes backward",
242            ));
243        }
244        let chunk = fork.get(src..end).ok_or(ApfsError::Decmpfs(
245            "decmpfs chunked fork chunk out of bounds",
246        ))?;
247        let chunk_uncompressed = uncompressed_size
248            .checked_sub(out.len())
249            .ok_or(ApfsError::Decmpfs("decmpfs chunked fork size underflow"))? // cov:unreachable: loop breaks when out.len() >= uncompressed_size
250            .min(CHUNK_SIZE);
251        let decoded = match algorithm {
252            Algorithm::Lzvn => lzvn_decode(chunk, chunk_uncompressed)?,
253            Algorithm::Lzfse => lzfse_decode(chunk)?,
254            Algorithm::Uncompressed => chunk.to_vec(),
255            // Zlib forks take the classic-header path; LzBitmap is rejected before
256            // dispatch. Either here is a routing bug, not bad input.
257            _ => return unreachable_algorithm(), // cov:unreachable: only Lzvn/Lzfse/Uncompressed routed here
258        };
259        out.extend_from_slice(&decoded);
260        src = end;
261    }
262    Ok(out)
263}
264
265/// Allocation cap for a decmpfs `Vec::with_capacity` hint — never trust the
266/// header's `uncompressed_size` to pre-allocate unbounded memory.
267const MAX_DECMPFS_CAP: usize = 1 << 30; // 1 GiB
268
269/// Inflate a zlib stream (DEFLATE with a zlib wrapper).
270fn inflate(data: &[u8]) -> crate::Result<Vec<u8>> {
271    let mut decoder = flate2::read::ZlibDecoder::new(data);
272    let mut out = Vec::new();
273    decoder
274        .read_to_end(&mut out)
275        .map_err(|_| ApfsError::Decmpfs("decmpfs zlib codec error"))?;
276    Ok(out)
277}
278
279/// Decode a raw LZVN chunk with the length-tolerant `lzvn` codec (stops at the
280/// end-of-stream opcode, ignoring decmpfs trailing bytes).
281fn lzvn_decode(chunk: &[u8], uncompressed_len: usize) -> crate::Result<Vec<u8>> {
282    lzvn::decode(chunk, uncompressed_len)
283        .map_err(|_| ApfsError::Decmpfs("decmpfs lzvn codec error"))
284}
285
286/// Decode a complete LZFSE stream.
287fn lzfse_decode(stream: &[u8]) -> crate::Result<Vec<u8>> {
288    let mut out = Vec::new();
289    lzfse_rust::decode_bytes(stream, &mut out)
290        .map_err(|_| ApfsError::Decmpfs("decmpfs lzfse codec error"))?;
291    Ok(out)
292}
293
294// ── bounds-checked little/big-endian readers (panic-free) ──
295
296fn le_u32(data: &[u8], offset: usize) -> crate::Result<u32> {
297    let end = offset
298        .checked_add(4)
299        .ok_or(ApfsError::Decmpfs("decmpfs read offset overflow"))?;
300    let bytes = data
301        .get(offset..end)
302        .ok_or(ApfsError::Decmpfs("decmpfs read out of bounds"))?;
303    Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
304}
305
306fn be_u32(data: &[u8], offset: usize) -> crate::Result<u32> {
307    let end = offset
308        .checked_add(4)
309        .ok_or(ApfsError::Decmpfs("decmpfs read offset overflow"))?;
310    let bytes = data
311        .get(offset..end)
312        .ok_or(ApfsError::Decmpfs("decmpfs read out of bounds"))?;
313    Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
314}
315
316fn le_u64(data: &[u8], offset: usize) -> crate::Result<u64> {
317    let end = offset
318        .checked_add(8)
319        .ok_or(ApfsError::Decmpfs("decmpfs read offset overflow"))?;
320    let bytes = data
321        .get(offset..end)
322        .ok_or(ApfsError::Decmpfs("decmpfs read out of bounds"))?;
323    let mut a = [0u8; 8];
324    a.copy_from_slice(bytes);
325    Ok(u64::from_le_bytes(a))
326}