archmeld 1.3.0

Secure, memory-safe, type-safe CLI for multi-format archive extraction, inspection and decompression
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
//! Archive format identification by magic bytes and file extension.
//!
// Binary format detection: indexing and arithmetic are fundamental
// to magic byte inspection. Safety is ensured by bounds checks.
#![allow(clippy::indexing_slicing)]
#![allow(clippy::arithmetic_side_effects)]

use std::fmt;
use std::path::Path;

/// Known archive and compression formats.
///
/// Detection is by magic bytes first and file extension only as a fallback, so
/// a variant here says what the *bytes* look like — never what the filename
/// claims. That ordering is deliberate: extension-first detection is how a
/// `.txt` that is really a zip bomb gets handed to an extractor.
///
/// Not every variant is extractable. [`Rar`](Self::Rar), [`Arc`](Self::Arc) and
/// [`Zoo`](Self::Zoo) are recognised so archmeld can report them precisely
/// instead of falling through to [`Unknown`](Self::Unknown); attempting to
/// extract one yields [`Error::UnsupportedFormat`](crate::error::Error::UnsupportedFormat).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
pub enum ArchiveFormat {
    /// ZIP container (`PK\x03\x04`).
    Zip,
    /// Uncompressed POSIX tar.
    Tar,
    /// tar wrapped in gzip.
    TarGz,
    /// tar wrapped in bzip2.
    TarBz2,
    /// tar wrapped in XZ.
    TarXz,
    /// tar wrapped in Zstandard.
    TarZst,
    /// tar wrapped in LZ4.
    TarLz4,
    /// 7-Zip container (`7z\xBC\xAF\x27\x1C`).
    SevenZip,
    /// Bare gzip stream, not wrapping a tar.
    Gz,
    /// Bare bzip2 stream, not wrapping a tar.
    Bz2,
    /// Bare XZ stream, not wrapping a tar.
    Xz,
    /// Bare LZ4 frame, not wrapping a tar.
    Lz4,
    /// Bare Zstandard frame, not wrapping a tar.
    Zstd,
    /// Raw LZMA stream (the pre-XZ alone format).
    Lzma,
    /// LHA / LZH container.
    Lha,
    /// RAR container — recognised for reporting, not extractable.
    Rar,
    /// ARC container — recognised for reporting, not extractable.
    Arc,
    /// ZOO container — recognised for reporting, not extractable.
    Zoo,
    /// XAR container, as used by macOS `.pkg` installers.
    Xar,
    /// `DirectDraw` Surface texture — inspected, never extracted.
    Dds,
    /// `StuffIt` container (classic Mac).
    StuffIt,
    /// Compact Pro container (classic Mac).
    CompactPro,
    /// No magic matched and no extension resolved it.
    Unknown,
}

impl fmt::Display for ArchiveFormat {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Zip => write!(f, "ZIP"),
            Self::Tar => write!(f, "TAR"),
            Self::TarGz => write!(f, "TAR.GZ"),
            Self::TarBz2 => write!(f, "TAR.BZ2"),
            Self::TarXz => write!(f, "TAR.XZ"),
            Self::TarZst => write!(f, "TAR.ZSTD"),
            Self::TarLz4 => write!(f, "TAR.LZ4"),
            Self::SevenZip => write!(f, "7Z"),
            Self::Gz => write!(f, "GZIP"),
            Self::Bz2 => write!(f, "BZIP2"),
            Self::Xz => write!(f, "XZ"),
            Self::Lz4 => write!(f, "LZ4"),
            Self::Zstd => write!(f, "ZSTD"),
            Self::Lzma => write!(f, "LZMA"),
            Self::Lha => write!(f, "LHA"),
            Self::Rar => write!(f, "RAR"),
            Self::Arc => write!(f, "ARC"),
            Self::Zoo => write!(f, "ZOO"),
            Self::Xar => write!(f, "XAR"),
            Self::Dds => write!(f, "DDS"),
            Self::StuffIt => write!(f, "StuffIt"),
            Self::CompactPro => write!(f, "Compact Pro"),
            Self::Unknown => write!(f, "Unknown"),
        }
    }
}

