archive-core 0.1.1

Pure-Rust archive-layer reader for forensics: transparently peels gzip/bzip2/xz/zip/7z/tar archive layers to reveal the inner evidence, with extension-and-magic format detection.
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
//! Member reading for the four archive formats — `.tgz`/`.tbz2` (+ plain
//! `ustar`), `.zip`/`.clbx`, and `.7z`.
//!
//! An [`Archive`] lists ([`entries`](Archive::entries)) and extracts
//! ([`read`](Archive::read)) members over an in-memory byte slice. Backends are
//! reused, never reimplemented: the tar family decompresses its outer layer with
//! [`crate::peel`]'s gzip/bzip2 decoders and walks members with the `tar` crate;
//! ZIP uses the fleet `zip-forensic-core` reader; 7z uses `sevenz-rust2`. Every
//! extraction is capped at [`crate::peel::MAX_INFLATED`]; a declared member size
//! is never trusted for allocation, and any backend error fails loud.

use crate::detect::{sniff, Format};
use crate::error::{ArchiveError, Result};
use crate::peel::MAX_INFLATED;
use crate::plan::Access;

/// One member of an archive.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveEntry {
    /// Member path within the archive (as recorded — evidence, not sanitized).
    pub name: String,
    /// Uncompressed size in bytes, as declared by the archive metadata.
    pub size: u64,
    /// Whether the entry names a directory.
    pub is_dir: bool,
}

/// A decoded, listable archive over an in-memory byte slice.
pub struct Archive {
    format: Format,
    entries: Vec<ArchiveEntry>,
    backend: Backend,
}

/// The concrete reader behind an [`Archive`]. One variant per reused backend.
// The 7z `ArchiveReader` is larger than the tar/zip variants, but exactly one
// `Backend` exists per `Archive` and they are never held in a collection, so the
// per-value size gap the lint guards against is immaterial here.
#[allow(clippy::large_enum_variant)]
enum Backend {
    /// The **compressed** archive bytes plus the outer tar format. Members are
    /// listed and extracted by streaming a fresh decompressor over these bytes,
    /// so the whole decompressed tar is never materialized in RAM.
    Tar { compressed: Vec<u8>, outer: Format },
    /// The fleet ZIP reader over the archive bytes.
    Zip {
        archive: zip_core::ZipArchive<std::io::Cursor<Vec<u8>>>,
    },
    /// The `sevenz-rust2` reader over the archive bytes.
    SevenZip {
        reader: sevenz_rust2::ArchiveReader<std::io::Cursor<Vec<u8>>>,
    },
}

impl Archive {
    /// Open `data` as one of the four archive formats, returning `Ok(None)` when
    /// it is not an archive (a bare wrapper or unrecognized input). `name` is an
    /// optional file-name hint used only to distinguish a compressed tar
    /// (`.tgz`/`.tbz2`) from a bare gzip/bzip2 stream.
    ///
    /// # Errors
    /// [`ArchiveError::Open`] if the archive directory cannot be parsed (a
    /// malformed outer compression layer surfaces here while streaming the tar
    /// listing). The tar family is listed by streaming, so no whole-archive
    /// inflate happens at open; the [`crate::peel::MAX_INFLATED`] cap is enforced
    /// per member in [`read`](Archive::read).
    pub fn open(data: &[u8], name: Option<&str>) -> Result<Option<Archive>> {
        Self::open_with_format(sniff(name, data), data)
    }

    /// Open `data` under an already-determined `format`, bypassing the
    /// name-based [`sniff`] — returns `Ok(None)` when `format` is not an archive.
    /// Phase-1 [`crate::detect`] uses this after classifying the format
    /// content-authoritatively (so a bare-compressed tar peeled to a known
    /// `TarGz`/`TarBz2` opens without a name hint).
    ///
    /// # Errors
    /// Same as [`open`](Archive::open): [`ArchiveError::Open`] if the archive
    /// directory cannot be parsed.
    pub(crate) fn open_with_format(format: Format, data: &[u8]) -> Result<Option<Archive>> {
        match format {
            Format::Tar | Format::TarGz | Format::TarBz2 => {
                Self::open_tar(format, data.to_vec()).map(Some)
            }
            Format::Zip => Self::open_zip(data).map(Some),
            Format::SevenZip => Self::open_7z(data).map(Some),
            _ => Ok(None),
        }
    }

