ewf-image 0.4.0

Pure-Rust library for reading and writing EWF/E01 forensic disk images.
Documentation
//! Verification and integrity report regressions.
#![cfg(feature = "verify")]

use std::io::{Seek, SeekFrom, Write};
use std::ops::ControlFlow;

use ewf_image::{
    EwfError, EwfWriter, HashReference, Image, IntegrityFindingKind, MediaScanStatus, OpenOptions,
    SectionKind, VerifyOptions, WriteCompression, WriteFormat, WriteOptions,
};
use sha2::{Digest, Sha256};

fn fixture(
    data: &[u8],
    format: WriteFormat,
    compression: WriteCompression,
) -> (tempfile::TempDir, std::path::PathBuf) {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("image.E01");
    let options = WriteOptions {
        format,
        compression,
        bytes_per_sector: 1,
        sectors_per_chunk: 32768,
        ..WriteOptions::default()
    };
    let mut writer = EwfWriter::create(&path, options).unwrap();
    writer.write_all(data).unwrap();
    writer.finish().unwrap();
    (dir, path)
}

#[test]
fn known_sha256_vector_and_independent_reference_mismatch() {
    let (_dir, path) = fixture(b"abc", WriteFormat::Ewf1Physical, WriteCompression::Zlib);
    let image = Image::open(path).unwrap();
    let expected = [
        0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae, 0x22,
        0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61, 0xf2, 0x00,
        0x15, 0xad,
    ];
    let options = VerifyOptions::default()
        .with_expected_sha256(expected)
        .with_expected_md5([0; 16]);
    let report = image.verify_with_options(&options).unwrap();
    assert_eq!(report.hashes.sha256, expected);
    assert_eq!(report.bytes_verified, 3);
    assert_eq!(report.references_match(), Some(false));
    assert!(
        report
            .comparisons
            .iter()
            .any(|item| item.reference == HashReference::Stored && item.matches)
    );
    assert!(
        report
            .comparisons
            .iter()
            .any(|item| item.reference == HashReference::External && !item.matches)
    );
    let analysis = image.analyze(&options).unwrap();
    assert_eq!(analysis.media_status, MediaScanStatus::Complete);
    assert!(analysis.findings.iter().any(|item| matches!(
        item.kind,
        IntegrityFindingKind::HashMismatch {
            reference: HashReference::External,
            ..
        }
    )));
}

#[test]
fn progress_is_ordered_exact_and_locally_cancellable() {
    let data = vec![0x57; 32768 * 3 + 7];
    let (_dir, path) = fixture(&data, WriteFormat::Ewf1Physical, WriteCompression::Zlib);
    let image = Image::open(path).unwrap();
    let mut events = Vec::new();
    let report = image
        .verify_with_progress(&VerifyOptions::default(), |event| {
            events.push(event);
            ControlFlow::Continue(())
        })
        .unwrap();
    assert_eq!(events.len(), 5);
    assert_eq!(events[0].bytes_processed, 0);
    assert_eq!(events[4].bytes_processed, data.len() as u64);
    assert_eq!(events[4].bytes_verified, data.len() as u64);
    assert_eq!(
        report.hashes.sha256.as_slice(),
        Sha256::digest(&data).as_slice()
    );
    for pair in events.windows(2) {
        assert_eq!(pair[1].chunks_processed, pair[0].chunks_processed + 1);
    }
    let error = image
        .verify_with_progress(&VerifyOptions::default(), |event| {
            if event.chunks_processed == 1 {
                ControlFlow::Break(())
            } else {
                ControlFlow::Continue(())
            }
        })
        .unwrap_err();
    assert!(matches!(error, EwfError::Aborted));
    assert_eq!(image.read_at(&mut [0; 1], 0).unwrap(), 1);
    image.signal_abort();
    assert!(matches!(image.verify(), Err(EwfError::Aborted)));
}

#[test]
fn verification_rejects_cached_recovery_zeros_and_analysis_continues() {
    let data = vec![0x57; 32768 * 3];
    let (_dir, path) = fixture(&data, WriteFormat::Ewf1Physical, WriteCompression::None);
    let pristine = Image::open(&path).unwrap();
    let sectors = pristine
        .sections()
        .iter()
        .find(|section| section.kind == SectionKind::Ewf1("sectors".into()))
        .unwrap();
    let offset = sectors.data_offset;
    drop(pristine);
    let mut file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
    file.seek(SeekFrom::Start(offset)).unwrap();
    file.write_all(&[0xff]).unwrap();
    file.seek(SeekFrom::Start(offset + 32768 + 4)).unwrap();
    file.write_all(&[0xfe]).unwrap();
    drop(file);
    let image = Image::open_with_options(
        path,
        OpenOptions::default().with_read_zero_chunk_on_error(true),
    )
    .unwrap();
    let mut buffer = [0xff; 8];
    image.read_at(&mut buffer, 0).unwrap();
    assert_eq!(buffer, [0; 8]);
    assert!(image.verify().is_err());
    let report = image
        .analyze(&VerifyOptions::default().with_maximum_findings(1))
        .unwrap();
    assert_eq!(report.media_status, MediaScanStatus::Incomplete);
    assert_eq!(report.bytes_verified, 32768);
    assert!(report.hashes.is_none());
    assert!(report.comparisons.is_empty());
    assert_eq!(report.error_count, 2);
    assert_eq!(report.findings.len(), 1);
    assert_eq!(report.suppressed_findings, 1);
    assert_eq!(report.findings[0].logical_offset, Some(0));
}

