1use crate::archive::Archive;
8use crate::detect::{sniff, Format};
9use crate::error::{ArchiveError, Result};
10
11const HEAD_PEEK: u64 = 512;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum Access {
21 InPlace {
23 offset: u64,
25 len: u64,
27 },
28 Zran,
31 SpillToTemp,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum Codec {
40 Gzip,
42 Bzip2,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum SegmentKind {
50 Ewf,
52 SplitRaw,
54 SplitVmdk,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Segment {
61 pub name: String,
63 pub index: usize,
65 pub access: Access,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71#[non_exhaustive]
72pub enum AccessPlan {
73 Direct,
75 Wrapper {
77 codec: Codec,
79 access: Access,
81 },
82 Member {
84 format: Format,
86 index: usize,
88 name: String,
90 access: Access,
92 },
93 SegmentSet {
95 format: Format,
97 members: Vec<Segment>,
99 kind: SegmentKind,
101 },
102 Collection {
104 format: Format,
106 },
107}
108
109pub fn detect(data: &[u8]) -> Result<AccessPlan> {
118 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 _ => Ok(AccessPlan::Direct),
128 }
129}
130
131fn detect_wrapper(codec: Codec, data: &[u8]) -> Result<AccessPlan> {
136 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
165fn detect_archive(format: Format, data: &[u8]) -> Result<AccessPlan> {
169 let Some(mut archive) = Archive::open_with_format(format, data)? else {
170 return Ok(AccessPlan::Collection { format });
174 };
175 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 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 _ => Ok(AccessPlan::Collection { format }),
220 }
221}
222
223fn 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
242fn wrapper_access(codec: Codec) -> Access {
244 match codec {
245 Codec::Gzip => Access::Zran,
246 Codec::Bzip2 => Access::SpillToTemp,
247 }
248}
249
250fn codec_name(codec: Codec) -> &'static str {
252 match codec {
253 Codec::Gzip => "gzip",
254 Codec::Bzip2 => "bzip2",
255 }
256}
257
258fn 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
267fn 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
279fn 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
289fn 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
304fn 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
314fn 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 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 #[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 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 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 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 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 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 #[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 #[test]
509 fn zip_ewf_segments_order_by_number_each_inplace() {
510 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 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 #[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 #[test]
600 fn bzip2_tar_classified_by_decompressed_content_not_name() {
601 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 #[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); 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); 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); assert_eq!(vmdk_segment("disk-flat.vmdk"), None);
639 assert_eq!(vmdk_segment("disk-s001.bin"), None); }
641
642 #[test]
645 fn vmdk_segment_rejects_malformed_s_index() {
646 assert_eq!(vmdk_segment("disk-sx.vmdk"), None); assert_eq!(vmdk_segment("disk-s.vmdk"), None); }
649}