    /// The most-seekable [`Access`] for member `index`, chosen from the member
    /// table without decompressing (ADR 0008, rule 4). A `Stored`/uncompressed
    /// zip member is `InPlace` (zero-copy sub-range); `Deflate`/`Deflate64` is
    /// `Zran`; every other codec — and tar/7z members, which expose no
    /// in-archive offset or use a non-seekable codec — is `SpillToTemp`.
    ///
    /// # Errors
    /// [`ArchiveError::IndexOutOfRange`] for a bad index, or [`ArchiveError::Read`]
    /// if a zip member's local header cannot be read.
    pub fn member_access(&mut self, index: usize) -> Result<Access> {
        let count = self.entries.len();
        if index >= count {
            return Err(ArchiveError::IndexOutOfRange { index, count });
        }
        match &mut self.backend {
            Backend::Zip { archive } => {
                let f = archive.by_index(index).map_err(|e| ArchiveError::Read {
                    format: "zip",
                    // cov:unreachable: open_zip already read this index's header, and
                    // the index is bounds-checked above — a re-read cannot fail.
                    detail: e.to_string(),
                })?;
                Ok(match f.compression() {
                    zip_core::CompressionMethod::Stored => Access::InPlace {
                        offset: f.data_start(),
                        len: f.compressed_size(),
                    },
                    zip_core::CompressionMethod::Deflated
                    | zip_core::CompressionMethod::Deflate64 => Access::Zran,
                    _ => Access::SpillToTemp,
                })
            }
            // The tar reader exposes no in-archive member offset, and 7z members
            // are non-seekable codecs (LZMA/LZMA2) — both spill to temp.
            Backend::Tar { .. } | Backend::SevenZip { .. } => Ok(Access::SpillToTemp),
        }
    }

    /// The archive's format.
    #[must_use]
    pub fn format(&self) -> Format {
        self.format
    }

    /// The archive's member list, in archive order.
    #[must_use]
    pub fn entries(&self) -> &[ArchiveEntry] {
        &self.entries
    }

    /// Extract the bytes of the member at `index`, capped at
    /// [`crate::peel::MAX_INFLATED`].
    ///
    /// # Errors
    /// [`ArchiveError::IndexOutOfRange`] for a bad index, [`ArchiveError::Read`]
    /// on a backend/codec failure, or [`ArchiveError::TooLarge`] past the cap.
    pub fn read(&mut self, index: usize) -> Result<Vec<u8>> {
        let count = self.entries.len();
        if index >= count {
            return Err(ArchiveError::IndexOutOfRange { index, count });
        }
        // 7z extracts by name; capture it (and the declared size for the
        // pre-alloc cap) before borrowing the backend mutably.
        let (name, declared_size) = {
            let e = &self.entries[index];
            (e.name.clone(), e.size)
        };
        match &mut self.backend {
            Backend::Tar { compressed, outer } => {
                extract_tar_member_streaming(compressed, *outer, index, MAX_INFLATED)
            }
            Backend::Zip { archive } => read_zip_member(archive, index),
            Backend::SevenZip { reader } => read_7z_member(reader, &name, declared_size),
        }
    }

    /// Stream the bytes of member `index` into `out`, capped at `cap`. The member
    /// is copied through a bounded buffer — never fully materialized in a `Vec` —
    /// so a multi-GB inner image spills to the caller's writer (a temp file)
    /// without holding it in RAM. Fails loud with [`ArchiveError::TooLarge`] past
    /// `cap`. Returns the number of bytes written.
    ///
    /// The one exception is a 7z member: `sevenz-rust2` exposes no streaming
    /// extract, so its bytes pass through a transient `Vec` before the write.
    ///
    /// # Errors
    /// [`ArchiveError::IndexOutOfRange`] for a bad index, [`ArchiveError::Read`]
    /// on a backend/codec failure, or [`ArchiveError::TooLarge`] past `cap`.
    pub fn stream_member(
        &mut self,
        index: usize,
        out: &mut dyn std::io::Write,
        cap: u64,
    ) -> Result<u64> {
        let count = self.entries.len();
        if index >= count {
            return Err(ArchiveError::IndexOutOfRange { index, count });
        }
        let (name, declared_size) = {
            let e = &self.entries[index];
            (e.name.clone(), e.size)
        };
        match &mut self.backend {
            Backend::Tar { compressed, outer } => {
                stream_tar_member(compressed, *outer, index, out, cap)
            }
            Backend::Zip { archive } => stream_zip_member(archive, index, out, cap),
            Backend::SevenZip { reader } => {
                let bytes = read_7z_member(reader, &name, declared_size)?;
                if bytes.len() as u64 > cap {
                    return Err(ArchiveError::TooLarge { cap });
                }
                out.write_all(&bytes).map_err(|e| ArchiveError::Read {
                    format: "7z",
                    detail: e.to_string(),
                })?;
                Ok(bytes.len() as u64)
            }
        }
    }

