Skip to main content

archive_core/
plan.rs

1//! Phase 1 of two-phase archive access (ADR 0008): **content-authoritative,
2//! name-free classification**. [`detect`] reads only bounded decompressed heads
3//! and the archive's own member *table* — never a payload — and returns the
4//! most-direct [`AccessPlan`] route to the evidence. Phase 2 (peel/execute) is a
5//! later step; `archive_layer.rs::peel_archive` is the current executor and coexists.
6
7use crate::archive::Archive;
8use crate::detect::{sniff, Format};
9use crate::error::{ArchiveError, Result};
10
11/// Bounded decompressed head peeked per compression layer (rules 2/3). 512 bytes
12/// reaches the deepest packing magic archive-core owns (tar `ustar` at 257);
13/// forensic filesystem magics are the VFS resolver's concern, not ours.
14const HEAD_PEEK: u64 = 512;
15
16/// The access strategy for one member or segment, chosen from the archive's
17/// member table without decompressing (ADR 0008, rule 4 — most-seekable first).
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum Access {
21    /// Stored/uncompressed member — seek a zero-copy sub-range in place.
22    InPlace {
23        /// Absolute offset of the member's first data byte in the archive.
24        offset: u64,
25        /// Length of the in-archive window (bytes).
26        len: u64,
27    },
28    /// Deflate/Deflate64/gzip — a checkpoint seek-index gives random access
29    /// with no full inflate.
30    Zran,
31    /// A non-seekable codec (LZMA/LZMA2/7z, bzip2 until a block-index lands) or
32    /// a format exposing no in-archive offset — decompress once to temp.
33    SpillToTemp,
34}
35
36/// The bare compression codec of an [`AccessPlan::Wrapper`].
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum Codec {
40    /// gzip (`1F 8B`).
41    Gzip,
42    /// bzip2 (`BZh`).
43    Bzip2,
44}
45
46/// How a segmented set's members reassemble into one logical image.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum SegmentKind {
50    /// EWF `.E01/.E02…` (also `.Ex01`, `.s01`) — ordered by segment number.
51    Ewf,
52    /// Raw split `.001/.002…` — ordered by the numeric suffix.
53    SplitRaw,
54    /// Split VMDK `<base>-s001.vmdk`… — ordered by the `-sNNN` index.
55    SplitVmdk,
56}
57
58/// One member of an [`AccessPlan::SegmentSet`], carrying its own access strategy.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Segment {
61    /// Member name within the archive (the ordering signal for a split set).
62    pub name: String,
63    /// The member's index in the archive's table.
64    pub index: usize,
65    /// This segment's most-seekable access strategy.
66    pub access: Access,
67}
68
69/// The classified, most-direct route from packed bytes to the evidence.
70#[derive(Debug, Clone, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum AccessPlan {
73    /// Uncompressed, non-archive stream — open as-is.
74    Direct,
75    /// A bare gzip/bzip2 wrapper over a single stream.
76    Wrapper {
77        /// The outer compression codec.
78        codec: Codec,
79        /// Access strategy for the single wrapped stream.
80        access: Access,
81    },
82    /// Exactly one forensic file member inside an archive.
83    Member {
84        /// The archive format the member lives in.
85        format: Format,
86        /// The member's index in the archive table.
87        index: usize,
88        /// The member's recorded name.
89        name: String,
90        /// The member's most-seekable access strategy.
91        access: Access,
92    },
93    /// A segmented image (`E01/E02…`, split raw, split VMDK) — one logical stream.
94    SegmentSet {
95        /// The archive format the segments live in.
96        format: Format,
97        /// The segments, ordered by segment number.
98        members: Vec<Segment>,
99        /// How the segments reassemble.
100        kind: SegmentKind,
101    },
102    /// Several independent members — a tree, each re-resolved on its own.
103    Collection {
104        /// The archive format.
105        format: Format,
106    },
107}
108
109/// Classify the most-direct access route to the evidence inside `data`,
110/// **content-authoritatively and name-free** (ADR 0008, five rules): every
111/// decision is made from bytes plus the archive's own internal member table; no
112/// payload is ever inflated to classify.
113///
114/// # Errors
115/// Propagates an archive open/read failure ([`crate::ArchiveError`]) from reading
116/// the member table (never from inflating a payload — that is phase 2).
117pub fn detect(data: &[u8]) -> Result<AccessPlan> {
118    // Rule 1: magic decides membership both ways. `sniff` is name-free here.
119    let fmt = sniff(None, data);
120    match fmt {
121        Format::Gzip => detect_wrapper(Codec::Gzip, data),
122        Format::Bzip2 => detect_wrapper(Codec::Bzip2, data),
123        Format::Zip | Format::SevenZip | Format::Tar | Format::TarGz | Format::TarBz2 => {
124            detect_archive(fmt, data)
125        }
126        // `Unknown` (and any future non-packing variant) is not packed → Direct.
127        _ => Ok(AccessPlan::Direct),
128    }
129}
130
131/// A bare gz/bz2 wrapper. Peeks a bounded decompressed head and classifies from
132/// it (rules 2/3): a decompressed tar routes to the archive branch over the
133/// decompressed member table; a nested archive/wrapper is recorded as a wrapper
134/// (phase 1 spills; recursion is a later phase); anything else is a bare stream.
135fn detect_wrapper(codec: Codec, data: &[u8]) -> Result<AccessPlan> {
136    // Rule 2 coincidental-magic guard: valid magic that does not actually decode
137    // is not packed → Direct.
138    let Ok(head) = peek_head(codec, data) else {
139        return Ok(AccessPlan::Direct);
140    };
141    match sniff(None, &head) {
142        Format::Tar => {
143            let archive_fmt = match codec {
144                Codec::Gzip => Format::TarGz,
145                Codec::Bzip2 => Format::TarBz2,
146            };
147            detect_archive(archive_fmt, data)
148        }
149        Format::Zip
150        | Format::SevenZip
151        | Format::Gzip
152        | Format::Bzip2
153        | Format::TarGz
154        | Format::TarBz2 => Ok(AccessPlan::Wrapper {
155            codec,
156            access: Access::SpillToTemp,
157        }),
158        _ => Ok(AccessPlan::Wrapper {
159            codec,
160            access: wrapper_access(codec),
161        }),
162    }
163}
164
165/// Read the archive's member TABLE (never a payload) and classify the set: a
166/// uniform split set → [`AccessPlan::SegmentSet`]; exactly one file member →
167/// [`AccessPlan::Member`]; otherwise a [`AccessPlan::Collection`].
168fn detect_archive(format: Format, data: &[u8]) -> Result<AccessPlan> {
169    let Some(mut archive) = Archive::open_with_format(format, data)? else {
170        // cov:unreachable: detect_archive is only ever called with an archive
171        // format, for which open_with_format returns Some (a real open failure
172        // surfaces as an Err, caught by `?` above).
173        return Ok(AccessPlan::Collection { format });
174    };
175    // File members only (directories are structure, not evidence), original
176    // index kept so `member_access` can address them.
177    let files: Vec<(usize, String)> = archive
178        .entries()
179        .iter()
180        .enumerate()
181        .filter(|(_, e)| !e.is_dir)
182        .map(|(i, e)| (i, e.name.clone()))
183        .collect();
184
185    // Rule 5: a multi-member set whose names all match one split pattern is a
186    // single logical image — the one place names are load-bearing (ordering).
187    if files.len() >= 2 {
188        if let Some(kind) = classify_segment_kind(&files) {
189            let ordered = order_segments(&files, kind);
190            let mut members = Vec::with_capacity(ordered.len());
191            for (index, name, _seg) in ordered {
192                let access = archive.member_access(index)?;
193                members.push(Segment {
194                    name,
195                    index,
196                    access,
197                });
198            }
199            return Ok(AccessPlan::SegmentSet {
200                format,
201                members,
202                kind,
203            });
204        }
205    }
206
207    match files.as_slice() {
208        [(index, name)] => {
209            let access = archive.member_access(*index)?;
210            Ok(AccessPlan::Member {
211                format,
212                index: *index,
213                name: name.clone(),
214                access,
215            })
216        }
217        // Zero file members (only directory entries) or several independent
218        // items → a tree, each re-resolved on its own.
219        _ => Ok(AccessPlan::Collection { format }),
220    }
221}
222
223/// Decompress at most [`HEAD_PEEK`] bytes of `data`'s single compressed stream —
224/// never the whole payload (the O(n) property is a type invariant here).
225fn peek_head(codec: Codec, data: &[u8]) -> Result<Vec<u8>> {
226    use std::io::Read;
227    let reader: Box<dyn Read> = match codec {
228        Codec::Gzip => Box::new(flate2::read::GzDecoder::new(data)),
229        Codec::Bzip2 => Box::new(bzip2_rs::DecoderReader::new(data)),
230    };
231    let mut out = Vec::new();
232    reader
233        .take(HEAD_PEEK)
234        .read_to_end(&mut out)
235        .map_err(|e| ArchiveError::Decode {
236            format: codec_name(codec),
237            detail: e.to_string(),
238        })?;
239    Ok(out)
240}
241
242/// The best access a bare wrapper's codec allows: gzip → zran, bzip2 → spill.
243fn wrapper_access(codec: Codec) -> Access {
244    match codec {
245        Codec::Gzip => Access::Zran,
246        Codec::Bzip2 => Access::SpillToTemp,
247    }
248}
249
250/// A short, stable label for a codec (for diagnostics).
251fn codec_name(codec: Codec) -> &'static str {
252    match codec {
253        Codec::Gzip => "gzip",
254        Codec::Bzip2 => "bzip2",
255    }
256}
257
258/// The segment number for `name` under `kind`, or `None` if it does not match.
259fn segment_number(name: &str, kind: SegmentKind) -> Option<u64> {
260    match kind {
261        SegmentKind::Ewf => ewf_segment(name),
262        SegmentKind::SplitRaw => raw_split(name),
263        SegmentKind::SplitVmdk => vmdk_segment(name),
264    }
265}
266
267/// The uniform [`SegmentKind`] all `files` match, if any (VMDK, then EWF, then
268/// raw — the three name patterns are disjoint, so order only breaks empty ties).
269fn classify_segment_kind(files: &[(usize, String)]) -> Option<SegmentKind> {
270    [
271        SegmentKind::SplitVmdk,
272        SegmentKind::Ewf,
273        SegmentKind::SplitRaw,
274    ]
275    .into_iter()
276    .find(|&kind| files.iter().all(|(_, n)| segment_number(n, kind).is_some()))
277}
278
279/// The `files` that match `kind`, ordered by segment number.
280fn order_segments(files: &[(usize, String)], kind: SegmentKind) -> Vec<(usize, String, u64)> {
281    let mut ordered: Vec<(usize, String, u64)> = files
282        .iter()
283        .filter_map(|(i, n)| segment_number(n, kind).map(|seg| (*i, n.clone(), seg)))
284        .collect();
285    ordered.sort_by_key(|(_, _, seg)| *seg);
286    ordered
287}
288
289/// EWF segment number from an `.E0N`/`.Ex0N`/`.s0N` name (two-digit suffix).
290fn ewf_segment(name: &str) -> Option<u64> {
291    let (_, ext) = name.rsplit_once('.')?;
292    let ext = ext.to_ascii_lowercase();
293    let digits = ext
294        .strip_prefix("ex")
295        .or_else(|| ext.strip_prefix('e'))
296        .or_else(|| ext.strip_prefix('s'))?;
297    if digits.len() == 2 && digits.bytes().all(|b| b.is_ascii_digit()) {
298        digits.parse::<u64>().ok()
299    } else {
300        None
301    }
302}
303
304/// Raw-split segment number from an all-digit extension (`.001`, ≥ 2 digits).
305fn raw_split(name: &str) -> Option<u64> {
306    let (_, ext) = name.rsplit_once('.')?;
307    if ext.len() >= 2 && ext.bytes().all(|b| b.is_ascii_digit()) {
308        ext.parse::<u64>().ok()
309    } else {
310        None
311    }
312}
313
314/// Split-VMDK segment number from a `<base>-sNNN.vmdk` name.
315fn vmdk_segment(name: &str) -> Option<u64> {
316    let lower = name.to_ascii_lowercase();
317    let stem = lower.strip_suffix(".vmdk")?;
318    let pos = stem.rfind("-s")?;
319    let num = stem.get(pos + 2..)?;
320    if !num.is_empty() && num.bytes().all(|b| b.is_ascii_digit()) {
321        num.parse::<u64>().ok()
322    } else {
323        None
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use std::io::Write;
331
332    const FX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/fixtures/");
333
334    fn load(name: &str) -> Vec<u8> {
335        std::fs::read(format!("{FX}{name}")).unwrap()
336    }
337
338    fn gzip(data: &[u8]) -> Vec<u8> {
339        let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
340        e.write_all(data).unwrap();
341        e.finish().unwrap()
342    }
343
344    /// Build an uncompressed `ustar` archive from `(name, bytes)` members.
345    fn build_tar(members: &[(&str, Vec<u8>)]) -> Vec<u8> {
346        let mut b = tar::Builder::new(Vec::new());
347        for (name, data) in members {
348            let mut h = tar::Header::new_gnu();
349            h.set_size(data.len() as u64);
350            h.set_mode(0o644);
351            h.set_cksum();
352            b.append_data(&mut h, name, data.as_slice()).unwrap();
353        }
354        b.into_inner().unwrap()
355    }
356
357    // ---- wrappers ------------------------------------------------------------
358
359    #[test]
360    fn bare_gzip_of_raw_bytes_is_wrapper_zran() {
361        let gz = gzip(&b"raw disk sector bytes, not an archive at all ".repeat(40));
362        assert_eq!(
363            detect(&gz).unwrap(),
364            AccessPlan::Wrapper {
365                codec: Codec::Gzip,
366                access: Access::Zran
367            }
368        );
369    }
370
371    #[test]
372    fn gzip_of_single_member_tar_is_targz_member() {
373        let tar = build_tar(&[("disk.img", b"RAW-IMAGE-BYTES".to_vec())]);
374        let gz = gzip(&tar);
375        match detect(&gz).unwrap() {
376            AccessPlan::Member {
377                format,
378                name,
379                access,
380                ..
381            } => {
382                assert_eq!(format, Format::TarGz);
383                assert_eq!(name, "disk.img");
384                assert_eq!(access, Access::SpillToTemp);
385            }
386            other => panic!("expected TarGz Member, got {other:?}"),
387        }
388    }
389
390    #[test]
391    fn coincidental_gzip_magic_is_direct() {
392        // Valid `1F 8B` magic but an invalid gzip member (CM != deflate): the
393        // bounded head fails to decode → not packed (rule 2 content guard).
394        assert_eq!(
395            detect(b"\x1f\x8b\x00\x00garbage-not-really-gzip").unwrap(),
396            AccessPlan::Direct
397        );
398    }
399
400    #[test]
401    fn raw_bytes_are_direct() {
402        assert_eq!(
403            detect(b"\x00\x01\x02 not a wrapper or archive").unwrap(),
404            AccessPlan::Direct
405        );
406    }
407
408    #[test]
409    fn bare_bzip2_of_raw_bytes_is_wrapper_spill() {
410        // payload.bz2 is a bare bzip2 of text (no in-tree bzip2 encoder, so this
411        // is a committed fixture). bzip2 has no block-index yet → SpillToTemp.
412        assert_eq!(
413            detect(&load("payload.bz2")).unwrap(),
414            AccessPlan::Wrapper {
415                codec: Codec::Bzip2,
416                access: Access::SpillToTemp
417            }
418        );
419    }
420
421    #[test]
422    fn coincidental_bzip2_magic_is_direct() {
423        // Valid `BZh` magic that does not decode → not packed (rule 2 guard);
424        // also exercises the bzip2 arm of the bounded-head decoder's error path.
425        assert_eq!(
426            detect(b"BZhnot-a-real-bzip2-stream").unwrap(),
427            AccessPlan::Direct
428        );
429    }
430
431    #[test]
432    fn gzip_of_zip_is_nested_wrapper_spill() {
433        // A bare gzip whose decompressed head is itself an archive (zip): phase 1
434        // records the outer wrapper and spills; nested recursion is a later phase.
435        let gz = gzip(&load("payload.zip"));
436        assert_eq!(
437            detect(&gz).unwrap(),
438            AccessPlan::Wrapper {
439                codec: Codec::Gzip,
440                access: Access::SpillToTemp
441            }
442        );
443    }
444
445    #[test]
446    fn zip_single_bzip2_member_spills() {
447        // A zip member compressed with a non-seekable codec (bzip2, method 12)
448        // → SpillToTemp (the access ladder's last rung).
449        match detect(&load("bzip2_member.zip")).unwrap() {
450            AccessPlan::Member {
451                format,
452                name,
453                access,
454                ..
455            } => {
456                assert_eq!(format, Format::Zip);
457                assert_eq!(name, "blob.bin");
458                assert_eq!(access, Access::SpillToTemp);
459            }
460            other => panic!("expected Member, got {other:?}"),
461        }
462    }
463
464    // ---- zip access ladder ---------------------------------------------------
465
466    #[test]
467    fn zip_single_stored_member_is_inplace() {
468        match detect(&load("stored_one.zip")).unwrap() {
469            AccessPlan::Member {
470                format,
471                name,
472                access,
473                ..
474            } => {
475                assert_eq!(format, Format::Zip);
476                assert_eq!(name, "disk.dd");
477                match access {
478                    Access::InPlace { offset, len } => {
479                        assert_eq!(len, 4096);
480                        assert!(offset > 0, "stored data starts after a local header");
481                    }
482                    other => panic!("expected InPlace, got {other:?}"),
483                }
484            }
485            other => panic!("expected Member, got {other:?}"),
486        }
487    }
488
489    #[test]
490    fn zip_single_deflate_member_is_zran() {
491        match detect(&load("deflate_one.zip")).unwrap() {
492            AccessPlan::Member {
493                format,
494                name,
495                access,
496                ..
497            } => {
498                assert_eq!(format, Format::Zip);
499                assert_eq!(name, "big.dd");
500                assert_eq!(access, Access::Zran);
501            }
502            other => panic!("expected Member, got {other:?}"),
503        }
504    }
505
506    // ---- segment sets --------------------------------------------------------
507
508    #[test]
509    fn zip_ewf_segments_order_by_number_each_inplace() {
510        // seg_ewf.zip stores members out of order (E03, E01, E02); detect must
511        // reorder by segment number and mark each Stored member InPlace.
512        match detect(&load("seg_ewf.zip")).unwrap() {
513            AccessPlan::SegmentSet {
514                format,
515                members,
516                kind,
517            } => {
518                assert_eq!(format, Format::Zip);
519                assert_eq!(kind, SegmentKind::Ewf);
520                let names: Vec<&str> = members.iter().map(|s| s.name.as_str()).collect();
521                assert_eq!(names, ["img.E01", "img.E02", "img.E03"]);
522                for s in &members {
523                    assert!(
524                        matches!(s.access, Access::InPlace { .. }),
525                        "stored segment → InPlace, got {:?}",
526                        s.access
527                    );
528                }
529            }
530            other => panic!("expected SegmentSet Ewf, got {other:?}"),
531        }
532    }
533
534    #[test]
535    fn zip_raw_split_is_segmentset_splitraw() {
536        match detect(&load("seg_split.zip")).unwrap() {
537            AccessPlan::SegmentSet {
538                format,
539                members,
540                kind,
541            } => {
542                assert_eq!(format, Format::Zip);
543                assert_eq!(kind, SegmentKind::SplitRaw);
544                let names: Vec<&str> = members.iter().map(|s| s.name.as_str()).collect();
545                assert_eq!(names, ["disk.001", "disk.002"]);
546            }
547            other => panic!("expected SegmentSet SplitRaw, got {other:?}"),
548        }
549    }
550
551    #[test]
552    fn tar_split_vmdk_is_segmentset_splitvmdk() {
553        // Segment classification is format-agnostic; a plain tar carrying split
554        // VMDK names classifies identically (access falls to SpillToTemp for tar).
555        let tar = build_tar(&[
556            ("disk-s002.vmdk", b"seg-two".to_vec()),
557            ("disk-s001.vmdk", b"seg-one".to_vec()),
558        ]);
559        match detect(&tar).unwrap() {
560            AccessPlan::SegmentSet {
561                format,
562                members,
563                kind,
564            } => {
565                assert_eq!(format, Format::Tar);
566                assert_eq!(kind, SegmentKind::SplitVmdk);
567                let names: Vec<&str> = members.iter().map(|s| s.name.as_str()).collect();
568                assert_eq!(names, ["disk-s001.vmdk", "disk-s002.vmdk"]);
569                assert!(members.iter().all(|s| s.access == Access::SpillToTemp));
570            }
571            other => panic!("expected SegmentSet SplitVmdk, got {other:?}"),
572        }
573    }
574
575    // ---- collections ---------------------------------------------------------
576
577    #[test]
578    fn zip_unrelated_members_is_collection() {
579        assert_eq!(
580            detect(&load("payload.zip")).unwrap(),
581            AccessPlan::Collection {
582                format: Format::Zip
583            }
584        );
585    }
586
587    #[test]
588    fn sevenzip_unrelated_members_is_collection() {
589        assert_eq!(
590            detect(&load("payload.7z")).unwrap(),
591            AccessPlan::Collection {
592                format: Format::SevenZip
593            }
594        );
595    }
596
597    // ---- content authority ---------------------------------------------------
598
599    #[test]
600    fn bzip2_tar_classified_by_decompressed_content_not_name() {
601        // payload.tbz2 is a bzip2-compressed tar. `detect` takes no name, so the
602        // classification is decided from the decompressed head (tar magic) — the
603        // outer bzip2 magic alone cannot reveal the inner tar.
604        match detect(&load("payload.tbz2")).unwrap() {
605            AccessPlan::Collection { format } => assert_eq!(format, Format::TarBz2),
606            other => panic!("expected TarBz2 Collection from content, got {other:?}"),
607        }
608    }
609
610    // ---- segment name matchers (unit) ---------------------------------------
611
612    #[test]
613    fn ewf_segment_matches_e_ex_s_only() {
614        assert_eq!(ewf_segment("img.E01"), Some(1));
615        assert_eq!(ewf_segment("img.e12"), Some(12));
616        assert_eq!(ewf_segment("img.Ex03"), Some(3));
617        assert_eq!(ewf_segment("img.s07"), Some(7));
618        assert_eq!(ewf_segment("notes.txt"), None);
619        assert_eq!(ewf_segment("tool.exe"), None);
620        assert_eq!(ewf_segment("img.E1"), None); // single digit
621        assert_eq!(ewf_segment("noext"), None);
622    }
623
624    #[test]
625    fn raw_split_matches_all_digit_ext() {
626        assert_eq!(raw_split("disk.001"), Some(1));
627        assert_eq!(raw_split("disk.017"), Some(17));
628        assert_eq!(raw_split("disk.E01"), None);
629        assert_eq!(raw_split("disk.1"), None); // needs >= 2 digits
630        assert_eq!(raw_split("noext"), None);
631    }
632
633    #[test]
634    fn vmdk_segment_matches_dash_s_only() {
635        assert_eq!(vmdk_segment("disk-s001.vmdk"), Some(1));
636        assert_eq!(vmdk_segment("disk-s012.vmdk"), Some(12));
637        assert_eq!(vmdk_segment("disk.vmdk"), None); // monolithic
638        assert_eq!(vmdk_segment("disk-flat.vmdk"), None);
639        assert_eq!(vmdk_segment("disk-s001.bin"), None); // not vmdk
640    }
641
642    // A `-s` marker whose index is non-numeric (or absent) is not a split-VMDK
643    // segment — the numeric-suffix guard rejects it rather than mis-ordering it.
644    #[test]
645    fn vmdk_segment_rejects_malformed_s_index() {
646        assert_eq!(vmdk_segment("disk-sx.vmdk"), None); // non-digit index
647        assert_eq!(vmdk_segment("disk-s.vmdk"), None); // empty index
648    }
649}