Skip to main content

hfsplus_forensic/
decmpfs.rs

1//! HFS+/APFS transparent-compression (`decmpfs`) decoder.
2//!
3//! A compressed file carries a `com.apple.decmpfs` extended attribute whose
4//! 16-byte header selects an algorithm and a storage location (see
5//! [`forensicnomicon::decmpfs`]). This module turns that header — plus the
6//! file's resource fork when the payload lives there — back into the original
7//! file bytes.
8//!
9//! # Layouts (reverse-engineered; see [`forensicnomicon::decmpfs`] for sources)
10//!
11//! - **Inline** (odd types): the payload follows the 16-byte header in the
12//!   xattr. Zlib type 3 has a quirk — a leading `0xFF` byte means the remainder
13//!   is stored verbatim, not DEFLATE-compressed.
14//! - **Zlib resource fork** (type 4): a classic Resource-Manager header
15//!   (`HFSPlusCmpfRsrcHead`, big-endian `headerSize, totalSize, dataSize,
16//!   flags`) followed at `headerSize` by a block table (big-endian `dataSize`,
17//!   little-endian `numBlocks`, then `numBlocks × (offset, size)` little-endian,
18//!   offsets relative to `headerSize`). Each block is an independent zlib stream
19//!   that inflates to at most [`CHUNK_SIZE`] bytes.
20//! - **LZVN/LZFSE resource fork** (types 8/12): a `HFSPlusCmpfLZVNRsrcHead` —
21//!   little-endian `headerSize` then `headerSize/4 − 1` chunk **end-offsets**.
22//!   The first chunk starts at `headerSize`; chunk *i* spans
23//!   `[prev_end, end_offsets[i])`. Each chunk decodes to [`CHUNK_SIZE`] bytes
24//!   (the last is shorter). LZVN chunks are raw (no block header) and are framed
25//!   as a single-block LZFSE stream before decoding; LZFSE chunks are already
26//!   complete streams.
27
28use std::io::Read;
29
30use forensicnomicon::decmpfs::{self, Algorithm, Storage, CHUNK_SIZE, HEADER_LEN, MAGIC};
31
32/// A decmpfs decode failure. Every arm fails loud — decmpfs never degrades to
33/// silent wrong output (a half-decoded file is worse than a named error).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum DecmpfsError {
36    /// The xattr is shorter than the 16-byte header.
37    Truncated,
38    /// The header magic was not `cmpf`.
39    BadMagic(u32),
40    /// `compression_type` is not a documented value.
41    UnknownType(u32),
42    /// A documented but unsupported type (LZBitmap — no public spec; or the
43    /// de-dup generation store, type 5, which has no payload here).
44    Unsupported(&'static str),
45    /// An even (resource-fork) type was seen but no resource fork was supplied.
46    MissingResourceFork,
47    /// A length/offset field pointed outside the available bytes.
48    OutOfBounds,
49    /// The underlying codec rejected the stream.
50    Codec(&'static str),
51    /// The decoded output length did not match the header's `uncompressed_size`.
52    LengthMismatch {
53        /// `uncompressed_size` from the header.
54        expected: usize,
55        /// Bytes actually produced.
56        got: usize,
57    },
58}
59
60impl std::fmt::Display for DecmpfsError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::Truncated => write!(f, "decmpfs xattr shorter than 16-byte header"),
64            Self::BadMagic(m) => write!(f, "decmpfs bad magic {m:#010x} (expected 'cmpf')"),
65            Self::UnknownType(t) => write!(f, "decmpfs unknown compression_type {t}"),
66            Self::Unsupported(s) => write!(f, "decmpfs unsupported: {s}"),
67            Self::MissingResourceFork => {
68                write!(
69                    f,
70                    "decmpfs resource-fork type but no resource fork supplied"
71                )
72            }
73            Self::OutOfBounds => write!(f, "decmpfs length/offset field out of bounds"),
74            Self::Codec(s) => write!(f, "decmpfs codec error: {s}"),
75            Self::LengthMismatch { expected, got } => {
76                write!(f, "decmpfs length mismatch: expected {expected}, got {got}")
77            }
78        }
79    }
80}
81
82impl std::error::Error for DecmpfsError {}
83
84type Result<T> = std::result::Result<T, DecmpfsError>;
85
86/// Decode a decmpfs-compressed file to its original bytes.
87///
88/// `xattr` is the raw `com.apple.decmpfs` attribute (16-byte header, optionally
89/// followed by an inline payload). `resource_fork` is the file's resource fork,
90/// required only for even (resource-fork) compression types — pass `None` when
91/// the file has no resource fork.
92///
93/// # Errors
94///
95/// Returns a [`DecmpfsError`] on any malformed header, unsupported algorithm,
96/// missing resource fork, codec failure, or output-length mismatch. It never
97/// returns a partially-decoded buffer as success.
98pub fn decompress(xattr: &[u8], resource_fork: Option<&[u8]>) -> Result<Vec<u8>> {
99    if xattr.len() < HEADER_LEN {
100        return Err(DecmpfsError::Truncated);
101    }
102    let magic = le_u32(xattr, 0)?;
103    if magic != MAGIC {
104        return Err(DecmpfsError::BadMagic(magic));
105    }
106    let compression_type = le_u32(xattr, decmpfs::COMPRESSION_TYPE_OFFSET)?;
107    let uncompressed_size = le_u64(xattr, decmpfs::UNCOMPRESSED_SIZE_OFFSET)? as usize;
108
109    let Some(kind) = decmpfs::classify(compression_type) else {
110        return Err(match compression_type {
111            5 => DecmpfsError::Unsupported("decmpfs type 5 (de-dup generation store)"),
112            other => DecmpfsError::UnknownType(other),
113        });
114    };
115    if kind.algorithm == Algorithm::LzBitmap {
116        return Err(DecmpfsError::Unsupported(
117            "decmpfs LZBitmap (no public spec)",
118        ));
119    }
120
121    let out = match kind.storage {
122        Storage::Inline => {
123            let payload = xattr.get(HEADER_LEN..).ok_or(DecmpfsError::Truncated)?;
124            decode_inline(kind.algorithm, payload, uncompressed_size, compression_type)?
125        }
126        Storage::ResourceFork => {
127            let fork = resource_fork.ok_or(DecmpfsError::MissingResourceFork)?;
128            decode_resource_fork(kind.algorithm, fork, uncompressed_size)?
129        }
130    };
131
132    if out.len() != uncompressed_size {
133        return Err(DecmpfsError::LengthMismatch {
134            expected: uncompressed_size,
135            got: out.len(),
136        });
137    }
138    Ok(out)
139}
140
141/// Decode an inline (odd-type) payload that follows the 16-byte header.
142///
143/// `compression_type` is threaded in because two inline-uncompressed types
144/// share one [`Algorithm::Uncompressed`] but differ in framing: type 1 stores
145/// its bytes verbatim, while type 9 is a *marker-prefixed* variant (one leading
146/// byte before the raw data). That is a documented discontinuity in the decmpfs
147/// format — verified against go-apfs `decmpfs.go` (strips `AttrBytes[1:]` for
148/// `CMP_ATTR_UNCOMPRESSED`/type 9) and forensicnomicon's type table — not a
149/// special case. Confirmed on real macOS 26.5 type-9 files (the marker byte,
150/// 0xCC in those samples, precedes the verbatim content).
151fn decode_inline(
152    algorithm: Algorithm,
153    payload: &[u8],
154    uncompressed_size: usize,
155    compression_type: u32,
156) -> Result<Vec<u8>> {
157    match algorithm {
158        // Type 1: verbatim. Type 9: strip the one-byte storage marker.
159        Algorithm::Uncompressed => match compression_type {
160            9 => Ok(payload.get(1..).unwrap_or(&[]).to_vec()),
161            _ => Ok(payload.to_vec()),
162        },
163        Algorithm::Zlib => {
164            // A leading 0xFF means the remainder is stored verbatim (the file
165            // did not compress); otherwise the payload is a zlib stream.
166            match payload.first() {
167                Some(0xFF) => Ok(payload[1..].to_vec()),
168                _ => inflate(payload),
169            }
170        }
171        // A leading 0x06 (the LZVN end-of-stream opcode) marks an inline payload
172        // stored uncompressed after that marker (go-apfs `CMP_ATTR_LZVN`).
173        Algorithm::Lzvn => match payload.first() {
174            Some(0x06) => Ok(payload.get(1..).unwrap_or(&[]).to_vec()),
175            _ => lzvn_decode(payload, uncompressed_size),
176        },
177        Algorithm::Lzfse => lzfse_decode(payload),
178        // LzBitmap is rejected before dispatch; the arm keeps the match total
179        // against future `#[non_exhaustive]` Algorithm variants.
180        _ => Err(DecmpfsError::Unsupported("decmpfs unsupported algorithm")),
181    }
182}
183
184/// Decode an even-type payload stored across the resource fork.
185fn decode_resource_fork(
186    algorithm: Algorithm,
187    fork: &[u8],
188    uncompressed_size: usize,
189) -> Result<Vec<u8>> {
190    match algorithm {
191        Algorithm::Zlib => decode_zlib_resource_fork(fork, uncompressed_size),
192        Algorithm::Lzvn | Algorithm::Lzfse | Algorithm::Uncompressed => {
193            decode_chunked_resource_fork(algorithm, fork, uncompressed_size)
194        }
195        // LzBitmap is rejected before dispatch; arm keeps the match total.
196        _ => Err(DecmpfsError::Unsupported("decmpfs unsupported algorithm")),
197    }
198}
199
200/// Zlib resource fork (type 4): classic Resource-Manager header + block table.
201fn decode_zlib_resource_fork(fork: &[u8], uncompressed_size: usize) -> Result<Vec<u8>> {
202    // HFSPlusCmpfRsrcHead: big-endian headerSize, totalSize, dataSize, flags.
203    let header_size = be_u32(fork, 0)? as usize;
204    // At `header_size`: a big-endian total-size prefix (4 bytes), then the block
205    // table proper — little-endian numBlocks, then numBlocks × (offset, size)
206    // little-endian. Block offsets are relative to `header_size + 4` (the start
207    // of the block table, i.e. the numBlocks field), NOT to `header_size`.
208    // (Verified against real afsctool/macOS forks — a synthetic round-trip can
209    // pass with the wrong base because it is self-consistent.)
210    let table = header_size
211        .checked_add(4)
212        .ok_or(DecmpfsError::OutOfBounds)?;
213    let num_blocks = le_u32(fork, table)? as usize;
214    let mut out = Vec::with_capacity(uncompressed_size);
215    for i in 0..num_blocks {
216        let entry = table
217            .checked_add(4)
218            .and_then(|b| b.checked_add(i.checked_mul(8)?))
219            .ok_or(DecmpfsError::OutOfBounds)?;
220        let offset = le_u32(fork, entry)? as usize;
221        let size = le_u32(fork, entry + 4)? as usize;
222        let start = table.checked_add(offset).ok_or(DecmpfsError::OutOfBounds)?;
223        let end = start.checked_add(size).ok_or(DecmpfsError::OutOfBounds)?;
224        let block = fork.get(start..end).ok_or(DecmpfsError::OutOfBounds)?;
225        out.extend_from_slice(&inflate(block)?);
226    }
227    Ok(out)
228}
229
230/// LZVN/LZFSE/uncompressed resource fork (types 8/12/10):
231/// `HFSPlusCmpfLZVNRsrcHead` — little-endian headerSize then chunk end-offsets.
232fn decode_chunked_resource_fork(
233    algorithm: Algorithm,
234    fork: &[u8],
235    uncompressed_size: usize,
236) -> Result<Vec<u8>> {
237    let header_size = le_u32(fork, 0)? as usize;
238    // The header holds headerSize/4 − 1 chunk end-offsets (the first u32 is the
239    // headerSize itself). Chunk data begins at `header_size`.
240    // headerSize/4 − 1 is an *upper bound* on the chunk count: the compressor may
241    // over-allocate the end-offset table and zero-pad the unused slots (observed
242    // in afsctool LZFSE forks). The true count is ceil(uncompressed_size /
243    // CHUNK_SIZE); the loop stops once it has produced that many bytes, never
244    // reading a zero-padding slot.
245    let n_slots = (header_size / 4)
246        .checked_sub(1)
247        .ok_or(DecmpfsError::OutOfBounds)?;
248    let mut out = Vec::with_capacity(uncompressed_size);
249    let mut src = header_size;
250    for i in 0..n_slots {
251        if out.len() >= uncompressed_size {
252            break;
253        }
254        let end = le_u32(fork, 4 + i * 4)? as usize;
255        if end < src {
256            return Err(DecmpfsError::OutOfBounds);
257        }
258        let chunk = fork.get(src..end).ok_or(DecmpfsError::OutOfBounds)?;
259        // Each chunk decodes to CHUNK_SIZE bytes, except the last (the remainder).
260        let chunk_uncompressed = uncompressed_size
261            .checked_sub(out.len())
262            .ok_or(DecmpfsError::OutOfBounds)?
263            .min(CHUNK_SIZE);
264        let decoded = match algorithm {
265            Algorithm::Lzvn => lzvn_decode(chunk, chunk_uncompressed)?,
266            Algorithm::Lzfse => lzfse_decode(chunk)?,
267            Algorithm::Uncompressed => chunk.to_vec(),
268            // Zlib forks take the classic-header path; LzBitmap is rejected
269            // before dispatch. Either here means a routing bug, not bad input.
270            _ => return Err(DecmpfsError::Codec("unexpected algorithm for chunked fork")),
271        };
272        out.extend_from_slice(&decoded);
273        src = end;
274    }
275    Ok(out)
276}
277
278/// Inflate a zlib stream (DEFLATE with a zlib wrapper).
279fn inflate(data: &[u8]) -> Result<Vec<u8>> {
280    let mut decoder = flate2::read::ZlibDecoder::new(data);
281    let mut out = Vec::new();
282    decoder
283        .read_to_end(&mut out)
284        .map_err(|_| DecmpfsError::Codec("zlib"))?;
285    Ok(out)
286}
287
288/// Decode a raw LZVN chunk with the length-tolerant `lzvn` codec.
289///
290/// A real macOS `decmpfs` resource-fork block ends with the LZVN end-of-stream
291/// opcode and is then followed by 80–300 trailing bytes that the kernel ignores.
292/// The previous bvxn+LZFSE-stream framing declared those trailing bytes as
293/// payload, so a strict whole-stream decoder (lzfse_rust) rejected every real
294/// Tahoe type-8 file. [`lzvn::decode`] stops at the end-of-stream opcode, so it
295/// reads the genuine blocks. (Validated 25/25 on macOS 26.5 vs 0/25 before.)
296fn lzvn_decode(chunk: &[u8], uncompressed_len: usize) -> Result<Vec<u8>> {
297    lzvn::decode(chunk, uncompressed_len).map_err(|_| DecmpfsError::Codec("lzvn"))
298}
299
300/// Decode a complete LZFSE stream.
301fn lzfse_decode(stream: &[u8]) -> Result<Vec<u8>> {
302    let mut out = Vec::new();
303    lzfse_rust::decode_bytes(stream, &mut out).map_err(|_| DecmpfsError::Codec("lzfse/lzvn"))?;
304    Ok(out)
305}
306
307// ── bounds-checked little/big-endian readers (panic-free) ──
308
309fn le_u32(data: &[u8], offset: usize) -> Result<u32> {
310    let end = offset.checked_add(4).ok_or(DecmpfsError::OutOfBounds)?;
311    let bytes = data.get(offset..end).ok_or(DecmpfsError::OutOfBounds)?;
312    Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
313}
314
315fn be_u32(data: &[u8], offset: usize) -> Result<u32> {
316    let end = offset.checked_add(4).ok_or(DecmpfsError::OutOfBounds)?;
317    let bytes = data.get(offset..end).ok_or(DecmpfsError::OutOfBounds)?;
318    Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
319}
320
321fn le_u64(data: &[u8], offset: usize) -> Result<u64> {
322    let end = offset.checked_add(8).ok_or(DecmpfsError::OutOfBounds)?;
323    let bytes = data.get(offset..end).ok_or(DecmpfsError::OutOfBounds)?;
324    let mut a = [0u8; 8];
325    a.copy_from_slice(bytes);
326    Ok(u64::from_le_bytes(a))
327}
328
329#[cfg(test)]
330#[allow(clippy::unwrap_used, clippy::expect_used)]
331mod tests {
332    use super::*;
333
334    /// Build a 16-byte decmpfs header for a given type + uncompressed size.
335    fn header(compression_type: u32, uncompressed_size: u64) -> Vec<u8> {
336        let mut h = Vec::with_capacity(16);
337        h.extend_from_slice(&MAGIC.to_le_bytes());
338        h.extend_from_slice(&compression_type.to_le_bytes());
339        h.extend_from_slice(&uncompressed_size.to_le_bytes());
340        h
341    }
342
343    fn xattr(compression_type: u32, uncompressed_size: u64, payload: &[u8]) -> Vec<u8> {
344        let mut x = header(compression_type, uncompressed_size);
345        x.extend_from_slice(payload);
346        x
347    }
348
349    // ── REAL macOS data: type-8 LZVN resource fork (ditto --hfsCompression) ──
350    #[test]
351    fn decodes_real_macos_lzvn_resource_fork() {
352        let fork = include_bytes!("../tests/data/decmpfs/lzvn.rsrc");
353        let expected = include_bytes!("../tests/data/decmpfs/lzvn.expected");
354        let hdr = header(8, expected.len() as u64);
355        let out = decompress(&hdr, Some(fork)).expect("real LZVN must decode");
356        assert_eq!(out, expected, "decoded bytes must match the original file");
357    }
358
359    // ── REAL macOS zlib resource fork (type 4), minted by afsctool -T ZLIB ──
360    #[test]
361    fn decodes_real_macos_zlib_resource_fork() {
362        let fork = include_bytes!("../tests/data/decmpfs/real_zlib_rsrc.rsrc");
363        let expected = include_bytes!("../tests/data/decmpfs/zlib.expected");
364        let hdr = header(4, expected.len() as u64);
365        let out = decompress(&hdr, Some(fork)).expect("real type-4 zlib must decode");
366        assert_eq!(out, expected);
367    }
368
369    // ── REAL macOS inline zlib (type 3), afsctool -T ZLIB on a small file ──
370    #[test]
371    fn decodes_real_macos_inline_zlib() {
372        let payload = include_bytes!("../tests/data/decmpfs/real_zlib_inline.payload");
373        let expected = include_bytes!("../tests/data/decmpfs/real_zlib_inline.expected");
374        let x = xattr(3, expected.len() as u64, payload);
375        let out = decompress(&x, None).expect("real type-3 inline zlib must decode");
376        assert_eq!(out, expected);
377    }
378
379    // ── inline zlib type 3 with the 0xFF "stored" marker ──
380    #[test]
381    fn decodes_inline_zlib_stored_marker() {
382        let payload = include_bytes!("../tests/data/decmpfs/zlib_type3_stored.payload");
383        let expected = include_bytes!("../tests/data/decmpfs/zlib_inline.expected");
384        let x = xattr(3, expected.len() as u64, payload);
385        let out = decompress(&x, None).expect("0xFF-stored type-3 must decode");
386        assert_eq!(out, expected);
387    }
388
389    // ── inline uncompressed (type 1) ──
390    #[test]
391    fn decodes_inline_uncompressed() {
392        let data = b"the quick brown fox jumps over the lazy dog";
393        let x = xattr(1, data.len() as u64, data);
394        let out = decompress(&x, None).expect("type-1 uncompressed must decode");
395        assert_eq!(out, data);
396    }
397
398    /// Build an even-type chunked resource fork (`HFSPlusCmpfLZVNRsrcHead`):
399    /// little-endian `headerSize` then one end-offset per chunk.
400    fn chunked_fork(chunks: &[Vec<u8>]) -> Vec<u8> {
401        let header_size = 4 * (chunks.len() + 1);
402        let mut fork = Vec::new();
403        fork.extend_from_slice(&(header_size as u32).to_le_bytes());
404        let mut end = header_size;
405        for c in chunks {
406            end += c.len();
407            fork.extend_from_slice(&(end as u32).to_le_bytes());
408        }
409        for c in chunks {
410            fork.extend_from_slice(c);
411        }
412        fork
413    }
414
415    // ── REAL macOS LZFSE resource fork (type 12), minted by afsctool -T LZFSE ──
416    #[test]
417    fn decodes_real_macos_lzfse_resource_fork() {
418        let fork = include_bytes!("../tests/data/decmpfs/real_lzfse_rsrc.rsrc");
419        let expected = include_bytes!("../tests/data/decmpfs/zlib.expected"); // 150K real text
420        let hdr = header(12, expected.len() as u64);
421        let out = decompress(&hdr, Some(fork)).expect("real type-12 LZFSE must decode");
422        assert_eq!(out, expected);
423    }
424
425    // ── REAL macOS inline LZFSE (type 11), afsctool -T LZFSE on a small file ──
426    #[test]
427    fn decodes_real_macos_inline_lzfse() {
428        let payload = include_bytes!("../tests/data/decmpfs/real_lzfse_inline.payload");
429        let expected = include_bytes!("../tests/data/decmpfs/real_zlib_inline.expected");
430        let x = xattr(11, expected.len() as u64, payload);
431        let out = decompress(&x, None).expect("real type-11 inline LZFSE must decode");
432        assert_eq!(out, expected);
433    }
434
435    // ── uncompressed resource fork (type 10): verbatim chunks ──
436    #[test]
437    fn decodes_uncompressed_resource_fork() {
438        let mut data = Vec::new();
439        for i in 0..(CHUNK_SIZE + 5000) {
440            data.push((i % 251) as u8);
441        }
442        let c0 = data[..CHUNK_SIZE].to_vec();
443        let c1 = data[CHUNK_SIZE..].to_vec();
444        let fork = chunked_fork(&[c0, c1]);
445        let hdr = header(10, data.len() as u64);
446        let out = decompress(&hdr, Some(&fork)).expect("type-10 uncompressed fork must decode");
447        assert_eq!(out, data);
448    }
449
450    // ── inline uncompressed variant (type 9) ──
451    #[test]
452    fn decodes_inline_uncompressed_type9() {
453        // Type 9 is a *marker-prefixed* variant of type 1: one storage-marker
454        // byte precedes the verbatim content, and uncompressed_size counts the
455        // content only. (Confirmed on real macOS 26.5 files; see the
456        // `tahoe_type9` fixture below. The earlier verbatim-only form was a
457        // synthetic-fixture bug that real data exposed.)
458        let content = b"type 9 is uncompressed-inline, a variant of type 1";
459        let mut payload = vec![0xCC];
460        payload.extend_from_slice(content);
461        let x = xattr(9, content.len() as u64, &payload);
462        assert_eq!(decompress(&x, None).expect("type-9 must decode"), content);
463    }
464
465    // ── REAL macOS 26.5 (Tahoe) type-8 LZVN resource fork WITH trailing bytes
466    //    after the end-of-stream opcode — the case strict decoders reject. ──
467    #[test]
468    fn decodes_real_tahoe_type8_lzvn_with_trailing_bytes() {
469        let fork = include_bytes!("../tests/data/decmpfs/tahoe_type8.rsrc");
470        let expected = include_bytes!("../tests/data/decmpfs/tahoe_type8.expected");
471        let hdr = header(8, expected.len() as u64);
472        let out = decompress(&hdr, Some(fork)).expect("Tahoe LZVN must decode");
473        assert_eq!(out.as_slice(), expected.as_slice());
474    }
475
476    // ── REAL macOS 26.5 (Tahoe) type-9 inline xattr with its 1-byte marker. ──
477    #[test]
478    fn decodes_real_tahoe_type9_inline_marker() {
479        let xattr_bytes = include_bytes!("../tests/data/decmpfs/tahoe_type9.decmpfs");
480        let expected = include_bytes!("../tests/data/decmpfs/tahoe_type9.expected");
481        let out = decompress(xattr_bytes, None).expect("Tahoe type-9 must decode");
482        assert_eq!(out.as_slice(), expected.as_slice());
483    }
484
485    // ── a wrong uncompressed_size must fail loud, not return a short buffer ──
486    #[test]
487    fn length_mismatch_is_loud() {
488        let data = b"the quick brown fox";
489        let x = xattr(1, 999, data); // claim 999, payload is 19
490        assert!(matches!(
491            decompress(&x, None),
492            Err(DecmpfsError::LengthMismatch { expected: 999, .. })
493        ));
494    }
495
496    // ── fail-loud arms ──
497    #[test]
498    fn rejects_bad_magic() {
499        let mut x = xattr(1, 0, &[]);
500        x[0] ^= 0xFF;
501        assert!(matches!(
502            decompress(&x, None),
503            Err(DecmpfsError::BadMagic(_))
504        ));
505    }
506
507    #[test]
508    fn rejects_truncated_header() {
509        assert_eq!(decompress(&[0u8; 8], None), Err(DecmpfsError::Truncated));
510    }
511
512    #[test]
513    fn rejects_unknown_type() {
514        let x = xattr(99, 0, &[]);
515        assert_eq!(decompress(&x, None), Err(DecmpfsError::UnknownType(99)));
516    }
517
518    #[test]
519    fn rejects_lzbitmap_unsupported() {
520        let x = xattr(14, 0, &[]);
521        assert!(matches!(
522            decompress(&x, None),
523            Err(DecmpfsError::Unsupported(_))
524        ));
525    }
526
527    #[test]
528    fn rejects_dedup_type5_unsupported() {
529        let x = xattr(5, 0, &[]);
530        assert!(matches!(
531            decompress(&x, None),
532            Err(DecmpfsError::Unsupported(_))
533        ));
534    }
535
536    #[test]
537    fn resource_fork_type_without_fork_errors() {
538        let hdr = header(8, 100);
539        assert_eq!(
540            decompress(&hdr, None),
541            Err(DecmpfsError::MissingResourceFork)
542        );
543    }
544}