use core::fmt;
use crate::entry::GptEntry;
use crate::guid::Guid;
use crate::header::GptHeader;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum Location {
Primary,
Backup,
}
impl fmt::Display for Location {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Location::Primary => "primary",
Location::Backup => "backup",
})
}
}
pub use forensicnomicon::report::Severity;
impl forensicnomicon::report::Observation for Anomaly {
fn severity(&self) -> Option<Severity> {
Some(self.severity)
}
fn code(&self) -> &'static str {
self.code
}
fn note(&self) -> String {
self.note.clone()
}
fn evidence(&self) -> Vec<forensicnomicon::report::Evidence> {
use forensicnomicon::report::Evidence;
use forensicnomicon::report::Location as Loc;
let at = |field: String, value: String, location: Loc| Evidence {
field,
value,
location: Some(location),
};
match &self.kind {
AnomalyKind::HeaderCrcInvalid { location }
| AnomalyKind::HeaderSlackData { location }
| AnomalyKind::PartitionArrayCrcInvalid { location } => vec![Evidence {
field: "GPT copy".to_string(),
value: location.to_string(),
location: None,
}],
AnomalyKind::HeaderLbaMismatch {
location,
claimed,
actual,
} => vec![at(
"header LBA".to_string(),
format!("{location} header claimed {claimed}, read at {actual}"),
Loc::Lba(*actual),
)],
AnomalyKind::BackupGptNotAtDiskEnd {
alternate_lba,
disk_last_lba,
} => vec![at(
"backup GPT".to_string(),
format!("alternate_lba {alternate_lba}, disk ends at {disk_last_lba}"),
Loc::Lba(*alternate_lba),
)],
AnomalyKind::PrimaryBackupDivergence { field } => vec![at(
"diverging field".to_string(),
(*field).to_string(),
Loc::Field((*field).to_string()),
)],
AnomalyKind::PartitionOutOfBounds {
index,
last_lba,
last_usable,
} => vec![at(
format!("partition {index} last LBA"),
format!("{last_lba} past last usable {last_usable}"),
Loc::Lba(*last_lba),
)],
AnomalyKind::PartitionOverlapsGptArea {
index,
first_lba,
first_usable,
} => vec![at(
format!("partition {index} first LBA"),
format!("{first_lba} before first usable {first_usable}"),
Loc::Lba(*first_lba),
)],
AnomalyKind::ProtectiveMbrUndersized {
covered_last_lba,
disk_last_lba,
} => vec![at(
"protective MBR coverage".to_string(),
format!("covers up to {covered_last_lba} of {disk_last_lba}"),
Loc::Lba(*covered_last_lba),
)],
AnomalyKind::HybridMbrHiddenPartition {
mbr_index,
lba_start,
lba_count,
} => vec![at(
format!("hybrid MBR entry {mbr_index}"),
format!("starts at {lba_start}, {lba_count} sectors"),
Loc::Lba(u64::from(*lba_start)),
)],
AnomalyKind::OverlappingPartitions { a, b }
| AnomalyKind::DuplicatePartitionGuid { a, b } => vec![Evidence {
field: "partitions".to_string(),
value: format!("{a} & {b}"),
location: None,
}],
AnomalyKind::HiddenEncryptedVolume { index, entropy } => vec![Evidence {
field: format!("partition {index} entropy"),
value: format!("{entropy:.2}"),
location: None,
}],
AnomalyKind::BackupGptUnreadable | AnomalyKind::MissingProtectiveMbr => Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum AnomalyKind {
HeaderCrcInvalid { location: Location },
HeaderSlackData { location: Location },
HeaderLbaMismatch {
location: Location,
claimed: u64,
actual: u64,
},
PartitionArrayCrcInvalid { location: Location },
BackupGptUnreadable,
BackupGptNotAtDiskEnd {
alternate_lba: u64,
disk_last_lba: u64,
},
PrimaryBackupDivergence { field: &'static str },
OverlappingPartitions { a: usize, b: usize },
DuplicatePartitionGuid { a: usize, b: usize },
HiddenEncryptedVolume { index: usize, entropy: f64 },
PartitionOutOfBounds {
index: usize,
last_lba: u64,
last_usable: u64,
},
PartitionOverlapsGptArea {
index: usize,
first_lba: u64,
first_usable: u64,
},
MissingProtectiveMbr,
ProtectiveMbrUndersized {
covered_last_lba: u64,
disk_last_lba: u64,
},
HybridMbrHiddenPartition {
mbr_index: usize,
lba_start: u32,
lba_count: u32,
},
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
use AnomalyKind as K;
match self {
K::OverlappingPartitions { .. } => Severity::Critical,
K::HeaderCrcInvalid { .. }
| K::HeaderSlackData { .. }
| K::HeaderLbaMismatch { .. }
| K::PartitionArrayCrcInvalid { .. }
| K::BackupGptUnreadable
| K::BackupGptNotAtDiskEnd { .. }
| K::PrimaryBackupDivergence { .. }
| K::PartitionOutOfBounds { .. }
| K::MissingProtectiveMbr
| K::ProtectiveMbrUndersized { .. }
| K::HybridMbrHiddenPartition { .. }
| K::DuplicatePartitionGuid { .. }
| K::PartitionOverlapsGptArea { .. }
| K::HiddenEncryptedVolume { .. } => Severity::High,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
use AnomalyKind as K;
match self {
K::HeaderCrcInvalid { .. } => "GPT-HDR-CRC",
K::HeaderSlackData { .. } => "GPT-HDR-SLACK",
K::HeaderLbaMismatch { .. } => "GPT-HDR-LBA",
K::PartitionArrayCrcInvalid { .. } => "GPT-ARRAY-CRC",
K::BackupGptUnreadable => "GPT-BACKUP-MISSING",
K::BackupGptNotAtDiskEnd { .. } => "GPT-BACKUP-NOTATEND",
K::PrimaryBackupDivergence { .. } => "GPT-DIVERGENCE",
K::OverlappingPartitions { .. } => "GPT-PART-OVERLAP",
K::DuplicatePartitionGuid { .. } => "GPT-PART-DUPGUID",
K::HiddenEncryptedVolume { .. } => "GPT-PART-ENCRYPTED",
K::PartitionOutOfBounds { .. } => "GPT-PART-OOB",
K::PartitionOverlapsGptArea { .. } => "GPT-PART-RESERVED",
K::MissingProtectiveMbr => "GPT-MBR-NOPROT",
K::ProtectiveMbrUndersized { .. } => "GPT-MBR-UNDERSIZED",
K::HybridMbrHiddenPartition { .. } => "GPT-MBR-HYBRID-HIDDEN",
}
}
#[must_use]
pub fn note(&self) -> String {
use AnomalyKind as K;
match self {
K::HeaderCrcInvalid { location } => {
format!("{location} GPT header CRC is invalid — corruption or tampering")
}
K::HeaderSlackData { location } => format!(
"{location} GPT header has non-zero bytes past header_size (CRC-unprotected \
reserved area) — possible hidden data"
),
K::HeaderLbaMismatch {
location,
claimed,
actual,
} => format!(
"{location} GPT header claims my_lba {claimed} but was read at LBA {actual} — \
relocated or forged header"
),
K::PartitionArrayCrcInvalid { location } => {
format!("{location} GPT partition-array CRC is invalid — corruption or tampering")
}
K::BackupGptUnreadable => {
"Backup GPT is missing or unreadable — the disk cannot self-repair".to_string()
}
K::BackupGptNotAtDiskEnd {
alternate_lba,
disk_last_lba,
} => format!(
"Backup GPT is at LBA {alternate_lba} but the disk ends at LBA {disk_last_lba} — \
trailing region hidden from GPT-aware tools"
),
K::PrimaryBackupDivergence { field } => {
format!("Primary and backup GPT headers disagree on `{field}` — possible tampering")
}
K::OverlappingPartitions { a, b } => {
format!("Partitions {a} and {b} claim overlapping LBA ranges")
}
K::DuplicatePartitionGuid { a, b } => {
format!("Partitions {a} and {b} share a unique GUID — cloned/duplicated entry")
}
K::HiddenEncryptedVolume { index, entropy } => format!(
"Partition {index} has near-maximal entropy ({entropy:.2} bits/byte) and no readable \
filesystem — consistent with a hidden encrypted container"
),
K::PartitionOutOfBounds {
index,
last_lba,
last_usable,
} => format!(
"Partition {index} ends at LBA {last_lba}, beyond the usable range (last usable {last_usable})"
),
K::PartitionOverlapsGptArea {
index,
first_lba,
first_usable,
} => format!(
"Partition {index} starts at LBA {first_lba}, before first usable {first_usable} — \
overlaps the reserved GPT metadata region"
),
K::MissingProtectiveMbr => {
"GPT present but the MBR has no protective (0xEE) entry guarding it".to_string()
}
K::ProtectiveMbrUndersized {
covered_last_lba,
disk_last_lba,
} => format!(
"Protective MBR (0xEE) covers only up to LBA {covered_last_lba} but the disk ends \
at LBA {disk_last_lba} — tail exposed to GPT-unaware tools"
),
K::HybridMbrHiddenPartition {
mbr_index,
lba_start,
lba_count,
} => format!(
"Hybrid MBR entry {mbr_index} (LBA {lba_start}, {lba_count} sectors) matches no GPT \
partition — legacy-visible but hidden from the GPT"
),
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Anomaly {
pub severity: Severity,
pub code: &'static str,
pub kind: AnomalyKind,
pub note: String,
}
impl Anomaly {
#[must_use]
pub fn new(kind: AnomalyKind) -> Self {
Anomaly {
severity: kind.severity(),
code: kind.code(),
note: kind.note(),
kind,
}
}
}
impl fmt::Display for Anomaly {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] {}: {}", self.severity, self.code, self.note)
}
}
#[derive(Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct GptAnalysis {
pub primary: GptHeader,
pub backup: Option<GptHeader>,
pub disk_guid: Guid,
pub partitions: Vec<GptEntry>,
pub sector_size: u64,
pub gpt_sha256: String,
pub anomalies: Vec<Anomaly>,
}
impl GptAnalysis {
#[must_use]
pub fn max_severity(&self) -> Option<Severity> {
self.anomalies.iter().map(|a| a.severity).max()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anomaly_display_includes_code_and_severity() {
let a = Anomaly::new(AnomalyKind::BackupGptUnreadable);
let rendered = format!("{a}");
assert!(rendered.contains(a.code), "{rendered}");
assert!(!format!("{}", a.severity).is_empty());
}
#[test]
fn severity_orders_info_below_critical() {
assert!(Severity::Critical > Severity::Info);
assert_eq!(
[Severity::Low, Severity::Critical, Severity::Medium]
.into_iter()
.max(),
Some(Severity::Critical)
);
}
}