#[test]
fn no_reference_is_distinct_from_a_match() {
    let (_dir, path) = fixture(b"abc", WriteFormat::Ewf1Physical, WriteCompression::None);
    let image = Image::open(&path).unwrap();
    let mut bytes = std::fs::read(&path).unwrap();
    for section in image.sections().iter().filter(|section| matches!(&section.kind, SectionKind::Ewf1(name) if name == "hash" || name == "digest" || name == "xhash")) {
        let offset = section.descriptor_offset as usize;
        bytes[offset..offset + 16].fill(0);
        bytes[offset..offset + 7].copy_from_slice(b"unknown");
        bytes[offset + 72..offset + 76].fill(0);
    }
    let image =
        Image::open_sources([("memory.E01", ewf_image::SegmentSource::from_bytes(bytes))]).unwrap();
    assert_eq!(
        image
            .verify_with_options(&VerifyOptions::default())
            .unwrap()
            .references_match(),
        None
    );
}

#[test]
fn structural_failure_has_unavailable_coverage_even_when_findings_are_suppressed() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("bad.E01");
    std::fs::write(&path, b"not an EWF image").unwrap();
    let report =
        ewf_image::analyze_path(path, &VerifyOptions::default().with_maximum_findings(0)).unwrap();
    assert_eq!(report.media_status, MediaScanStatus::Unavailable);
    assert!(report.hashes.is_none());
    assert_eq!(report.error_count, 1);
    assert_eq!(report.suppressed_findings, 1);
}

#[test]
fn invalid_options_fail_before_progress() {
    let (_dir, path) = fixture(b"abc", WriteFormat::Ewf1Physical, WriteCompression::None);
    let image = Image::open(path).unwrap();
    for options in [
        VerifyOptions::default().with_parallelism(0),
        VerifyOptions::default().with_parallelism(65),
        VerifyOptions::default().with_chunk_buffer_size_bytes(0),
    ] {
        assert!(
            image
                .verify_with_progress(&options, |_| panic!("invalid options must not scan"))
                .is_err()
        );
    }
}

#[test]
fn empty_media_hashes_and_progress_are_well_defined() {
    let (_dir, path) = fixture(b"", WriteFormat::Ewf1Physical, WriteCompression::None);
    let image = Image::open(path).unwrap();
    let mut events = Vec::new();
    let report = image
        .verify_with_progress(&VerifyOptions::default(), |event| {
            events.push(event);
            ControlFlow::Continue(())
        })
        .unwrap();
    assert_eq!(
        report.hashes.sha256.as_slice(),
        Sha256::digest(b"").as_slice()
    );
    assert_eq!(report.bytes_verified, 0);
    assert_eq!(events.len(), 1);
    assert_eq!(events[0].chunks_total, 0);
}

#[cfg(feature = "parallel")]
#[test]
fn encrypted_positioned_sources_verify_against_the_reference_vector() {
    let expected = [
        0xa4, 0xa3, 0xec, 0x30, 0xd6, 0x38, 0x82, 0x44, 0xde, 0xd0, 0x6c, 0x36, 0xc1, 0xc7, 0xf5,
        0x01, 0xee, 0x42, 0xfd, 0x79, 0x40, 0xc0, 0x43, 0x50, 0xc6, 0xe3, 0x67, 0xa6, 0x44, 0x22,
        0x03, 0x13,
    ];
    for name in ["aes128-known-password.E01", "aes256-known-password.E01"] {
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("tests/data/xways-encrypted")
            .join(name);
        let source = ewf_image::SegmentSource::from_bytes(std::fs::read(path).unwrap());
        let password = ewf_image::EwfPassword::utf8("xways-test");
        let image = Image::open_sources_with_options_and_password(
            [(name, source)],
            OpenOptions::default(),
            &password,
        )
        .unwrap();
        let options = VerifyOptions::default()
            .with_parallelism(4)
            .with_expected_sha256(expected);
        assert_eq!(
            image
                .verify_with_options(&options)
                .unwrap()
                .references_match(),
            Some(true)
        );
    }
}

