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    Err(ApfsError::Decmpfs("decmpfs unsupported algorithm")) // cov:unreachable: LzBitmap rejected pre-dispatch, all other variants routed
178}
179
180/// Zlib resource fork (type 4): classic Resource-Manager header + block table.
181fn decode_zlib_resource_fork(fork: &[u8], uncompressed_size: usize) -> crate::Result<Vec<u8>> {
182    // HFSPlusCmpfRsrcHead: big-endian headerSize, totalSize, dataSize, flags.
183    let header_size = be_u32(fork, 0)? as usize;
184    // At `header_size`: a big-endian total-size prefix (4 bytes), then the block
185    // table — little-endian numBlocks, then numBlocks × (offset, size). Block
186    // offsets are relative to `header_size + 4` (the numBlocks field).
187    let table = header_size.checked_add(4).ok_or(ApfsError::Decmpfs(
188        "decmpfs zlib fork table offset overflow",
189    ))?;
190    let num_blocks = le_u32(fork, table)? as usize;
191    let mut out = Vec::with_capacity(uncompressed_size.min(MAX_DECMPFS_CAP));
192    for i in 0..num_blocks {
193        let entry = table
194            .checked_add(4)
195            .and_then(|b| b.checked_add(i.checked_mul(8)?))
196            .ok_or(ApfsError::Decmpfs(
197                "decmpfs zlib fork entry offset overflow",
198            ))?;
199        let offset = le_u32(fork, entry)? as usize;
200        let size = le_u32(fork, entry + 4)? as usize;
201        let start = table
202            .checked_add(offset)
203            .ok_or(ApfsError::Decmpfs("decmpfs zlib fork block start overflow"))?;
204        let end = start
205            .checked_add(size)
206            .ok_or(ApfsError::Decmpfs("decmpfs zlib fork block end overflow"))?;
207        let block = fork
208            .get(start..end)
209            .ok_or(ApfsError::Decmpfs("decmpfs zlib fork block out of bounds"))?;
210        out.extend_from_slice(&inflate(block)?);
211    }
212    Ok(out)
213}
214
215/// LZVN/LZFSE/uncompressed resource fork (types 8/12/10):
216/// `HFSPlusCmpfLZVNRsrcHead` — little-endian headerSize then chunk end-offsets.
217fn decode_chunked_resource_fork(
218    algorithm: Algorithm,
219    fork: &[u8],
220    uncompressed_size: usize,
221) -> crate::Result<Vec<u8>> {
222    let header_size = le_u32(fork, 0)? as usize;
223    // The header holds headerSize/4 − 1 chunk end-offsets (the first u32 is the
224    // headerSize itself); chunk data begins at `header_size`. The slot count is
225    // an upper bound — the compressor may zero-pad unused slots, so the loop stops
226    // once it has produced `uncompressed_size` bytes (true count = ceil(size /
227    // CHUNK_SIZE)).
228    let n_slots = (header_size / 4)
229        .checked_sub(1)
230        .ok_or(ApfsError::Decmpfs("decmpfs chunked fork header too small"))?;
231    let mut out = Vec::with_capacity(uncompressed_size.min(MAX_DECMPFS_CAP));
232    let mut src = header_size;
233    for i in 0..n_slots {
234        if out.len() >= uncompressed_size {
235            break;
236        }
237        let end = le_u32(fork, 4 + i * 4)? as usize;
238        if end < src {
239            return Err(ApfsError::Decmpfs(
240                "decmpfs chunked fork end-offset goes backward",
241            ));
242        }
243        let chunk = fork.get(src..end).ok_or(ApfsError::Decmpfs(
244            "decmpfs chunked fork chunk out of bounds",
245        ))?;
246        let chunk_uncompressed = uncompressed_size
247            .checked_sub(out.len())
248            .ok_or(ApfsError::Decmpfs("decmpfs chunked fork size underflow"))? // cov:unreachable: loop breaks when out.len() >= uncompressed_size
249            .min(CHUNK_SIZE);
250        let decoded = match algorithm {
251            Algorithm::Lzvn => lzvn_decode(chunk, chunk_uncompressed)?,
252            Algorithm::Lzfse => lzfse_decode(chunk)?,
253            Algorithm::Uncompressed => chunk.to_vec(),
254            // Zlib forks take the classic-header path; LzBitmap is rejected before
255            // dispatch. Either here is a routing bug, not bad input.
256            _ => return unreachable_algorithm(), // cov:unreachable: only Lzvn/Lzfse/Uncompressed routed here
257        };
258        out.extend_from_slice(&decoded);
259        src = end;
260    }
261    Ok(out)
262}
263
264/// Allocation cap for a decmpfs `Vec::with_capacity` hint — never trust the
265/// header's `uncompressed_size` to pre-allocate unbounded memory.
266const MAX_DECMPFS_CAP: usize = 1 << 30; // 1 GiB
267
268/// Inflate a zlib stream (DEFLATE with a zlib wrapper).
269fn inflate(data: &[u8]) -> crate::Result<Vec<u8>> {
270    let mut decoder = flate2::read::ZlibDecoder::new(data);
271    let mut out = Vec::new();
272    decoder
273        .read_to_end(&mut out)
274        .map_err(|_| ApfsError::Decmpfs("decmpfs zlib codec error"))?;
275    Ok(out)
276}
277
278/// Decode a raw LZVN chunk with the length-tolerant `lzvn` codec (stops at the
279/// end-of-stream opcode, ignoring decmpfs trailing bytes).
280fn lzvn_decode(chunk: &[u8], uncompressed_len: usize) -> crate::Result<Vec<u8>> {
281    lzvn::decode(chunk, uncompressed_len)
282        .map_err(|_| ApfsError::Decmpfs("decmpfs lzvn codec error"))
283}
284
285/// Decode a complete LZFSE stream.
286fn lzfse_decode(stream: &[u8]) -> crate::Result<Vec<u8>> {
287    let mut out = Vec::new();
288    lzfse_rust::decode_bytes(stream, &mut out)
289        .map_err(|_| ApfsError::Decmpfs("decmpfs lzfse codec error"))?;
290    Ok(out)
291}
292
293// ── bounds-checked little/big-endian readers (panic-free) ──
294
295fn le_u32(data: &[u8], offset: usize) -> crate::Result<u32> {
296    let end = offset
297        .checked_add(4)
298        .ok_or(ApfsError::Decmpfs("decmpfs read offset overflow"))?;
299    let bytes = data
300        .get(offset..end)
301        .ok_or(ApfsError::Decmpfs("decmpfs read out of bounds"))?;
302    Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
303}
304
305fn be_u32(data: &[u8], offset: usize) -> crate::Result<u32> {
306    let end = offset
307        .checked_add(4)
308        .ok_or(ApfsError::Decmpfs("decmpfs read offset overflow"))?;
309    let bytes = data
310        .get(offset..end)
311        .ok_or(ApfsError::Decmpfs("decmpfs read out of bounds"))?;
312    Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
313}
314
315fn le_u64(data: &[u8], offset: usize) -> crate::Result<u64> {
316    let end = offset
317        .checked_add(8)
318        .ok_or(ApfsError::Decmpfs("decmpfs read offset overflow"))?;
319    let bytes = data
320        .get(offset..end)
321        .ok_or(ApfsError::Decmpfs("decmpfs read out of bounds"))?;
322    let mut a = [0u8; 8];
323    a.copy_from_slice(bytes);
324    Ok(u64::from_le_bytes(a))
325}