    /// Build a tar [`Archive`] over the still-**compressed** `compressed` bytes,
    /// listing members by streaming a fresh decompressor. The `tar` crate skips
    /// each member's body *through* the decompressor, so no member data is
    /// buffered while listing.
    fn open_tar(outer: Format, compressed: Vec<u8>) -> Result<Archive> {
        let entries = {
            let mut ar = tar::Archive::new(tar_stream(&compressed, outer));
            let iter = ar.entries().map_err(|e| ArchiveError::Open {
                format: "tar",
                detail: e.to_string(),
            })?;
            let mut entries = Vec::new();
            for entry in iter {
                let entry = entry.map_err(|e| ArchiveError::Open {
                    format: "tar",
                    detail: e.to_string(),
                })?;
                let name = entry.path().map_or_else(
                    |_| String::from_utf8_lossy(&entry.path_bytes()).into_owned(),
                    |p| p.to_string_lossy().into_owned(),
                );
                let header = entry.header();
                let size = header.size().map_err(|e| ArchiveError::Open {
                    format: "tar",
                    detail: e.to_string(),
                })?;
                entries.push(ArchiveEntry {
                    name,
                    size,
                    is_dir: header.entry_type().is_dir(),
                });
            }
            entries
        };
        Ok(Archive {
            format: outer,
            entries,
            backend: Backend::Tar { compressed, outer },
        })
    }

    /// Build a ZIP [`Archive`] via the fleet `zip-forensic-core` reader.
    fn open_zip(data: &[u8]) -> Result<Archive> {
        let mut archive =
            zip_core::ZipArchive::new(std::io::Cursor::new(data.to_vec())).map_err(|e| {
                ArchiveError::Open {
                    format: "zip",
                    detail: e.to_string(),
                }
            })?;
        let count = archive.len();
        let mut entries = Vec::with_capacity(count);
        for i in 0..count {
            let f = archive.by_index(i).map_err(|e| ArchiveError::Open {
                format: "zip",
                detail: e.to_string(),
            })?;
            entries.push(ArchiveEntry {
                name: f.name().to_string(),
                size: f.size(),
                is_dir: f.is_dir(),
            });
        }
        Ok(Archive {
            format: Format::Zip,
            entries,
            backend: Backend::Zip { archive },
        })
    }

    /// Build a 7z [`Archive`] via `sevenz-rust2`.
    fn open_7z(data: &[u8]) -> Result<Archive> {
        let reader = sevenz_rust2::ArchiveReader::new(
            std::io::Cursor::new(data.to_vec()),
            sevenz_rust2::Password::empty(),
        )
        .map_err(|e| ArchiveError::Open {
            format: "7z",
            detail: e.to_string(),
        })?;
        let entries = reader
            .archive()
            .files
            .iter()
            .map(|f| ArchiveEntry {
                name: f.name.clone(),
                size: f.size,
                is_dir: f.is_directory,
            })
            .collect();
        Ok(Archive {
            format: Format::SevenZip,
            entries,
            backend: Backend::SevenZip { reader },
        })
    }
}

/// A fresh streaming `Read` over the compressed archive bytes for the given
/// outer tar format. Each call re-wraps `compressed` from the start, so the
/// decompressor holds only a bounded window — never the whole decompressed tar.
fn tar_stream(compressed: &[u8], outer: Format) -> Box<dyn std::io::Read + '_> {
    let cursor = std::io::Cursor::new(compressed);
    match outer {
        Format::TarGz => Box::new(flate2::read::GzDecoder::new(cursor)),
        Format::TarBz2 => Box::new(bzip2_rs::DecoderReader::new(cursor)),
        // Plain `Format::Tar` (and, defensively, any non-tar caller) reads the
        // bytes straight through with no decompression layer.
        _ => Box::new(cursor),
    }
}