#[cfg(feature = "parallel")]
#[test]
fn split_images_verify_across_positioned_backings_and_handle_limits() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("split.E01");
    let data: Vec<_> = (0..65536 * 2).map(|i| (i % 193) as u8).collect();
    let mut writer = EwfWriter::create(
        &path,
        WriteOptions {
            maximum_segment_size: Some(34_500),
            ..WriteOptions::default()
        },
    )
    .unwrap();
    writer.write_all(&data).unwrap();
    let written = writer.finish().unwrap();
    assert!(written.segment_paths.len() > 1);
    let sources: Vec<_> = written
        .segment_paths
        .iter()
        .map(|path| {
            (
                path.clone(),
                ewf_image::SegmentSource::from_file(std::fs::File::open(path).unwrap()).unwrap(),
            )
        })
        .collect();
    let options = VerifyOptions::default().with_parallelism(4);
    let supplied = Image::open_sources(sources)
        .unwrap()
        .verify_with_options(&options)
        .unwrap();
    let image = Image::open_with_options(
        path,
        OpenOptions::default().with_maximum_open_handles(Some(1)),
    )
    .unwrap();
    assert_eq!(image.verify_with_options(&options).unwrap(), supplied);
    assert!(image.number_of_open_segment_handles().unwrap() <= 1);
}

#[cfg(feature = "parallel")]
#[test]
#[ignore = "manual release-mode throughput measurement"]
fn benchmark_verification_workers() {
    let data: Vec<_> = (0..32 * 1024 * 1024)
        .map(|i| ((i * 31 + i / 251) % 251) as u8)
        .collect();
    for (format, compression) in [
        (WriteFormat::Ewf1Physical, WriteCompression::Zlib),
        (WriteFormat::Ewf2Physical, WriteCompression::Bzip2),
    ] {
        let (_dir, path) = fixture(&data, format, compression);
        let image = Image::open_sources([(
            "benchmark.E01",
            ewf_image::SegmentSource::from_file(std::fs::File::open(path).unwrap()).unwrap(),
        )])
        .unwrap();
        let expected = Sha256::digest(&data);
        for workers in [1, 4] {
            let options = VerifyOptions::default().with_parallelism(workers);
            let mut times = Vec::new();
            for _ in 0..3 {
                let start = std::time::Instant::now();
                let report = image.verify_with_options(&options).unwrap();
                times.push(start.elapsed());
                assert_eq!(report.hashes.sha256.as_slice(), expected.as_slice());
            }
            times.sort();
            eprintln!(
                "{compression:?}, {workers} workers: median {:?}, {:.1} MiB/s",
                times[1],
                32.0 / times[1].as_secs_f64()
            );
        }
    }
}

#[cfg(feature = "parallel")]
#[test]
fn parallel_hashes_match_serial_across_formats_without_polluting_chunk_cache() {
    let data: Vec<u8> = (0..32768 * 9 + 17).map(|i| (i * 31 % 251) as u8).collect();
    for (format, compression) in [
        (WriteFormat::Ewf1Physical, WriteCompression::None),
        (WriteFormat::Ewf1Physical, WriteCompression::Zlib),
        (WriteFormat::Ewf2Physical, WriteCompression::Zlib),
        (WriteFormat::Ewf2Physical, WriteCompression::Bzip2),
        (WriteFormat::Ewf2Logical, WriteCompression::None),
    ] {
        let (_dir, path) = fixture(&data, format, compression);
        let image =
            Image::open_with_options(path, OpenOptions::default().with_reader_statistics(true))
                .unwrap();
        image.read_at(&mut [0; 8], 0).unwrap();
        let before = image.reader_statistics().unwrap();
        let serial = image
            .verify_with_options(&VerifyOptions::default())
            .unwrap();
        let parallel = image
            .verify_with_options(
                &VerifyOptions::default()
                    .with_parallelism(4)
                    .with_chunk_buffer_size_bytes(32768 * 3),
            )
            .unwrap();
        assert_eq!(parallel, serial);
        let after = image.reader_statistics().unwrap();
        assert_eq!(after.chunk_cache_hits(), before.chunk_cache_hits());
        assert_eq!(after.chunk_cache_misses(), before.chunk_cache_misses());
        assert_eq!(
            serial.hashes.sha256.as_slice(),
            Sha256::digest(&data).as_slice()
        );
    }
}

#[cfg(not(feature = "parallel"))]
#[test]
fn requesting_parallelism_requires_the_feature() {
    let (_dir, path) = fixture(b"abc", WriteFormat::Ewf1Physical, WriteCompression::None);
    let image = Image::open(path).unwrap();
    assert!(matches!(
        image.verify_with_options(&VerifyOptions::default().with_parallelism(2)),
        Err(EwfError::Unsupported(_))
    ));
}

#[cfg(feature = "serde")]
#[test]
fn reports_serialize_without_losing_coverage_or_reference_origin() {
    let (_dir, path) = fixture(b"abc", WriteFormat::Ewf1Physical, WriteCompression::None);
    let report = Image::open(path)
        .unwrap()
        .analyze(&VerifyOptions::default().with_expected_sha256([0; 32]))
        .unwrap();
    let json = serde_json::to_value(report).unwrap();
    assert_eq!(json["media_status"], "Complete");
    assert_eq!(json["error_count"], 1);
    assert!(
        json["comparisons"]
            .as_array()
            .unwrap()
            .iter()
            .any(|item| item["reference"] == "External")
    );
}