/// Magic byte signatures for format detection.
const ZIP_MAGIC: &[u8] = &[0x50, 0x4B, 0x03, 0x04];
const GZ_MAGIC: &[u8] = &[0x1F, 0x8B];
const BZ2_MAGIC: &[u8] = &[0x42, 0x5A, 0x68];
const XZ_MAGIC: &[u8] = &[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00];
const SEVENZ_MAGIC: &[u8] = &[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C];
const ZSTD_MAGIC: &[u8] = &[0x28, 0xB5, 0x2F, 0xFD];
const LZ4_FRAME_MAGIC: &[u8] = &[0x04, 0x22, 0x4D, 0x18];
const STUFFIT_MAGIC: &[u8] = b"SIT!";
const STUFFIT5_MAGIC: &[u8] = b"StuffIt";
const COMPACT_PRO_MAGIC: u8 = 0x01;
const RAR_MAGIC: &[u8] = &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00];
const RAR5_MAGIC: &[u8] = &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00];
const ZOO_MAGIC: &[u8] = &[0xDC, 0xA7, 0xC4, 0xFD];
const XAR_MAGIC: &[u8] = &[0x78, 0x61, 0x72, 0x21]; // "xar!"
const DDS_MAGIC: &[u8] = &[0x44, 0x44, 0x53, 0x20]; // "DDS "

/// Magic signatures matched at offset 0, in priority order.
///
/// Longest and rarest first: RAR5's 8-byte magic has to be tested before
/// RAR3/4's 7-byte prefix of it, or every RAR5 archive would be reported as
/// RAR3. `starts_with` already handles the length check, so no separate
/// minimum is needed.
///
/// Only prefix matches belong here. Formats whose magic sits at a non-zero
/// offset (ZOO at 20, tar at 257) or that need a heuristic (LZMA, ARC) stay in
/// [`detect_format`] below, because a table of prefixes cannot express them.
const PREFIX_MAGIC_TABLE: &[(&[u8], ArchiveFormat)] = &[
    (XAR_MAGIC, ArchiveFormat::Xar),
    (DDS_MAGIC, ArchiveFormat::Dds),
    (ZIP_MAGIC, ArchiveFormat::Zip),
    (RAR5_MAGIC, ArchiveFormat::Rar),
    (RAR_MAGIC, ArchiveFormat::Rar),
    (SEVENZ_MAGIC, ArchiveFormat::SevenZip),
    (XZ_MAGIC, ArchiveFormat::Xz),
    (ZSTD_MAGIC, ArchiveFormat::Zstd),
    (LZ4_FRAME_MAGIC, ArchiveFormat::Lz4),
    (GZ_MAGIC, ArchiveFormat::Gz),
    (BZ2_MAGIC, ArchiveFormat::Bz2),
    (STUFFIT_MAGIC, ArchiveFormat::StuffIt),
    (STUFFIT5_MAGIC, ArchiveFormat::StuffIt),
];

/// Detect archive format from raw bytes (magic number inspection).
///
/// Detection order: most specific (longest/rarest magic) first.
#[must_use]
pub fn detect_format(data: &[u8]) -> ArchiveFormat {
    if data.len() < 4 {
        return ArchiveFormat::Unknown;
    }

    for &(magic, format) in PREFIX_MAGIC_TABLE {
        if data.starts_with(magic) {
            return format;
        }
    }

    detect_format_by_structure(data)
}