/// Stream the `index`-th tar member's bytes over a fresh decompressor, capped at
/// `cap`. The `tar` crate skips prior members' bodies *through* the stream, so
/// only this one member is ever read into memory — the whole decompressed tar is
/// never materialized. Fails loud with [`ArchiveError::TooLarge`] past `cap`.
/// CRC-agnostic (tar has no per-member data checksum).
fn extract_tar_member_streaming(
    compressed: &[u8],
    outer: Format,
    index: usize,
    cap: u64,
) -> Result<Vec<u8>> {
    use std::io::Read;
    let mut ar = tar::Archive::new(tar_stream(compressed, outer));
    let mut iter = ar.entries().map_err(|e| ArchiveError::Read {
        format: "tar",
        detail: e.to_string(),
    })?;
    let entry = iter
        .nth(index)
        .ok_or(ArchiveError::IndexOutOfRange {
            index,
            count: index,
        })?
        .map_err(|e| ArchiveError::Read {
            format: "tar",
            detail: e.to_string(),
        })?;
    let mut out = Vec::new();
    let mut limited = entry.take(cap + 1);
    limited
        .read_to_end(&mut out)
        .map_err(|e| ArchiveError::Read {
            format: "tar",
            detail: e.to_string(),
        })?;
    if out.len() as u64 > cap {
        return Err(ArchiveError::TooLarge { cap });
    }
    Ok(out)
}

/// Extract the `index`-th ZIP member, capped. The fleet reader verifies CRC-32
/// at EOF and fails loud on mismatch.
fn read_zip_member(
    archive: &mut zip_core::ZipArchive<std::io::Cursor<Vec<u8>>>,
    index: usize,
) -> Result<Vec<u8>> {
    use std::io::Read;
    let zf = archive.by_index(index).map_err(|e| ArchiveError::Read {
        format: "zip",
        detail: e.to_string(),
    })?;
    let mut out = Vec::new();
    let mut limited = zf.take(MAX_INFLATED + 1);
    limited
        .read_to_end(&mut out)
        .map_err(|e| ArchiveError::Read {
            format: "zip",
            detail: e.to_string(),
        })?;
    if out.len() as u64 > MAX_INFLATED {
        return Err(ArchiveError::TooLarge { cap: MAX_INFLATED });
    }
    Ok(out)
}

/// Extract a 7z member by name. `sevenz-rust2` decodes the whole member; an
/// unsupported-codec member surfaces as a loud [`ArchiveError::Read`] carrying
/// the backend's diagnostic (never a silent skip). The declared size is checked
/// against the cap before decoding, and the output length after.
fn read_7z_member(
    reader: &mut sevenz_rust2::ArchiveReader<std::io::Cursor<Vec<u8>>>,
    name: &str,
    declared_size: u64,
) -> Result<Vec<u8>> {
    if declared_size > MAX_INFLATED {
        return Err(ArchiveError::TooLarge { cap: MAX_INFLATED });
    }
    let out = reader.read_file(name).map_err(|e| ArchiveError::Read {
        format: "7z",
        detail: e.to_string(),
    })?;
    if out.len() as u64 > MAX_INFLATED {
        return Err(ArchiveError::TooLarge { cap: MAX_INFLATED });
    }
    Ok(out)
}

/// Copy `reader` into `out` through a bounded buffer, capped at `cap`. Reads one
/// byte past the cap so an over-cap stream is *detected*, not silently truncated;
/// fails loud with [`ArchiveError::TooLarge`]. Returns the bytes written.
fn copy_capped(
    reader: impl std::io::Read,
    out: &mut dyn std::io::Write,
    cap: u64,
    format: &'static str,
) -> Result<u64> {
    let mut limited = reader.take(cap + 1);
    let n = std::io::copy(&mut limited, out).map_err(|e| ArchiveError::Read {
        format,
        detail: e.to_string(),
    })?;
    if n > cap {
        return Err(ArchiveError::TooLarge { cap });
    }
    Ok(n)
}

/// Stream the `index`-th tar member into `out`, capped. Mirrors
/// [`extract_tar_member_streaming`], writing to a sink instead of a `Vec` so the
/// member never lands in RAM whole.
fn stream_tar_member(
    compressed: &[u8],
    outer: Format,
    index: usize,
    out: &mut dyn std::io::Write,
    cap: u64,
) -> Result<u64> {
    let mut ar = tar::Archive::new(tar_stream(compressed, outer));
    let mut iter = ar.entries().map_err(|e| ArchiveError::Read {
        format: "tar",
        detail: e.to_string(),
    })?;
    let entry = iter
        .nth(index)
        .ok_or(ArchiveError::IndexOutOfRange {
            index,
            count: index,
        })?
        .map_err(|e| ArchiveError::Read {
            format: "tar",
            detail: e.to_string(),
        })?;
    copy_capped(entry, out, cap, "tar")
}

