Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ categories = ["parser-implementations", "filesystem"]
exclude = ["fuzz/", ".github/", "wix/", "deny.toml", "renovate.json", ".pre-commit-config.yaml"]

[dependencies]
# Bounded integer reads over untrusted image headers (ADR-0012).
safe-read = "0.2"
thiserror = "2"
# Archive-layer peel: transparently unwrap a compression-wrapped image
# (evidence.dd.gz -> dd) before container detection.
Expand Down Expand Up @@ -106,3 +108,22 @@ acquisition-integrity findings."""
assets = [
["target/release/disk4n6", "usr/bin/", "755"],
]

[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
correctness = "deny"
suspicious = "deny"
unwrap_used = "deny"
expect_used = "deny"
# container::open is a flat dispatch over every supported container format;
# splitting it would scatter one readable match across helpers. Allowed fleet-wide.
too_many_lines = { level = "allow", priority = 1 }
module_name_repetitions = { level = "allow", priority = 1 }
must_use_candidate = { level = "allow", priority = 1 }
missing_errors_doc = { level = "allow", priority = 1 }
missing_panics_doc = { level = "allow", priority = 1 }
cast_possible_truncation = { level = "allow", priority = 1 }
cast_possible_wrap = { level = "allow", priority = 1 }
cast_sign_loss = { level = "allow", priority = 1 }
cast_precision_loss = { level = "allow", priority = 1 }
23 changes: 17 additions & 6 deletions src/bin/disk4n6.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ fn run_list(json: bool) -> ExitCode {
}
};

// Without `serde` the JSON arm ends in `return`, so clippy sees the `else`
// as redundant; with `serde` enabled it is not. Keep the `else` - removing it
// breaks the feature-on build. Verified: this does not fire under --all-features.
#[allow(clippy::redundant_else)]
if json {
#[cfg(feature = "serde")]
{
Expand Down Expand Up @@ -233,6 +237,10 @@ fn report_disk(
}
};

// Without `serde` the JSON arm ends in `return`, so clippy sees the `else`
// as redundant; with `serde` enabled it is not. Keep the `else` - removing it
// breaks the feature-on build. Verified: this does not fire under --all-features.
#[allow(clippy::redundant_else)]
if json {
#[cfg(feature = "serde")]
{
Expand Down Expand Up @@ -283,6 +291,10 @@ fn analyse_filesystem(path: &str, reader: &mut Box<dyn ReadSeek>, json: bool) ->
}
};

// Without `serde` the JSON arm ends in `return`, so clippy sees the `else`
// as redundant; with `serde` enabled it is not. Keep the `else` - removing it
// breaks the feature-on build. Verified: this does not fire under --all-features.
#[allow(clippy::redundant_else)]
if json {
#[cfg(feature = "serde")]
{
Expand All @@ -299,13 +311,12 @@ fn analyse_filesystem(path: &str, reader: &mut Box<dyn ReadSeek>, json: bool) ->
eprintln!("disk4n6: --json requires the `serde` feature");
return ExitCode::from(2);
}
} else {
println!("Filesystem: ISO 9660\n");
print!(
"{}",
disk_forensic::report::render(&disk_forensic::normalize::iso_report(&analysis))
);
}
println!("Filesystem: ISO 9660\n");
print!(
"{}",
disk_forensic::report::render(&disk_forensic::normalize::iso_report(&analysis))
);

if analysis.anomalies.is_empty() {
ExitCode::SUCCESS
Expand Down
11 changes: 6 additions & 5 deletions src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,32 +275,34 @@ const AD1_SEGMENTED_MARKER: &[u8] = b"ADSEGMENTEDFILE";
/// DAR offset-0 magic — `SAUV_MAGIC_NUMBER` (123) as a big-endian u32. Mirrors
/// `dar-core`'s `DAR_MAGIC`.
const DAR_MAGIC: [u8; 4] = [0x00, 0x00, 0x00, 0x7b];
/// ECMA-119 puts the Primary Volume Descriptor at sector 16 (32768) + 1 byte.
const ISO_PVD_OFFSET: usize = 32769;

/// A detected disk-image container format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum ContainerFormat {
/// No container wrapper — a flat raw/`dd` image (analyse in place).
Raw,
/// Expert Witness Format (EnCase E01 / Ex01 / logical L01).
/// Expert Witness Format (`EnCase` E01 / Ex01 / logical L01).
Ewf,
/// Microsoft VHD (fixed / dynamic / differencing).
Vhd,
/// Microsoft VHDX.
Vhdx,
/// VMware VMDK (sparse extent).
/// `VMware` VMDK (sparse extent).
Vmdk,
/// QEMU / KVM QCOW2.
Qcow2,
/// Advanced Forensic Format 4 (ZIP-based). Physical (`aff4:ImageStream` /
/// `aff4:Map`) images decode to a disk view via [`open`]; logical
/// (`aff4:FileImage`) collections are read via [`crate::logical::open`].
Aff4,
/// AccessData AD1 (FTK "Custom Content Image") — a *logical* file container,
/// `AccessData` AD1 (FTK "Custom Content Image") — a *logical* file container,
/// not a raw disk. Read via [`crate::logical::open`]; [`open`] refuses it
/// with [`OpenError::LogicalContainer`].
Ad1,
/// DAR (Denis Corbin Disk ARchiver) backup archive — a *logical* file
/// DAR (Denis Corbin Disk `ARchiver`) backup archive — a *logical* file
/// container, not a raw disk. Read via [`crate::logical::open`].
Dar,
/// Apple Disk Image (UDIF).
Expand Down Expand Up @@ -353,7 +355,6 @@ pub fn detect(header: &[u8], footer: &[u8]) -> ContainerFormat {
return ContainerFormat::Dar;
}
// ── Optical (ISO 9660): "CD001" at the PVD, offset 32769 (ECMA-119) ───────
const ISO_PVD_OFFSET: usize = 32769;
if header.len() >= ISO_PVD_OFFSET + 5 && &header[ISO_PVD_OFFSET..ISO_PVD_OFFSET + 5] == b"CD001"
{
return ContainerFormat::Iso;
Expand Down
6 changes: 3 additions & 3 deletions src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ fn mbr_partitions(mbr: &mbr_partition_forensic::MbrAnalysis) -> (u32, Vec<Partit
}

fn apm_partitions(apm: &apm_partition_forensic::ApmAnalysis) -> (u32, Vec<Partition>) {
let bs = apm.block_size as u64;
let bs = u64::from(apm.block_size);
let parts = apm
.partitions
.iter()
Expand All @@ -113,8 +113,8 @@ fn apm_partitions(apm: &apm_partition_forensic::ApmAnalysis) -> (u32, Vec<Partit
};
part(
name,
p.start_block as u64 * bs,
(p.end_block() as u64 - p.start_block as u64 + 1) * bs,
u64::from(p.start_block) * bs,
(u64::from(p.end_block()) - u64::from(p.start_block) + 1) * bs,
p.type_name.clone(),
None,
)
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```

#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]

use std::io::{Read, Seek, SeekFrom};

pub mod container;
Expand Down
4 changes: 2 additions & 2 deletions src/logical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//! disk.
//!
//! Some forensic containers carry captured files rather than a block device:
//! AccessData **AD1** (FTK "Custom Content Image") and **AFF4-Logical**
//! `AccessData` **AD1** (FTK "Custom Content Image") and **AFF4-Logical**
//! (`aff4:FileImage` collections). They have no partition table or filesystem to
//! walk with the partition parsers, so they do not fit [`crate::container::open`]
//! (which yields a `Read + Seek` *disk* view). [`open`] is their home: it lists
Expand Down Expand Up @@ -84,7 +84,7 @@ impl core::fmt::Debug for LogicalImage {
f.debug_struct("LogicalImage")
.field("format", &self.format)
.field("entries", &self.entries.len())
.finish()
.finish_non_exhaustive()
}
}

Expand Down
37 changes: 29 additions & 8 deletions src/vhd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ impl VhdReader {
}
}

let data_offset = u64::from_be_bytes(footer[16..24].try_into().unwrap());
let virtual_size = u64::from_be_bytes(footer[48..56].try_into().unwrap());
let disk_type = u32::from_be_bytes(footer[60..64].try_into().unwrap());
let data_offset = safe_read::be_u64(&footer, 16);
let virtual_size = safe_read::be_u64(&footer, 48);
let disk_type = safe_read::be_u32(&footer, 60);

let layout = match disk_type {
2 => Layout::Fixed,
Expand Down Expand Up @@ -94,19 +94,40 @@ impl VhdReader {
if &dh[0..8] != b"cxsparse" {
return Err(invalid("missing dynamic-disk 'cxsparse' header"));
}
let table_offset = u64::from_be_bytes(dh[16..24].try_into().unwrap());
let max_entries = u32::from_be_bytes(dh[28..32].try_into().unwrap()) as usize;
let block_size = u64::from(u32::from_be_bytes(dh[32..36].try_into().unwrap()));
let table_offset = safe_read::be_u64(&dh, 16);
let max_entries = safe_read::be_u32(&dh, 28);
let block_size = u64::from(safe_read::be_u32(&dh, 32));
if block_size == 0 || block_size % SECTOR != 0 {
return Err(invalid("invalid VHD block size"));
}

let mut bat_raw = vec![0u8; max_entries * 4];
// `max_entries` is an untrusted u32 straight from the image: 0xFFFF_FFFF
// asks for a 16 GiB allocation before a single BAT byte is known to
// exist. The BAT cannot outgrow the file that holds it, so bound it by
// the file first (ADR-0012: never trust a length field).
let bat_bytes = u64::from(max_entries) * 4;
let file_len = file.seek(SeekFrom::End(0))?;
if table_offset > file_len || bat_bytes > file_len - table_offset {
return Err(invalid(format!(
"VHD BAT claims {bat_bytes} bytes at offset {table_offset}, \
past the end of the {file_len}-byte file"
)));
}
// let-else rather than `.map_err(|_| ...)`: the closure would be a
// function llvm-cov can never see executed, because bat_bytes is a u32
// times 4 and so always fits a 64-bit usize. The guard is kept for the
// 32-bit case; only the closure goes.
let Ok(bat_len) = usize::try_from(bat_bytes) else {
return Err(invalid(format!("VHD BAT size {bat_bytes} exceeds usize")));
};

let mut bat_raw = vec![0u8; bat_len];
file.seek(SeekFrom::Start(table_offset))?;
file.read_exact(&mut bat_raw)?;
let bat = bat_raw
.chunks_exact(4)
.map(|c| u32::from_be_bytes(c.try_into().unwrap()))
.enumerate()
.map(|(i, _)| safe_read::be_u32(&bat_raw, i * 4))
.collect();

let sectors_per_block = block_size / SECTOR;
Expand Down
4 changes: 3 additions & 1 deletion tests/ad1_logical_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
//! and `logical::open` must list its entries and read a file's bytes back.
//!
//! Fixtures come from `ad1::testfix` (the reader crate's spec-faithful builder,
//! ground truth via independent flate2 + RustCrypto), so correctness is not
//! ground truth via independent flate2 + `RustCrypto`), so correctness is not
//! self-referential.

#![allow(clippy::unwrap_used, clippy::expect_used)]

use disk_forensic::container::{open, ContainerFormat, OpenError};
use disk_forensic::logical;

Expand Down
2 changes: 2 additions & 0 deletions tests/aff4_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
//! Fixtures come from `aff4::testutil` (the reader crate's own spec-faithful
//! builder), so ground truth is independent of disk-forensic.

#![allow(clippy::unwrap_used, clippy::expect_used)]

use disk_forensic::container::{open, ContainerFormat, OpenError};
use std::io::{Read, Seek, SeekFrom};

Expand Down
4 changes: 3 additions & 1 deletion tests/cli_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! End-to-end tests for the `disk-forensic` binary.

#![allow(clippy::unwrap_used, clippy::expect_used)]

mod common;
use common::{build_gpt, build_mbr};
use std::path::PathBuf;
Expand Down Expand Up @@ -113,7 +115,7 @@ fn no_args_defaults_to_listing() {
// panic. On the Linux CI runner this drives the sysfs backend end-to-end.
let out = bin().output().unwrap();
assert!(
matches!(out.status.code(), Some(0) | Some(1)),
matches!(out.status.code(), Some(0 | 1)),
"default-list exit: {:?}, stderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
Expand Down
2 changes: 2 additions & 0 deletions tests/container_findings_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
//! shutdown, …) aggregate into the normalized report alongside the partition and
//! filesystem findings — not silently dropped at the container boundary.

#![allow(clippy::unwrap_used, clippy::expect_used)]

const DF_VMDK: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/df.vmdk");

#[test]
Expand Down
2 changes: 2 additions & 0 deletions tests/container_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Container-format detection (magic-sniff) — which decoder a disk image needs.

#![allow(clippy::unwrap_used, clippy::expect_used)]

use disk_forensic::container::{detect, ContainerFormat};
use forensicnomicon::{aff4, dmg, ewf, qcow2, vhd, vhdx, vmdk};

Expand Down
4 changes: 3 additions & 1 deletion tests/dar_logical_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! DAR (Denis Corbin Disk ARchiver) and the logical-container path.
//! DAR (Denis Corbin Disk `ARchiver`) and the logical-container path.
//!
//! DAR is a *logical* backup archive (a file tree), not a raw disk, so
//! [`container::open`] must refuse it with [`OpenError::LogicalContainer`]
Expand All @@ -8,6 +8,8 @@
//! The fixture `tests/data/v11_hello.dar` is a real `dar`-produced archive
//! (format 11) holding `files/hello.txt`; provenance mirrors dar-core's corpus.

#![allow(clippy::unwrap_used, clippy::expect_used)]

use disk_forensic::container::{open, ContainerFormat, OpenError};
use disk_forensic::logical;

Expand Down
2 changes: 2 additions & 0 deletions tests/decode_error_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
//! [`container::open`] routes it to the matching decoder, which then rejects the
//! junk body. This exercises the otherwise-untested error arm of every decoder.

#![allow(clippy::unwrap_used, clippy::expect_used)]

use disk_forensic::container::{open, ContainerFormat, OpenError};
use forensicnomicon::{dmg, ewf, qcow2, vhd, vhdx, vmdk};

Expand Down
2 changes: 2 additions & 0 deletions tests/dispatch_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Scheme auto-detection and dispatch to the correct parser.

#![allow(clippy::unwrap_used, clippy::expect_used)]

mod common;
use common::{build_gpt, build_mbr};
use disk_forensic::{analyse_disk, DiskReport, Error, Scheme};
Expand Down
4 changes: 3 additions & 1 deletion tests/iso_normalize_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Normalizing an ISO 9660 analysis into the shared forensicnomicon::report
//! Normalizing an ISO 9660 analysis into the shared `forensicnomicon::report`
//! model — provenance completeness and the temporal timeline.

#![allow(clippy::unwrap_used, clippy::expect_used)]

use disk_forensic::normalize;
use std::fs::File;

Expand Down
5 changes: 3 additions & 2 deletions tests/live_linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
//! known device on the Linux CI runner (where passwordless `sudo` is available);
//! it auto-skips anywhere `sudo losetup` is not usable, so local `cargo test`
//! stays green.

#![allow(clippy::unwrap_used, clippy::expect_used)]
#![cfg(target_os = "linux")]

mod common;
Expand All @@ -17,8 +19,7 @@ fn sudo_losetup_available() -> bool {
Command::new("sudo")
.args(["-n", "losetup", "--version"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
.is_ok_and(|o| o.status.success())
}

#[test]
Expand Down
4 changes: 3 additions & 1 deletion tests/normalize_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Normalizing each scheme's native analysis into the shared
//! forensicnomicon::report::Report model.
//! `forensicnomicon::report::Report` model.

#![allow(clippy::unwrap_used, clippy::expect_used)]

mod common;
use common::{build_gpt, build_mbr};
Expand Down
2 changes: 2 additions & 0 deletions tests/open_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Opening images: raw passthrough, E01 (EWF) decoding, and unsupported
//! containers — feeding a decoded `Read + Seek` view into `analyse_disk`.

#![allow(clippy::unwrap_used, clippy::expect_used)]

use disk_forensic::container::{open, ContainerFormat, OpenError};
use disk_forensic::{analyse_disk, Scheme};
use std::path::Path;
Expand Down
2 changes: 2 additions & 0 deletions tests/provenance_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Provenance breadcrumbs normalized from each scheme's native fields.

#![allow(clippy::unwrap_used, clippy::expect_used)]

mod common;
use common::{build_gpt, build_mbr};
use disk_forensic::{analyse_disk, normalize};
Expand Down
2 changes: 2 additions & 0 deletions tests/render_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! disk4n6's presentation of the normalized findings Report.

#![allow(clippy::unwrap_used, clippy::expect_used)]

mod common;
use common::build_mbr;
use disk_forensic::{analyse_disk, normalize, report::render};
Expand Down
Loading
Loading