/// Formats that a prefix table cannot express: magic at a non-zero offset, or
/// a heuristic rather than a literal signature.
///
/// Split out of [`detect_format`] so the table above stays a table. Order is
/// still the contract — the ARC test is two loose bytes and would swallow
/// anything reaching it, so it must come after every stricter check.
fn detect_format_by_structure(data: &[u8]) -> ArchiveFormat {
    // Compact Pro: 0x01 0x01 at offset 0.
    if data[0] == COMPACT_PRO_MAGIC && data[1] == 0x01 {
        return ArchiveFormat::CompactPro;
    }
    // LHA/LZH: 5-byte method identifier at offset 2 (-lhN- or -lzN-)
    if data.len() >= 7 && is_lha_magic(&data[2..7]) {
        return ArchiveFormat::Lha;
    }
    // ZOO: magic at offset 20
    if data.len() >= 24 && data[20..24] == *ZOO_MAGIC {
        return ArchiveFormat::Zoo;
    }
    // LZMA: heuristic — properties byte + valid dictionary size
    if data.len() >= 13 && is_lzma_header(data) {
        return ArchiveFormat::Lzma;
    }
    // ARC: 0x1A marker + method byte 0–9
    if data[0] == 0x1A && data[1] <= 9 {
        return ArchiveFormat::Arc;
    }
    // TAR detection: check for "ustar" at offset 257
    if data.len() > 262 && &data[257..262] == b"ustar" {
        return ArchiveFormat::Tar;
    }

    ArchiveFormat::Unknown
}

/// Check if 5 bytes form a valid LHA method identifier.
fn is_lha_magic(magic: &[u8]) -> bool {
    if magic.len() < 5 || magic[0] != b'-' || magic[4] != b'-' {
        return false;
    }
    // -lhN- where N is 0-9, d, s, x
    if magic[1] == b'l' && magic[2] == b'h' {
        return magic[3].is_ascii_digit()
            || magic[3] == b'd'
            || magic[3] == b's'
            || magic[3] == b'x';
    }
    // -lzN- where N is 4, 5, s
    if magic[1] == b'l' && magic[2] == b'z' {
        return magic[3] == b'4' || magic[3] == b'5' || magic[3] == b's';
    }
    // -pmN- where N is 0, 1, 2
    if magic[1] == b'p' && magic[2] == b'm' {
        return magic[3] == b'0' || magic[3] == b'1' || magic[3] == b'2';
    }
    false
}

/// Heuristic check for standalone LZMA header.
///
/// LZMA header: properties byte (0–224) + 4-byte dictionary size
/// (LE, 4 KiB–1.5 GiB) + 8-byte uncompressed size.
fn is_lzma_header(data: &[u8]) -> bool {
    if data.len() < 13 {
        return false;
    }
    // Properties byte: lc + 9*(lp + 5*pb)
    // Valid range: lc ∈ [0,8], lp ∈ [0,4], pb ∈ [0,4]
    // Max value: 8 + 9*(4 + 5*4) = 224 (0xE0)
    let props = data[0];
    if props > 224 {
        return false;
    }
    // Dictionary size (u32 LE) — typically 4 KiB to 1.5 GiB
    let dict_size = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
    if dict_size == 0 {
        return false;
    }
    // Common LZMA dict sizes are powers of 2 or adjacent values
    // Accept any non-zero dict size up to 1.5 GiB
    let max_dict = 1_610_612_736; // 1.5 GiB
    if dict_size > max_dict {
        return false;
    }
    // Most common properties byte is 0x5D (lc=3, lp=0, pb=2)
    // Also check that remaining bytes look plausible (not all zeros)
    props == 0x5D || (dict_size.is_power_of_two() && dict_size >= 4096)
}

/// Filename suffixes that resolve a format, in priority order.
///
/// Compound suffixes come first on purpose: `.tar.gz` has to win over `.gz`.
/// Adding a format means adding a row here, not another branch in
/// [`detect_format_from_path`].
const EXTENSION_TABLE: &[(&[&str], ArchiveFormat)] = &[
    (&[".tar.gz", ".tgz"], ArchiveFormat::TarGz),
    (&[".tar.bz2", ".tbz2"], ArchiveFormat::TarBz2),
    (&[".tar.xz", ".txz"], ArchiveFormat::TarXz),
    (&[".tar.zst", ".tzst"], ArchiveFormat::TarZst),
    (&[".tar.lz4"], ArchiveFormat::TarLz4),
    (&[".tar"], ArchiveFormat::Tar),
    (&[".sit", ".sitx"], ArchiveFormat::StuffIt),
    (&[".cpt"], ArchiveFormat::CompactPro),
    (&[".7z"], ArchiveFormat::SevenZip),
    (&[".lzma"], ArchiveFormat::Lzma),
    (&[".lzh", ".lha"], ArchiveFormat::Lha),
    (&[".rar"], ArchiveFormat::Rar),
    (&[".arc"], ArchiveFormat::Arc),
    (&[".zoo"], ArchiveFormat::Zoo),
    (&[".xar", ".pkg"], ArchiveFormat::Xar),
    (&[".dds"], ArchiveFormat::Dds),
];