/// Stream the `index`-th ZIP member into `out`, capped. The fleet reader verifies
/// CRC-32 at EOF and fails loud on mismatch.
fn stream_zip_member(
    archive: &mut zip_core::ZipArchive<std::io::Cursor<Vec<u8>>>,
    index: usize,
    out: &mut dyn std::io::Write,
    cap: u64,
) -> Result<u64> {
    let zf = archive.by_index(index).map_err(|e| ArchiveError::Read {
        format: "zip",
        detail: e.to_string(),
    })?;
    copy_capped(zf, out, cap, "zip")
}

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

    /// Build an uncompressed `ustar` archive from `(name, bytes)` members.
    fn build_tar(members: &[(&str, Vec<u8>)]) -> Vec<u8> {
        let mut b = tar::Builder::new(Vec::new());
        for (name, data) in members {
            let mut h = tar::Header::new_gnu();
            h.set_size(data.len() as u64);
            h.set_mode(0o644);
            h.set_cksum();
            b.append_data(&mut h, name, data.as_slice()).unwrap();
        }
        b.into_inner().unwrap()
    }

    fn gzip(data: &[u8]) -> Vec<u8> {
        let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
        e.write_all(data).unwrap();
        e.finish().unwrap()
    }

    // The load-bearing streaming property: `cap = 1200` is LESS than the combined
    // decompressed tar (two members × 512-byte header + 1024-byte padded data,
    // plus 1024 bytes of end blocks ≈ 3584 bytes) but MORE than either single
    // member (1000 bytes). A whole-tar materialization under this cap would
    // exceed it and fail `TooLarge`; the streaming per-member extract must
    // succeed because the whole decompressed tar is never held at once.
    #[test]
    fn streaming_targz_extracts_each_member_under_whole_tar_cap() {
        let a = vec![0xAA_u8; 1000];
        let b = vec![0xBB_u8; 1000];
        let tar = build_tar(&[("a.bin", a.clone()), ("b.bin", b.clone())]);
        assert!(
            tar.len() as u64 > 1200,
            "combined tar ({}) must exceed the per-member cap",
            tar.len()
        );
        let targz = gzip(&tar);
        assert_eq!(
            extract_tar_member_streaming(&targz, Format::TarGz, 0, 1200).unwrap(),
            a
        );
        assert_eq!(
            extract_tar_member_streaming(&targz, Format::TarGz, 1, 1200).unwrap(),
            b
        );
    }

    #[test]
    fn streaming_plain_tar_extracts_member() {
        let a = vec![0x11_u8; 800];
        let b = vec![0x22_u8; 800];
        let tar = build_tar(&[("a.bin", a.clone()), ("b.bin", b.clone())]);
        assert_eq!(
            extract_tar_member_streaming(&tar, Format::Tar, 0, MAX_INFLATED).unwrap(),
            a
        );
        assert_eq!(
            extract_tar_member_streaming(&tar, Format::Tar, 1, MAX_INFLATED).unwrap(),
            b
        );
    }

    #[test]
    fn streaming_member_over_cap_fails_loud() {
        let targz = gzip(&build_tar(&[("a.bin", vec![0xAA_u8; 1000])]));
        match extract_tar_member_streaming(&targz, Format::TarGz, 0, 500) {
            Err(ArchiveError::TooLarge { cap }) => assert_eq!(cap, 500),
            other => panic!("expected TooLarge, got {other:?}"),
        }
    }

    #[test]
    fn streaming_bad_index_fails_loud() {
        let tar = build_tar(&[("only.bin", vec![0x33_u8; 10])]);
        assert!(matches!(
            extract_tar_member_streaming(&tar, Format::Tar, 99, MAX_INFLATED),
            Err(ArchiveError::IndexOutOfRange { .. })
        ));
    }

    // bzip2 uses the identical streaming code path; drive it through the public
    // `Archive` so the `TarBz2` arm is exercised in both `open_tar` (listing) and
    // `read` (extraction).
    const PAYLOAD_TBZ2: &[u8] = include_bytes!("../../tests/data/fixtures/payload.tbz2");

    #[test]
    fn member_access_out_of_range_fails_loud() {
        let mut a = Archive::open(PAYLOAD_TBZ2, Some("payload.tbz2"))
            .unwrap()
            .unwrap();
        assert!(matches!(
            a.member_access(9999),
            Err(ArchiveError::IndexOutOfRange { .. })
        ));
    }

    #[test]
    fn streaming_tbz2_reads_member_via_same_path() {
        let mut a = Archive::open(PAYLOAD_TBZ2, Some("payload.tbz2"))
            .unwrap()
            .unwrap();
        assert_eq!(a.format(), Format::TarBz2);
        let ia = a
            .entries()
            .iter()
            .position(|e| e.name == "a.txt" && !e.is_dir)
            .unwrap();
        assert_eq!(a.read(ia).unwrap(), b"alpha member contents\n");
    }
}