archive_core/reassemble.rs
1//! Phase 4 of two-phase archive access (ADR 0008): **`SegmentSet` reassembly**.
2//! A multi-segment image split across archive members (`.E01/.E02/.E03`, split
3//! raw `.001/.002`, split VMDK `-s001/-s002`) is presented as ONE logical
4//! seekable source, built from the per-segment [`PeeledSource`] handles the
5//! phase-2/3 executor produces for each member's own [`crate::Access`].
6//!
7//! Two reassembly regimes, chosen by [`crate::SegmentKind`]:
8//!
9//! - [`SegmentKind::SplitRaw`] (`.001/.002/.003`, a raw dd split) — the segments
10//! ARE a byte-for-byte concatenation, so [`ConcatSource`] stitches the ordered
11//! per-segment handles into one `Read + Seek`: logical offset `O` maps to
12//! `(segment k, local offset)`, and `seek`/`read` dispatch to the owning
13//! segment. Fully reassembled in archive-core.
14//! - [`SegmentKind::Ewf`] / [`SegmentKind::SplitVmdk`] — the segments are NOT a
15//! raw concatenation: each `.E0N` carries its own EWF header/section structure,
16//! and split-VMDK extents are described by the descriptor. Stitching needs the
17//! container reader's multi-segment logic, so archive-core's job here is to hand
18//! back the **ordered per-segment byte sources** via [`segment_sources`]; the
19//! container reader consumes them through its own segment backing.
20//!
21//! ## The EWF `SegmentBacking` seam
22//!
23//! The `ewf` crate accepts explicit segment backings through
24//! `EwfReader::open_from_sources(Vec<ewf::SegmentSource>)`, where
25//! `ewf::SegmentSource` is a closed enum of `File` (a loose segment file), `Sub`
26//! (a `[base, base+len)` sub-range of a shared **on-disk** `File`), and `Mem`
27//! (an in-RAM `Arc<[u8]>`). archive-core's [`PeeledSource`]s are in-RAM
28//! `Read + Seek` handles (a zero-copy [`SubRange`](crate::SubRange) window, a
29//! zran checkpoint index, or a temp spill) — not `File`s — so the only lossless
30//! adapter available today is materializing each segment to an `Arc<[u8]>` and
31//! handing `ewf::SegmentSource::Mem`, which defeats the zero-spill zran path for
32//! a Deflate-compressed E01-in-zip set.
33//!
34//! A *clean*, zero-copy wiring therefore needs an `ewf`-side change: a
35//! `SegmentSource` variant that accepts an arbitrary boxed positioned reader
36//! (`read_at` + `len`), so a zran-backed or windowed source flows in without a
37//! full inflate. That is a cross-repo change to `ewf`; per the phase plan
38//! archive-core STOPS at exposing the ordered [`PeeledSource`]s here, and the
39//! adapter into a container reader lives in the consumer (disk-forensic /
40//! forensic-vfs-engine), never inside this leaf (which would invert the layer
41//! direction — the archive layer must not depend on a CONTAINER crate).
42
43use std::io::{self, Read, Seek, SeekFrom};
44use std::sync::Arc;
45
46use crate::detect::Format;
47use crate::error::Result;
48use crate::execute::{execute_member_access, offset_from, PeeledSource};
49use crate::plan::{detect, AccessPlan, SegmentKind};
50use crate::resolve::Limits;
51
52/// The ordered per-segment byte sources of a segmented image, each materialized
53/// via its own [`crate::Access`] (phase-2/3 executor) and ordered by segment
54/// number. For [`SegmentKind::SplitRaw`] these concatenate ([`ConcatSource`]);
55/// for [`SegmentKind::Ewf`] / [`SegmentKind::SplitVmdk`] they feed a container
56/// reader's multi-segment backing (the `SegmentBacking` seam above).
57#[derive(Debug)]
58#[non_exhaustive]
59pub struct SegmentSources {
60 /// The archive format the segments live in.
61 pub format: Format,
62 /// How the segments reassemble.
63 pub kind: SegmentKind,
64 /// The per-segment seekable handles, ordered by segment number.
65 pub sources: Vec<PeeledSource>,
66}
67
68/// One logical, seekable image reassembled by **concatenating** the ordered
69/// per-segment handles of a raw split set (`.001/.002/.003`). A logical offset
70/// maps to `(segment, local offset)`; a single `read` never crosses a segment
71/// boundary (the caller's `read_exact` loops across it), and `seek` is
72/// bounded — a position past the end clamps to the total length.
73#[derive(Debug)]
74pub struct ConcatSource {
75 segs: Vec<ConcatSeg>,
76 total: u64,
77 pos: u64,
78}
79
80/// One segment inside a [`ConcatSource`]: its handle plus its logical window.
81#[derive(Debug)]
82struct ConcatSeg {
83 source: PeeledSource,
84 /// Logical start offset of this segment in the reassembled image.
85 start: u64,
86 /// Length of this segment in bytes.
87 len: u64,
88}
89
90impl ConcatSource {
91 /// Stitch `sources` (ordered by segment number) into one logical image, each
92 /// segment placed at its running logical offset.
93 pub(crate) fn new(sources: Vec<PeeledSource>) -> Self {
94 let mut segs = Vec::with_capacity(sources.len());
95 let mut start = 0u64;
96 for source in sources {
97 let len = source.len();
98 segs.push(ConcatSeg { source, start, len });
99 start = start.saturating_add(len);
100 }
101 ConcatSource {
102 segs,
103 total: start,
104 pos: 0,
105 }
106 }
107
108 /// The reassembled image's total length, in bytes.
109 #[must_use]
110 pub fn len(&self) -> u64 {
111 self.total
112 }
113
114 /// Whether the reassembled image is empty.
115 #[must_use]
116 pub fn is_empty(&self) -> bool {
117 self.total == 0
118 }
119
120 /// Index of the segment containing logical `pos`, or `None` at/after the end.
121 /// Contiguous, ordered segments make this the first segment whose end exceeds
122 /// `pos` — a zero-length segment (`start == end`) is never selected.
123 fn seg_at(&self, pos: u64) -> Option<usize> {
124 let idx = self.segs.partition_point(|s| s.start + s.len <= pos);
125 (idx < self.segs.len()).then_some(idx)
126 }
127}
128
129impl Read for ConcatSource {
130 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
131 if buf.is_empty() || self.pos >= self.total {
132 return Ok(0);
133 }
134 let pos = self.pos;
135 let Some(i) = self.seg_at(pos) else {
136 return Ok(0); // cov:unreachable: pos < total ⇒ a segment contains it
137 };
138 let seg = &mut self.segs[i];
139 let local = pos - seg.start;
140 seg.source.seek(SeekFrom::Start(local))?;
141 // Cap the read at this segment's boundary so one `read` never crosses
142 // into the next segment; a caller's `read_exact` loops across it.
143 let avail = seg.len - local;
144 let want = (buf.len() as u64).min(avail) as usize;
145 let n = seg.source.read(&mut buf[..want])?;
146 self.pos = self.pos.saturating_add(n as u64);
147 Ok(n)
148 }
149}
150
151impl Seek for ConcatSource {
152 fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
153 let target = match from {
154 SeekFrom::Start(o) => o,
155 SeekFrom::End(o) => offset_from(self.total, o)?,
156 SeekFrom::Current(o) => offset_from(self.pos, o)?,
157 };
158 // A reassembled image is bounded: clamp so reads stop at its end.
159 self.pos = target.min(self.total);
160 Ok(self.pos)
161 }
162}
163
164/// The outcome of reassembling a segmented image (or the finding that the input
165/// is not a segment set).
166#[derive(Debug)]
167#[non_exhaustive]
168pub enum Reassembled {
169 /// A raw split set (`.001/.002/.003`) stitched into one logical `Read + Seek`.
170 Concat(ConcatSource),
171 /// An EWF / split-VMDK set: the ordered per-segment sources for a container
172 /// reader's multi-segment backing (archive-core does not stitch these).
173 Segments(SegmentSources),
174 /// The input is not a segment set (a single member, wrapper, collection, or
175 /// raw stream) — nothing to reassemble.
176 NotSegmented,
177}
178
179/// Produce the ordered per-segment [`PeeledSource`] handles for a segmented
180/// image, or `Ok(None)` when `data` is not a segment set. Each segment is
181/// materialized via its own [`crate::Access`] (in-place window / zran index /
182/// temp spill), in segment-number order — the primitive both [`reassemble`] and
183/// a container reader's `SegmentBacking` build on.
184///
185/// # Errors
186/// Propagates a detect/executor failure ([`crate::ArchiveError`]).
187pub fn segment_sources(data: Vec<u8>, limits: &Limits) -> Result<Option<SegmentSources>> {
188 let AccessPlan::SegmentSet {
189 format,
190 members,
191 kind,
192 } = detect(&data)?
193 else {
194 return Ok(None);
195 };
196 // Share the archive bytes across every segment window (no per-segment clone).
197 let data = Arc::new(data);
198 let mut sources = Vec::with_capacity(members.len());
199 for seg in &members {
200 sources.push(execute_member_access(
201 format,
202 &data,
203 seg.index,
204 &seg.name,
205 &seg.access,
206 limits,
207 )?);
208 }
209 Ok(Some(SegmentSources {
210 format,
211 kind,
212 sources,
213 }))
214}
215
216/// Reassemble a segmented image: a [`SegmentKind::SplitRaw`] set becomes a
217/// stitched [`ConcatSource`]; an [`SegmentKind::Ewf`] / [`SegmentKind::SplitVmdk`]
218/// set yields its ordered [`SegmentSources`] for a container reader's backing;
219/// anything else is [`Reassembled::NotSegmented`].
220///
221/// # Errors
222/// Propagates a detect/executor failure ([`crate::ArchiveError`]).
223pub fn reassemble(data: Vec<u8>, limits: &Limits) -> Result<Reassembled> {
224 let Some(ss) = segment_sources(data, limits)? else {
225 return Ok(Reassembled::NotSegmented);
226 };
227 Ok(match ss.kind {
228 // A raw split IS a byte-for-byte concatenation — stitch it here.
229 SegmentKind::SplitRaw => Reassembled::Concat(ConcatSource::new(ss.sources)),
230 // EWF / split-VMDK need the container reader's multi-segment logic;
231 // hand back the ordered sources for its backing (the seam above).
232 SegmentKind::Ewf | SegmentKind::SplitVmdk => Reassembled::Segments(ss),
233 })
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 /// A deterministic, non-repeating byte pattern of length `n` seeded by
241 /// `seed`, so a wrong segment/offset yields visibly wrong bytes.
242 fn pattern(n: usize, seed: usize) -> Vec<u8> {
243 (0..n).map(|i| ((i + seed) % 251) as u8).collect()
244 }
245
246 /// Hand-assemble a multi-member ZIP whose members are each STORED (method 0),
247 /// so every member's [`crate::Access`] is `InPlace` (a zero-copy window). The
248 /// members appear in the given order — pass them out of segment order to
249 /// prove reassembly reorders by segment number.
250 fn stored_zip(members: &[(&str, &[u8])]) -> Vec<u8> {
251 let mut z = Vec::new();
252 let mut central = Vec::new();
253 let mut count = 0u16;
254 for (name, payload) in members {
255 let nb = name.as_bytes();
256 let mut crc = flate2::Crc::new();
257 crc.update(payload);
258 let crc = crc.sum();
259 let lho = z.len() as u32;
260 let (sz, nlen) = (payload.len() as u32, nb.len() as u16);
261 // Local file header (method 0 = stored).
262 z.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
263 z.extend_from_slice(&20u16.to_le_bytes()); // version needed
264 z.extend_from_slice(&0u16.to_le_bytes()); // flags
265 z.extend_from_slice(&0u16.to_le_bytes()); // method: stored
266 z.extend_from_slice(&0u16.to_le_bytes()); // mod time
267 z.extend_from_slice(&0u16.to_le_bytes()); // mod date
268 z.extend_from_slice(&crc.to_le_bytes());
269 z.extend_from_slice(&sz.to_le_bytes()); // compressed size
270 z.extend_from_slice(&sz.to_le_bytes()); // uncompressed size
271 z.extend_from_slice(&nlen.to_le_bytes());
272 z.extend_from_slice(&0u16.to_le_bytes()); // extra len
273 z.extend_from_slice(nb);
274 z.extend_from_slice(payload);
275 // Central directory header.
276 central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
277 central.extend_from_slice(&20u16.to_le_bytes()); // version made by
278 central.extend_from_slice(&20u16.to_le_bytes()); // version needed
279 central.extend_from_slice(&0u16.to_le_bytes()); // flags
280 central.extend_from_slice(&0u16.to_le_bytes()); // method
281 central.extend_from_slice(&0u16.to_le_bytes()); // mod time
282 central.extend_from_slice(&0u16.to_le_bytes()); // mod date
283 central.extend_from_slice(&crc.to_le_bytes());
284 central.extend_from_slice(&sz.to_le_bytes());
285 central.extend_from_slice(&sz.to_le_bytes());
286 central.extend_from_slice(&nlen.to_le_bytes());
287 central.extend_from_slice(&0u16.to_le_bytes()); // extra len
288 central.extend_from_slice(&0u16.to_le_bytes()); // comment len
289 central.extend_from_slice(&0u16.to_le_bytes()); // disk number start
290 central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
291 central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
292 central.extend_from_slice(&lho.to_le_bytes()); // local header offset
293 central.extend_from_slice(nb);
294 count += 1;
295 }
296 let cd_offset = z.len() as u32;
297 let cd_size = central.len() as u32;
298 z.extend_from_slice(¢ral);
299 // End of central directory.
300 z.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
301 z.extend_from_slice(&0u16.to_le_bytes()); // disk num
302 z.extend_from_slice(&0u16.to_le_bytes()); // disk with cd
303 z.extend_from_slice(&count.to_le_bytes()); // entries this disk
304 z.extend_from_slice(&count.to_le_bytes()); // total entries
305 z.extend_from_slice(&cd_size.to_le_bytes());
306 z.extend_from_slice(&cd_offset.to_le_bytes());
307 z.extend_from_slice(&0u16.to_le_bytes()); // comment len
308 z
309 }
310
311 const FX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/fixtures/");
312
313 fn load(name: &str) -> Vec<u8> {
314 std::fs::read(format!("{FX}{name}")).unwrap()
315 }
316
317 // (a) A raw split set (`.001/.002/.003`), stored OUT OF ORDER in the zip, is
318 // reassembled by concatenation in segment-number order; random reads across
319 // the segment boundaries return the correct logical bytes, and a full read
320 // reproduces `p1 ++ p2 ++ p3`.
321 #[test]
322 fn split_raw_concatenates_in_order_with_random_access() {
323 let p1 = pattern(1000, 0);
324 let p2 = pattern(1500, 100);
325 let p3 = pattern(777, 200);
326 // Out of order in the archive: 002, 003, 001.
327 let data = stored_zip(&[("img.002", &p2), ("img.003", &p3), ("img.001", &p1)]);
328 let Reassembled::Concat(mut c) = reassemble(data, &Limits::default()).unwrap() else {
329 panic!("expected a SplitRaw ConcatSource");
330 };
331 let total = (p1.len() + p2.len() + p3.len()) as u64;
332 assert_eq!(c.len(), total);
333 assert!(!c.is_empty());
334
335 // Full read reproduces the ordered concatenation.
336 let mut whole = Vec::new();
337 c.seek(SeekFrom::Start(0)).unwrap();
338 c.read_to_end(&mut whole).unwrap();
339 let mut expected = Vec::new();
340 expected.extend_from_slice(&p1);
341 expected.extend_from_slice(&p2);
342 expected.extend_from_slice(&p3);
343 assert_eq!(whole, expected);
344
345 // A read straddling the p1|p2 boundary (offset 1000).
346 c.seek(SeekFrom::Start(995)).unwrap();
347 let mut span = [0u8; 12];
348 c.read_exact(&mut span).unwrap();
349 assert_eq!(&span[..5], &p1[995..1000]);
350 assert_eq!(&span[5..], &p2[..7]);
351
352 // A read straddling the p2|p3 boundary (offset 2500).
353 c.seek(SeekFrom::Start(2495)).unwrap();
354 let mut span2 = [0u8; 10];
355 c.read_exact(&mut span2).unwrap();
356 assert_eq!(&span2[..5], &p2[p2.len() - 5..]);
357 assert_eq!(&span2[5..], &p3[..5]);
358
359 // End-relative seek reads the tail of the last segment.
360 c.seek(SeekFrom::End(-4)).unwrap();
361 let mut tail = [0u8; 4];
362 c.read_exact(&mut tail).unwrap();
363 assert_eq!(tail, p3[p3.len() - 4..]);
364
365 // A seek past the end clamps to the total length.
366 assert_eq!(c.seek(SeekFrom::Start(u64::MAX)).unwrap(), total);
367 assert_eq!(c.read(&mut [0u8; 8]).unwrap(), 0);
368 }
369
370 // (b) Each segment of a raw split uses its OWN access — a stored member is a
371 // zero-copy `InPlace` window, never a copy — and `segment_sources` orders
372 // them by segment number, each reading the exact segment bytes.
373 #[test]
374 fn split_raw_segment_sources_are_inplace_and_ordered() {
375 let p1 = pattern(300, 1);
376 let p2 = pattern(400, 2);
377 let data = stored_zip(&[("d.002", &p2), ("d.001", &p1)]);
378 let ss = segment_sources(data, &Limits::default())
379 .unwrap()
380 .expect("a segment set");
381 assert_eq!(ss.kind, SegmentKind::SplitRaw);
382 assert_eq!(ss.sources.len(), 2);
383 let expected = [p1, p2];
384 for (src, want) in ss.sources.into_iter().zip(expected) {
385 assert!(
386 matches!(src, PeeledSource::InPlace(_)),
387 "a stored segment is a zero-copy InPlace window"
388 );
389 let mut got = Vec::new();
390 let mut src = src;
391 src.read_to_end(&mut got).unwrap();
392 assert_eq!(got, want, "segment bytes read in order");
393 }
394 }
395
396 // (c) An EWF `.E0N` set: archive-core hands back the ORDERED per-segment
397 // sources (the `SegmentBacking` seam) — E01, E02, E03 — each reading the exact
398 // segment bytes. Uses the committed `seg_ewf.zip` (members stored out of order).
399 #[test]
400 fn ewf_segment_sources_are_ordered_per_segment_handles() {
401 let data = load("seg_ewf.zip");
402 // Oracle: the ordered member bytes, straight from the archive reader.
403 let mut a = crate::Archive::open(&data, Some("seg_ewf.zip"))
404 .unwrap()
405 .unwrap();
406 let order = ["img.E01", "img.E02", "img.E03"];
407 let oracle: Vec<Vec<u8>> = order
408 .iter()
409 .map(|n| {
410 let idx = a.entries().iter().position(|e| e.name == *n).unwrap();
411 a.read(idx).unwrap()
412 })
413 .collect();
414
415 let ss = segment_sources(data, &Limits::default())
416 .unwrap()
417 .expect("a segment set");
418 assert_eq!(ss.kind, SegmentKind::Ewf);
419 assert_eq!(ss.sources.len(), 3);
420 for (src, want) in ss.sources.into_iter().zip(oracle) {
421 let mut got = Vec::new();
422 let mut src = src;
423 src.read_to_end(&mut got).unwrap();
424 assert_eq!(got, want, "ordered E0N segment bytes");
425 }
426
427 // `reassemble` routes an EWF set to the Segments seam, not a ConcatSource.
428 let data = load("seg_ewf.zip");
429 match reassemble(data, &Limits::default()).unwrap() {
430 Reassembled::Segments(s) => {
431 assert_eq!(s.kind, SegmentKind::Ewf);
432 assert_eq!(s.sources.len(), 3);
433 }
434 other => panic!("expected Segments seam, got {other:?}"),
435 }
436 }
437
438 // A non-segmented input (a single stored member) is `NotSegmented`, and
439 // `segment_sources` returns `None`.
440 #[test]
441 fn single_member_is_not_segmented() {
442 let data = load("stored_one.zip");
443 assert!(matches!(
444 reassemble(data.clone(), &Limits::default()).unwrap(),
445 Reassembled::NotSegmented
446 ));
447 assert!(segment_sources(data, &Limits::default()).unwrap().is_none());
448 }
449}