/// Detect archive format from file extension, refining magic-byte detection.
#[must_use]
#[allow(clippy::case_sensitive_file_extension_comparisons)]
pub fn detect_format_from_path(path: &Path, data: &[u8]) -> ArchiveFormat {
    let magic = detect_format(data);

    // If magic detection was conclusive for non-compound
    // formats, use it directly
    if !matches!(
        magic,
        ArchiveFormat::Gz
            | ArchiveFormat::Bz2
            | ArchiveFormat::Xz
            | ArchiveFormat::Zstd
            | ArchiveFormat::Lz4
            | ArchiveFormat::Unknown
    ) {
        return magic;
    }

    // Use extension to disambiguate compound formats (e.g., tar.gz vs plain gz)
    let name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();

    // Dispatch table, not an if/else ladder: the ORDER is the contract.
    // ".tar.gz" must be tested before ".gz" or a tarball would be reported as
    // a bare gzip stream, and the table makes that ordering visible in one
    // place instead of spread across twenty early returns.
    for &(suffixes, format) in EXTENSION_TABLE {
        if suffixes.iter().any(|suffix| name.ends_with(suffix)) {
            return format;
        }
    }

    // Fall back to magic detection
    magic
}

/// Information about a detected format.
#[derive(Debug, serde::Serialize)]
pub struct FormatInfo {
    /// The detected format itself.
    pub format: ArchiveFormat,
    /// Human-readable name, for CLI output.
    pub description: String,
    /// IANA media type, or `application/octet-stream` when none is registered.
    pub mime_type: String,
    /// Whether the format is a *container* holding multiple named entries.
    ///
    /// False for single-stream compressors such as [`Gz`](ArchiveFormat::Gz):
    /// they carry one payload and no directory, so listing entries is not a
    /// meaningful operation on them.
    pub is_archive: bool,
    /// Whether the payload is compressed.
    ///
    /// Orthogonal to [`is_archive`](Self::is_archive): plain
    /// [`Tar`](ArchiveFormat::Tar) is an archive that is not compressed, and
    /// [`Gz`](ArchiveFormat::Gz) is compressed without being an archive.
    pub is_compressed: bool,
}

