use std::io::{self, Read, Seek, SeekFrom};
use forensicnomicon::report::{Category, Finding, Severity};
use vmdk::header::{self, SparseExtentHeader};
use vmdk::sesparse::{self, SeConstHeader};
pub use vmdk::VmdkReader;
const SECTOR_SIZE: u64 = 512;
const MAX_GD_BYTES: u64 = 16 * 1024 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct IntegrityReport {
pub grains_checked: u64,
pub out_of_bounds_grains: u64,
pub out_of_bounds_grain_tables: u64,
}
impl IntegrityReport {
#[must_use]
pub fn is_ok(&self) -> bool {
self.out_of_bounds_grains == 0 && self.out_of_bounds_grain_tables == 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GdRecoveryReport {
pub has_rgd: bool,
pub total_entries: usize,
pub primary_intact: usize,
pub primary_damaged: usize,
pub recoverable_via_rgd: usize,
pub unrecoverable: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[allow(clippy::struct_excessive_bools)] pub struct HeaderProvenance {
pub version: u32,
pub unclean_shutdown: bool,
pub newline_check_intact: bool,
pub uses_redundant_gd: bool,
pub compressed: bool,
pub has_markers: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum AnomalyKind {
UncleanShutdown,
FtpAsciiMangled,
RedundantGdMismatch,
PrimaryGdRecoverableViaRgd {
damaged: usize,
total: usize,
recoverable: usize,
},
PrimaryGdUnrecoverable {
unrecoverable: usize,
},
DanglingGrainTable {
count: u64,
},
DanglingGrain {
count: u64,
},
}
impl forensicnomicon::report::Observation for AnomalyKind {
fn severity(&self) -> Option<Severity> {
use AnomalyKind::{
DanglingGrain, DanglingGrainTable, FtpAsciiMangled, PrimaryGdRecoverableViaRgd,
PrimaryGdUnrecoverable, RedundantGdMismatch, UncleanShutdown,
};
Some(match self {
UncleanShutdown => Severity::Low,
PrimaryGdRecoverableViaRgd { .. } => Severity::Medium,
FtpAsciiMangled
| RedundantGdMismatch
| PrimaryGdUnrecoverable { .. }
| DanglingGrainTable { .. }
| DanglingGrain { .. } => Severity::High,
})
}
fn category(&self) -> Category {
match self {
AnomalyKind::DanglingGrainTable { .. } | AnomalyKind::DanglingGrain { .. } => {
Category::Structure
}
_ => Category::Integrity,
}
}
fn code(&self) -> &'static str {
use AnomalyKind::{
DanglingGrain, DanglingGrainTable, FtpAsciiMangled, PrimaryGdRecoverableViaRgd,
PrimaryGdUnrecoverable, RedundantGdMismatch, UncleanShutdown,
};
match self {
UncleanShutdown => "VMDK-UNCLEAN-SHUTDOWN",
FtpAsciiMangled => "VMDK-FTP-ASCII-MANGLED",
RedundantGdMismatch => "VMDK-RGD-MISMATCH",
PrimaryGdRecoverableViaRgd { .. } => "VMDK-PRIMARY-GD-RECOVERABLE",
PrimaryGdUnrecoverable { .. } => "VMDK-PRIMARY-GD-UNRECOVERABLE",
DanglingGrainTable { .. } => "VMDK-DANGLING-GT",
DanglingGrain { .. } => "VMDK-DANGLING-GRAIN",
}
}
fn note(&self) -> String {
use AnomalyKind::{
DanglingGrain, DanglingGrainTable, FtpAsciiMangled, PrimaryGdRecoverableViaRgd,
PrimaryGdUnrecoverable, RedundantGdMismatch, UncleanShutdown,
};
match self {
UncleanShutdown => "uncleanShutdown flag set — the disk was not closed cleanly; \
in-flight writes may be inconsistent"
.to_string(),
FtpAsciiMangled => "header newline-detection bytes mangled — the image was likely \
corrupted by an ASCII-mode FTP transfer"
.to_string(),
RedundantGdMismatch => "redundant grain directory diverges from the primary — the \
grain tables they reference hold different contents"
.to_string(),
PrimaryGdRecoverableViaRgd { damaged, total, recoverable } => format!(
"{damaged} of {total} grain-directory entries damaged, {recoverable} recoverable via the RGD"
),
PrimaryGdUnrecoverable { unrecoverable } => format!(
"{unrecoverable} grain-directory entries damaged with no RGD recovery available"
),
DanglingGrainTable { count } => format!(
"{count} grain-table pointer(s) point beyond end-of-file (truncation or tampering)"
),
DanglingGrain { count } => format!(
"{count} grain pointer(s) point beyond end-of-file (truncation or tampering)"
),
}
}
fn mitre(&self) -> &'static [&'static str] {
match self {
AnomalyKind::RedundantGdMismatch => &["T1565.001"],
_ => &[],
}
}
fn evidence(&self) -> Vec<forensicnomicon::report::Evidence> {
use AnomalyKind::{DanglingGrain, DanglingGrainTable, PrimaryGdRecoverableViaRgd, PrimaryGdUnrecoverable};
let ev = |field: &str, value: String| forensicnomicon::report::Evidence {
field: field.to_string(),
value,
location: None,
};
match self {
PrimaryGdRecoverableViaRgd { damaged, total, recoverable } => vec![
ev("damaged", damaged.to_string()),
ev("total", total.to_string()),
ev("recoverable", recoverable.to_string()),
],
PrimaryGdUnrecoverable { unrecoverable } => {
vec![ev("unrecoverable", unrecoverable.to_string())]
}
DanglingGrainTable { count } | DanglingGrain { count } => {
vec![ev("count", count.to_string())]
}
_ => Vec::new(),
}
}
}
struct SparseLayout {
grain_dir: Vec<u32>,
rgd_offset: u64,
num_gtes_per_gt: u64,
grain_size_bytes: u64,
gd_entry_count: usize,
file_len: u64,
}
pub struct VmdkIntegrity<R: Read + Seek> {
inner: R,
}
impl<R: Read + Seek> VmdkIntegrity<R> {
pub fn new(reader: R) -> Self {
Self { inner: reader }
}
pub fn into_inner(self) -> R {
self.inner
}
fn file_len(&mut self) -> io::Result<u64> {
self.inner.seek(SeekFrom::End(0))
}
fn read_at(&mut self, offset: u64, len: usize) -> io::Result<Vec<u8>> {
self.inner.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; len];
self.inner.read_exact(&mut buf)?;
Ok(buf)
}
fn sparse_layout(&mut self) -> io::Result<Option<SparseLayout>> {
let file_len = self.file_len()?;
if file_len < SECTOR_SIZE {
return Ok(None);
}
let hdr_bytes = self.read_at(0, SECTOR_SIZE as usize)?;
let Ok(hdr) = SparseExtentHeader::parse(&hdr_bytes) else {
return Ok(None);
};
let num_grains = hdr.capacity.div_ceil(hdr.grain_size);
let num_gtes = u64::from(hdr.num_gtes_per_gt);
let num_gts = num_grains.div_ceil(num_gtes);
let gd_byte_len = num_gts.saturating_mul(4);
if gd_byte_len > MAX_GD_BYTES {
return Ok(None);
}
let gd_byte_len = gd_byte_len as usize;
let gd_offset = if hdr.gd_offset == header::GD_AT_END {
if file_len < 1024 {
return Ok(None);
}
let footer = self.read_at(file_len - 1024, SECTOR_SIZE as usize)?;
match SparseExtentHeader::parse(&footer) {
Ok(fh) => fh.gd_offset,
Err(_) => return Ok(None),
}
} else {
hdr.gd_offset
};
let gd_byte = gd_offset.saturating_mul(SECTOR_SIZE);
if gd_byte.saturating_add(gd_byte_len as u64) > file_len {
return Ok(None);
}
let gd = self.read_at(gd_byte, gd_byte_len)?;
let grain_dir: Vec<u32> = gd
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().expect("4 bytes")))
.collect();
Ok(Some(SparseLayout {
grain_dir,
rgd_offset: hdr.rgd_offset,
num_gtes_per_gt: num_gtes,
grain_size_bytes: hdr.grain_size.saturating_mul(SECTOR_SIZE),
gd_entry_count: num_gts as usize,
file_len,
}))
}
fn read_grain_table_bytes(
&mut self,
gt_sector: u32,
gt_byte_len: usize,
file_len: u64,
) -> io::Result<Option<Vec<u8>>> {
let gt_byte = u64::from(gt_sector) * SECTOR_SIZE;
if gt_byte.saturating_add(gt_byte_len as u64) > file_len {
return Ok(None);
}
Ok(Some(self.read_at(gt_byte, gt_byte_len)?))
}
fn read_rgd(&mut self, layout: &SparseLayout) -> io::Result<Option<Vec<u32>>> {
if layout.rgd_offset == 0 || layout.rgd_offset == header::GD_AT_END {
return Ok(None);
}
let rgd_byte = layout.rgd_offset.saturating_mul(SECTOR_SIZE);
let len = layout.gd_entry_count.saturating_mul(4) as u64;
if rgd_byte.saturating_add(len) > layout.file_len {
return Ok(None);
}
let bytes = self.read_at(rgd_byte, len as usize)?;
Ok(Some(
bytes
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().expect("4 bytes")))
.collect(),
))
}
pub fn validate_rgd(&mut self) -> io::Result<bool> {
let Some(layout) = self.sparse_layout()? else {
return Ok(false);
};
let Some(rgd) = self.read_rgd(&layout)? else {
return Ok(false);
};
let gt_byte_len = (layout.num_gtes_per_gt * 4) as usize;
for i in 0..layout.gd_entry_count {
let p = layout.grain_dir.get(i).copied().unwrap_or(0);
let r = rgd.get(i).copied().unwrap_or(0);
if p == 0 && r == 0 {
continue;
}
if (p == 0) != (r == 0) {
return Ok(false);
}
let pgt = self.read_grain_table_bytes(p, gt_byte_len, layout.file_len)?;
let rgt = self.read_grain_table_bytes(r, gt_byte_len, layout.file_len)?;
match (pgt, rgt) {
(Some(a), Some(b)) if a == b => {}
_ => return Ok(false),
}
}
Ok(true)
}
pub fn grain_directory_recovery(&mut self) -> io::Result<GdRecoveryReport> {
let Some(layout) = self.sparse_layout()? else {
return Ok(GdRecoveryReport::default());
};
let rgd = self.read_rgd(&layout)?;
let Some(rgd) = rgd else {
if layout.rgd_offset == 0 || layout.rgd_offset == header::GD_AT_END {
return Ok(GdRecoveryReport::default());
}
let mut report = GdRecoveryReport {
has_rgd: true,
total_entries: layout.gd_entry_count,
..GdRecoveryReport::default()
};
for &p in &layout.grain_dir {
if Self::in_bounds(p, layout.num_gtes_per_gt, layout.file_len) || p == 0 {
report.primary_intact += 1;
} else {
report.primary_damaged += 1;
report.unrecoverable += 1;
}
}
return Ok(report);
};
let mut report = GdRecoveryReport {
has_rgd: true,
total_entries: layout.gd_entry_count,
..GdRecoveryReport::default()
};
for i in 0..layout.gd_entry_count {
let p = layout.grain_dir.get(i).copied().unwrap_or(0);
let r = rgd.get(i).copied().unwrap_or(0);
let p_ok = Self::in_bounds(p, layout.num_gtes_per_gt, layout.file_len);
if p_ok || (p == 0 && r == 0) {
report.primary_intact += 1;
} else {
report.primary_damaged += 1;
if Self::in_bounds(r, layout.num_gtes_per_gt, layout.file_len) {
report.recoverable_via_rgd += 1;
} else {
report.unrecoverable += 1;
}
}
}
Ok(report)
}
fn in_bounds(ptr: u32, num_gtes_per_gt: u64, file_len: u64) -> bool {
ptr != 0
&& u64::from(ptr)
.saturating_mul(SECTOR_SIZE)
.saturating_add(num_gtes_per_gt * 4)
<= file_len
}
pub fn check_integrity(&mut self) -> io::Result<IntegrityReport> {
let file_len = self.file_len()?;
if file_len < SECTOR_SIZE {
return Ok(IntegrityReport::default());
}
let head = self.read_at(0, 8)?;
let magic8 = u64::from_le_bytes(head.try_into().expect("8 bytes"));
if magic8 == sesparse::SE_CONST_MAGIC {
return self.check_integrity_sesparse(file_len);
}
let Some(layout) = self.sparse_layout()? else {
return Ok(IntegrityReport::default());
};
let mut report = IntegrityReport::default();
let gt_byte_len = layout.num_gtes_per_gt * 4;
for >_sector in &layout.grain_dir {
if gt_sector == 0 {
continue;
}
let gt_byte = u64::from(gt_sector) * SECTOR_SIZE;
if gt_byte.saturating_add(gt_byte_len) > file_len {
report.out_of_bounds_grain_tables += 1;
continue;
}
let gt = self.read_at(gt_byte, gt_byte_len as usize)?;
for c in gt.chunks_exact(4) {
let gte = u32::from_le_bytes(c.try_into().expect("4 bytes"));
if gte <= 1 {
continue; }
report.grains_checked += 1;
let grain_byte = u64::from(gte) * SECTOR_SIZE;
if grain_byte.saturating_add(layout.grain_size_bytes) > file_len {
report.out_of_bounds_grains += 1;
}
}
}
Ok(report)
}
fn check_integrity_sesparse(&mut self, file_len: u64) -> io::Result<IntegrityReport> {
let mut report = IntegrityReport::default();
let hdr_bytes = self.read_at(0, SECTOR_SIZE as usize)?;
let Ok(hdr) = SeConstHeader::parse(&hdr_bytes) else {
return Ok(report);
};
if hdr.grain_size == 0 {
return Ok(report);
}
let num_grains = hdr.capacity.div_ceil(hdr.grain_size);
let num_gts = num_grains.div_ceil(sesparse::SE_GTES_PER_GT).max(1);
let gd_len = num_gts.saturating_mul(8);
let gd_byte = hdr.gd_offset.saturating_mul(SECTOR_SIZE);
if gd_len > MAX_GD_BYTES || gd_byte.saturating_add(gd_len) > file_len {
return Ok(report);
}
let gd = self.read_at(gd_byte, gd_len as usize)?;
let grain_dir: Vec<u64> = gd
.chunks_exact(8)
.map(|c| u64::from_le_bytes(c.try_into().expect("8 bytes")))
.collect();
let grain_size_bytes = hdr.grain_size.saturating_mul(SECTOR_SIZE);
let grain_sectors = hdr.grain_size;
let gt_byte_len = sesparse::SE_GTES_PER_GT * 8;
for &gd_entry in &grain_dir {
if gd_entry == 0 {
continue;
}
if gd_entry & sesparse::SE_GD_ALLOC_MASK != sesparse::SE_GD_ALLOC_FLAG {
report.out_of_bounds_grain_tables += 1;
continue;
}
let gt_table_idx = gd_entry & sesparse::SE_GD_INDEX_MASK;
let gt_sector = hdr
.gt_offset
.saturating_add(gt_table_idx.saturating_mul(sesparse::SE_GT_SECTORS));
let gt_byte = gt_sector.saturating_mul(SECTOR_SIZE);
if gt_byte.saturating_add(gt_byte_len) > file_len {
report.out_of_bounds_grain_tables += 1;
continue;
}
let gt = self.read_at(gt_byte, gt_byte_len as usize)?;
for c in gt.chunks_exact(8) {
let gte = u64::from_le_bytes(c.try_into().expect("8 bytes"));
if gte & sesparse::SE_GTE_TYPE_MASK != sesparse::SE_GTE_TYPE_ALLOCATED {
continue;
}
report.grains_checked += 1;
let grain_idx = sesparse::se_gte_grain_index(gte);
let grain_byte = hdr
.grains_offset
.saturating_add(grain_idx.saturating_mul(grain_sectors))
.saturating_mul(SECTOR_SIZE);
if grain_byte.saturating_add(grain_size_bytes) > file_len {
report.out_of_bounds_grains += 1;
}
}
}
Ok(report)
}
pub fn header_provenance(&mut self) -> io::Result<Option<HeaderProvenance>> {
let file_len = self.file_len()?;
if file_len < SECTOR_SIZE {
return Ok(None);
}
let hdr = self.read_at(0, SECTOR_SIZE as usize)?;
if u32::from_le_bytes(hdr[0..4].try_into().expect("4 bytes")) != header::MAGIC {
return Ok(None);
}
let version = u32::from_le_bytes(hdr[4..8].try_into().expect("4 bytes"));
let flags = u32::from_le_bytes(hdr[8..12].try_into().expect("4 bytes"));
Ok(Some(HeaderProvenance {
version,
unclean_shutdown: hdr[72] != 0,
newline_check_intact: hdr[73..77] == [0x0A, 0x20, 0x0D, 0x0A],
uses_redundant_gd: flags & 0x0000_0002 != 0,
compressed: flags & 0x0001_0000 != 0,
has_markers: flags & 0x0002_0000 != 0,
}))
}
pub fn analyse(&mut self) -> io::Result<Vec<Finding>> {
use forensicnomicon::report::{Observation, Source};
let mut kinds: Vec<AnomalyKind> = Vec::new();
if let Some(p) = self.header_provenance()? {
if p.unclean_shutdown {
kinds.push(AnomalyKind::UncleanShutdown);
}
if !p.newline_check_intact {
kinds.push(AnomalyKind::FtpAsciiMangled);
}
}
let recovery = self.grain_directory_recovery()?;
if recovery.has_rgd && !self.validate_rgd()? {
kinds.push(AnomalyKind::RedundantGdMismatch);
}
if recovery.recoverable_via_rgd > 0 {
kinds.push(AnomalyKind::PrimaryGdRecoverableViaRgd {
damaged: recovery.primary_damaged,
total: recovery.total_entries,
recoverable: recovery.recoverable_via_rgd,
});
}
if recovery.unrecoverable > 0 {
kinds.push(AnomalyKind::PrimaryGdUnrecoverable {
unrecoverable: recovery.unrecoverable,
});
}
let integrity = self.check_integrity()?;
if integrity.out_of_bounds_grain_tables > 0 {
kinds.push(AnomalyKind::DanglingGrainTable {
count: integrity.out_of_bounds_grain_tables,
});
}
if integrity.out_of_bounds_grains > 0 {
kinds.push(AnomalyKind::DanglingGrain {
count: integrity.out_of_bounds_grains,
});
}
let source = Source {
analyzer: "vmdk-forensic".to_string(),
scope: "VMDK".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
let mut out: Vec<Finding> = kinds.iter().map(|k| k.to_finding(source.clone())).collect();
out.sort_by(|a, b| b.severity.cmp(&a.severity));
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use forensicnomicon::report::Severity;
use std::io::Cursor;
use vmdk::testutil::{test_sesparse_vmdk, test_sparse_vmdk};
#[test]
fn header_provenance_clean_image() {
let v = test_sparse_vmdk(&[0u8; 512]);
let mut a = VmdkIntegrity::new(Cursor::new(v));
let p = a.header_provenance().expect("io").expect("VMDK4 header");
assert_eq!(p.version, 1);
assert!(!p.unclean_shutdown);
assert!(p.newline_check_intact);
}
#[test]
fn validate_rgd_true_on_healthy_image() {
let v = test_sparse_vmdk(&[0xAB; 512]);
let mut a = VmdkIntegrity::new(Cursor::new(v));
assert!(a.validate_rgd().expect("io"));
}
#[test]
fn validate_rgd_false_on_redundant_gt_divergence() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[22 * 512] ^= 0xFF;
let mut a = VmdkIntegrity::new(Cursor::new(v));
assert!(!a.validate_rgd().expect("io"));
}
#[test]
fn grain_directory_recovery_flags_recoverable_damage() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[21 * 512..21 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(v));
let r = a.grain_directory_recovery().expect("io");
assert!(r.has_rgd);
assert_eq!(r.primary_damaged, 1);
assert_eq!(r.recoverable_via_rgd, 1);
}
#[test]
fn check_integrity_clean_then_flags_dangling_gt() {
let v = test_sparse_vmdk(&[0xAB; 512]);
let mut a = VmdkIntegrity::new(Cursor::new(v));
assert!(a.check_integrity().expect("io").is_ok());
let mut v2 = test_sparse_vmdk(&[0xAB; 512]);
v2[21 * 512..21 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
let mut a2 = VmdkIntegrity::new(Cursor::new(v2));
let rep = a2.check_integrity().expect("io");
assert!(!rep.is_ok());
assert_eq!(rep.out_of_bounds_grain_tables, 1);
}
#[test]
fn analyse_reports_rgd_mismatch_anomaly() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[22 * 512] ^= 0xFF; let mut a = VmdkIntegrity::new(Cursor::new(v));
let anomalies = a.analyse().expect("io");
assert!(
anomalies
.iter()
.any(|x| x.code.as_ref() == "VMDK-RGD-MISMATCH"),
"expected an RGD mismatch anomaly, got: {anomalies:?}"
);
}
#[test]
fn into_inner_returns_reader() {
let v = test_sparse_vmdk(&[0u8; 512]);
let a = VmdkIntegrity::new(Cursor::new(v));
let _cursor = a.into_inner();
}
#[test]
fn header_provenance_flags_unclean_shutdown_and_ftp_mangling() {
let mut v = test_sparse_vmdk(&[0u8; 512]);
v[72] = 1; v[73] = 0x20; let mut a = VmdkIntegrity::new(Cursor::new(v));
let p = a.header_provenance().expect("io").expect("vmdk4");
assert!(p.unclean_shutdown);
assert!(!p.newline_check_intact);
}
#[test]
fn header_provenance_none_for_non_vmdk4() {
let mut a = VmdkIntegrity::new(Cursor::new(vec![0u8; 1024]));
assert!(a.header_provenance().expect("io").is_none());
}
#[test]
fn validate_rgd_false_for_sesparse() {
let se = test_sesparse_vmdk(&[0u8; 512]);
let mut a = VmdkIntegrity::new(Cursor::new(se));
assert!(!a.validate_rgd().expect("io")); }
#[test]
fn grain_directory_recovery_default_when_no_rgd() {
let mut v = test_sparse_vmdk(&[0u8; 512]);
v[48..56].copy_from_slice(&0u64.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(v));
let r = a.grain_directory_recovery().expect("io");
assert!(!r.has_rgd);
assert_eq!(r.total_entries, 0);
}
#[test]
fn grain_directory_recovery_counts_unrecoverable() {
let mut v = test_sparse_vmdk(&[0u8; 512]);
v[21 * 512..21 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); v[22 * 512..22 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(v));
let r = a.grain_directory_recovery().expect("io");
assert_eq!(r.primary_damaged, 1);
assert_eq!(r.unrecoverable, 1);
assert_eq!(r.recoverable_via_rgd, 0);
}
#[test]
fn grain_directory_recovery_clean_all_intact() {
let v = test_sparse_vmdk(&[0xAB; 512]);
let mut a = VmdkIntegrity::new(Cursor::new(v));
let r = a.grain_directory_recovery().expect("io");
assert!(r.has_rgd);
assert_eq!(r.primary_intact, r.total_entries);
assert_eq!(r.primary_damaged, 0);
}
#[test]
fn sesparse_integrity_clean_and_flagged() {
let se = test_sesparse_vmdk(&[0xAB; 512]);
let mut a = VmdkIntegrity::new(Cursor::new(se));
assert!(a.check_integrity().expect("io").is_ok());
let mut se2 = test_sesparse_vmdk(&[0xAB; 512]);
let gd = 2 * 512;
se2[gd..gd + 8].copy_from_slice(&0x5000_0000_0000_0000u64.to_le_bytes());
let mut a2 = VmdkIntegrity::new(Cursor::new(se2));
let rep = a2.check_integrity().expect("io");
assert!(!rep.is_ok());
assert_eq!(rep.out_of_bounds_grain_tables, 1);
let mut se3 = test_sesparse_vmdk(&[0xAB; 512]);
se3[gd..gd + 8].copy_from_slice(&(0x1000_0000_0000_0000u64 | 0xFFFF_FFFF).to_le_bytes());
let mut a3 = VmdkIntegrity::new(Cursor::new(se3));
assert!(!a3.check_integrity().expect("io").is_ok());
}
#[test]
fn check_integrity_flags_grain_past_eof() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[23 * 512..23 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
let mut a = VmdkIntegrity::new(Cursor::new(v));
let rep = a.check_integrity().expect("io");
assert_eq!(rep.out_of_bounds_grains, 1);
assert!(!rep.is_ok());
}
#[test]
fn analyse_flags_unclean_shutdown_warning() {
let mut v = test_sparse_vmdk(&[0u8; 512]);
v[72] = 1;
let mut a = VmdkIntegrity::new(Cursor::new(v));
let anomalies = a.analyse().expect("io");
assert!(anomalies
.iter()
.any(|x| x.code.as_ref() == "VMDK-UNCLEAN-SHUTDOWN"));
}
#[test]
fn analyse_flags_dangling_and_recoverable_for_corrupt_primary_gd() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[21 * 512..21 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
let mut a = VmdkIntegrity::new(Cursor::new(v));
let k: Vec<_> = a
.analyse()
.expect("io")
.into_iter()
.map(|x| x.code)
.collect();
assert!(k.iter().any(|c| c.as_ref() == "VMDK-DANGLING-GT"));
assert!(k.iter().any(|c| c.as_ref() == "VMDK-PRIMARY-GD-RECOVERABLE"));
}
#[test]
fn analyse_flags_unrecoverable_when_both_directories_damaged() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[21 * 512..21 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
v[22 * 512..22 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
let mut a = VmdkIntegrity::new(Cursor::new(v));
let k: Vec<_> = a
.analyse()
.expect("io")
.into_iter()
.map(|x| x.code)
.collect();
assert!(k.iter().any(|c| c.as_ref() == "VMDK-PRIMARY-GD-UNRECOVERABLE"));
}
#[test]
fn tiny_and_garbage_inputs_are_safe() {
for bytes in [vec![0u8; 100], vec![0xFFu8; 512], vec![0u8; 600]] {
let mut a = VmdkIntegrity::new(Cursor::new(bytes));
assert!(!a.validate_rgd().expect("io"));
assert!(a.check_integrity().expect("io").is_ok());
assert!(a.grain_directory_recovery().expect("io").total_entries == 0);
assert!(a.header_provenance().expect("io").is_none());
let _ = a.analyse().expect("io");
}
}
#[test]
fn validate_rgd_false_when_only_one_directory_has_a_gt() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[21 * 512..21 * 512 + 4].copy_from_slice(&0u32.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(v));
assert!(!a.validate_rgd().expect("io"));
}
#[test]
fn grain_directory_recovery_rgd_directory_out_of_bounds_is_unrecoverable() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[21 * 512..21 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); v[48..56].copy_from_slice(&9_999_999u64.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(v));
let r = a.grain_directory_recovery().expect("io");
assert!(r.has_rgd);
assert_eq!(r.unrecoverable, 1);
assert_eq!(r.recoverable_via_rgd, 0);
}
#[test]
fn analyse_flags_ftp_ascii_mangling() {
let mut v = test_sparse_vmdk(&[0u8; 512]);
v[73] = 0x20; let mut a = VmdkIntegrity::new(Cursor::new(v));
let k: Vec<_> = a
.analyse()
.expect("io")
.into_iter()
.map(|x| x.code)
.collect();
assert!(k.iter().any(|c| c.as_ref() == "VMDK-FTP-ASCII-MANGLED"));
}
#[test]
fn analyse_flags_dangling_grain() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[23 * 512..23 * 512 + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(v));
let k: Vec<_> = a
.analyse()
.expect("io")
.into_iter()
.map(|x| x.code)
.collect();
assert!(k.iter().any(|c| c.as_ref() == "VMDK-DANGLING-GRAIN"));
}
#[test]
fn sesparse_grain_past_eof_is_flagged() {
let mut se = test_sesparse_vmdk(&[0xAB; 512]);
se[16..24].copy_from_slice(&u64::MAX.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(se));
let _ = a.check_integrity().expect("io"); }
#[test]
fn validate_rgd_false_when_grain_directory_out_of_bounds() {
let mut v = test_sparse_vmdk(&[0u8; 512]);
v[56..64].copy_from_slice(&9_999_999u64.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(v));
assert!(!a.validate_rgd().expect("io"));
assert!(a.check_integrity().expect("io").is_ok()); }
#[test]
fn validate_rgd_false_when_rgd_directory_out_of_bounds() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[48..56].copy_from_slice(&9_999_999u64.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(v));
assert!(!a.validate_rgd().expect("io"));
}
#[test]
fn sesparse_zero_grain_size_and_oob_gd_are_safe() {
let mut se = test_sesparse_vmdk(&[0u8; 512]);
se[24..32].copy_from_slice(&0u64.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(se));
assert!(a.check_integrity().expect("io").is_ok());
let mut se2 = test_sesparse_vmdk(&[0u8; 512]);
se2[128..136].copy_from_slice(&9_999_999u64.to_le_bytes()); let mut a2 = VmdkIntegrity::new(Cursor::new(se2));
assert!(a2.check_integrity().expect("io").is_ok());
}
#[test]
fn streamoptimized_gd_at_end_footer_resolution() {
let mut v = test_sparse_vmdk(&[0xAB; 512]);
v[56..64].copy_from_slice(&0xFFFF_FFFF_FFFF_FFFFu64.to_le_bytes()); let mut footer = v[0..512].to_vec();
footer[56..64].copy_from_slice(&21u64.to_le_bytes()); v.extend_from_slice(&footer);
v.extend_from_slice(&[0u8; 512]); let mut a = VmdkIntegrity::new(Cursor::new(v));
assert!(a.check_integrity().expect("io").is_ok());
assert!(a.validate_rgd().expect("io"));
}
#[test]
fn sesparse_huge_capacity_grain_directory_too_large_is_safe() {
let mut se = test_sesparse_vmdk(&[0u8; 512]);
se[16..24].copy_from_slice(&u64::MAX.to_le_bytes()); let mut a = VmdkIntegrity::new(Cursor::new(se));
assert!(a.check_integrity().expect("io").is_ok()); }
#[test]
fn sesparse_allocated_gte_grain_past_eof_is_flagged() {
let mut se = test_sesparse_vmdk(&[0xAB; 512]);
let gt0 = 3 * 512; se[gt0..gt0 + 8].copy_from_slice(&(0x3000_0000_0000_0000u64 | 0x00FF_FFFF).to_le_bytes());
let mut a = VmdkIntegrity::new(Cursor::new(se));
let _ = a.check_integrity().expect("io"); }
#[test]
fn analyse_clean_image_has_no_error_anomalies() {
let v = test_sparse_vmdk(&[0xAB; 512]);
let mut a = VmdkIntegrity::new(Cursor::new(v));
let anomalies = a.analyse().expect("io");
assert!(anomalies.iter().all(|x| x.severity < Some(Severity::High)));
}
}