/// Get detailed information about a format.
#[must_use]
pub fn format_info(format: ArchiveFormat) -> FormatInfo {
    match format {
        ArchiveFormat::Zip => FormatInfo {
            format,
            description: "ZIP archive (PKZIP compatible)".into(),
            mime_type: "application/zip".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::Tar => FormatInfo {
            format,
            description: "POSIX TAR archive (uncompressed)".into(),
            mime_type: "application/x-tar".into(),
            is_archive: true,
            is_compressed: false,
        },
        ArchiveFormat::TarGz => FormatInfo {
            format,
            description: "TAR archive compressed with gzip".into(),
            mime_type: "application/gzip".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::TarBz2 => FormatInfo {
            format,
            description: "TAR archive compressed with bzip2".into(),
            mime_type: "application/x-bzip2".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::TarXz => FormatInfo {
            format,
            description: "TAR archive compressed with XZ/LZMA2".into(),
            mime_type: "application/x-xz".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::TarZst => FormatInfo {
            format,
            description: "TAR archive compressed with Zstandard".into(),
            mime_type: "application/zstd".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::TarLz4 => FormatInfo {
            format,
            description: "TAR archive compressed with LZ4".into(),
            mime_type: "application/x-lz4".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::SevenZip => FormatInfo {
            format,
            description: "7-Zip archive (LZMA/LZMA2)".into(),
            mime_type: "application/x-7z-compressed".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::Gz => FormatInfo {
            format,
            description: "Gzip compressed file (RFC 1952)".into(),
            mime_type: "application/gzip".into(),
            is_archive: false,
            is_compressed: true,
        },
        ArchiveFormat::Bz2 => FormatInfo {
            format,
            description: "Bzip2 compressed file".into(),
            mime_type: "application/x-bzip2".into(),
            is_archive: false,
            is_compressed: true,
        },
        ArchiveFormat::Xz => FormatInfo {
            format,
            description: "XZ compressed file (LZMA2)".into(),
            mime_type: "application/x-xz".into(),
            is_archive: false,
            is_compressed: true,
        },
        ArchiveFormat::Lz4 => FormatInfo {
            format,
            description: "LZ4 compressed file (frame format)".into(),
            mime_type: "application/x-lz4".into(),
            is_archive: false,
            is_compressed: true,
        },
        ArchiveFormat::Zstd => FormatInfo {
            format,
            description: "Zstandard compressed file".into(),
            mime_type: "application/zstd".into(),
            is_archive: false,
            is_compressed: true,
        },
        ArchiveFormat::Lzma => FormatInfo {
            format,
            description: "LZMA compressed file (standalone)".into(),
            mime_type: "application/x-lzma".into(),
            is_archive: false,
            is_compressed: true,
        },
        ArchiveFormat::Lha => FormatInfo {
            format,
            description: "LHA/LZH archive".into(),
            mime_type: "application/x-lzh-compressed".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::Rar => FormatInfo {
            format,
            description: "RAR archive".into(),
            mime_type: "application/vnd.rar".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::Arc => FormatInfo {
            format,
            description: "ARC archive".into(),
            mime_type: "application/x-arc".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::Zoo => FormatInfo {
            format,
            description: "ZOO archive".into(),
            mime_type: "application/x-zoo".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::Xar => FormatInfo {
            format,
            description: "XAR archive (eXtensible ARchive)".into(),
            mime_type: "application/x-xar".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::Dds => FormatInfo {
            format,
            description: "DDS texture (DirectDraw Surface)".into(),
            mime_type: "image/vnd-ms.dds".into(),
            is_archive: false,
            is_compressed: true,
        },
        ArchiveFormat::StuffIt => FormatInfo {
            format,
            description: "StuffIt archive (classic Mac OS)".into(),
            mime_type: "application/x-stuffit".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::CompactPro => FormatInfo {
            format,
            description: "Compact Pro archive (classic Mac OS)".into(),
            mime_type: "application/x-compact-pro".into(),
            is_archive: true,
            is_compressed: true,
        },
        ArchiveFormat::Unknown => FormatInfo {
            format,
            description: "Unknown format".into(),
            mime_type: "application/octet-stream".into(),
            is_archive: false,
            is_compressed: false,
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_detect_zip() {
        let data = [0x50, 0x4B, 0x03, 0x04, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Zip);
    }

    #[test]
    fn test_detect_gzip() {
        let data = [0x1F, 0x8B, 0x08, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Gz);
    }

    #[test]
    fn test_detect_xz() {
        let data = [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Xz);
    }

    #[test]
    fn test_detect_lz4_frame() {
        let data = [0x04, 0x22, 0x4D, 0x18, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Lz4);
    }

    #[test]
    fn test_detect_zstd() {
        let data = [0x28, 0xB5, 0x2F, 0xFD, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Zstd);
    }

    #[test]
    fn test_detect_bz2() {
        let data = [0x42, 0x5A, 0x68, 0x39, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Bz2);
    }

    #[test]
    fn test_detect_7z() {
        let data = [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::SevenZip);
    }

    #[test]
    fn test_detect_stuffit() {
        let data = b"SIT!\x00\x00\x00\x00";
        assert_eq!(detect_format(data), ArchiveFormat::StuffIt);
    }

    #[test]
    fn test_detect_compact_pro() {
        let data = [0x01, 0x01, 0x00, 0x00, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::CompactPro);
    }

    #[test]
    fn test_detect_rar3() {
        let data = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Rar);
    }

    #[test]
    fn test_detect_rar5() {
        let data = [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Rar);
    }

    #[test]
    fn test_detect_lha() {
        // "-lh5-" at offset 2
        let data = [0x20, 0x00, b'-', b'l', b'h', b'5', b'-'];
        assert_eq!(detect_format(&data), ArchiveFormat::Lha);
    }

    #[test]
    fn test_detect_lha_lz() {
        // "-lz5-" at offset 2
        let data = [0x20, 0x00, b'-', b'l', b'z', b'5', b'-'];
        assert_eq!(detect_format(&data), ArchiveFormat::Lha);
    }

    #[test]
    fn test_detect_lzma() {
        // Properties 0x5D + dict size 8 MiB (LE)
        let data = [
            0x5D, 0x00, 0x00, 0x80, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
        ];
        assert_eq!(detect_format(&data), ArchiveFormat::Lzma);
    }

    #[test]
    fn test_detect_arc() {
        let data = [0x1A, 0x02, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Arc);
    }

    #[test]
    fn test_detect_zoo() {
        // ZOO magic at offset 20
        let mut data = [0u8; 25];
        data[20] = 0xDC;
        data[21] = 0xA7;
        data[22] = 0xC4;
        data[23] = 0xFD;
        assert_eq!(detect_format(&data), ArchiveFormat::Zoo);
    }

    #[test]
    fn test_detect_xar() {
        // "xar!" magic
        let data = [0x78, 0x61, 0x72, 0x21, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Xar);
    }

    #[test]
    fn test_detect_dds() {
        // "DDS " magic
        let data = [0x44, 0x44, 0x53, 0x20, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Dds);
    }

    #[test]
    fn test_extension_xar() {
        let data = [0x78, 0x61, 0x72, 0x21, 0x00, 0x00];
        let path = Path::new("test.xar");
        assert_eq!(detect_format_from_path(path, &data), ArchiveFormat::Xar);
    }

    #[test]
    fn test_extension_pkg() {
        let data = [0x78, 0x61, 0x72, 0x21, 0x00, 0x00];
        let path = Path::new("installer.pkg");
        assert_eq!(detect_format_from_path(path, &data), ArchiveFormat::Xar);
    }

    #[test]
    fn test_extension_dds() {
        let data = [0x44, 0x44, 0x53, 0x20, 0x00, 0x00];
        let path = Path::new("texture.dds");
        assert_eq!(detect_format_from_path(path, &data), ArchiveFormat::Dds);
    }

    #[test]
    fn test_detect_unknown() {
        let data = [0xFF, 0xFE, 0x00, 0x00];
        assert_eq!(detect_format(&data), ArchiveFormat::Unknown);
    }

    #[test]
    fn test_detect_too_short() {
        let data = [0x50, 0x4B];
        assert_eq!(detect_format(&data), ArchiveFormat::Unknown);
    }

    #[test]
    fn test_extension_lzma() {
        // Use non-heuristic data so magic alone wouldn't match
        let data = [0x00; 16];
        let path = Path::new("test.lzma");
        assert_eq!(detect_format_from_path(path, &data), ArchiveFormat::Lzma);
    }

    #[test]
    fn test_extension_lzh() {
        let data = [0x00; 16];
        let path = Path::new("test.lzh");
        assert_eq!(detect_format_from_path(path, &data), ArchiveFormat::Lha);
    }

    #[test]
    fn test_extension_rar() {
        let data = [0x00; 16];
        let path = Path::new("test.rar");
        assert_eq!(detect_format_from_path(path, &data), ArchiveFormat::Rar);
    }

    #[test]
    fn test_extension_tar_gz() {
        let data = [0x1F, 0x8B, 0x08, 0x00, 0x00];
        let path = Path::new("test.tar.gz");
        assert_eq!(detect_format_from_path(path, &data), ArchiveFormat::TarGz);
    }

    #[test]
    fn test_extension_tgz() {
        let data = [0x1F, 0x8B, 0x08, 0x00, 0x00];
        let path = Path::new("test.tgz");
        assert_eq!(detect_format_from_path(path, &data), ArchiveFormat::TarGz);
    }
}