Skip to main content

disk_forensic/
normalize.rs

1//! Normalize each scheme's native analysis into the shared
2//! [`forensicnomicon::report`] model, so disk4n6 (and a future GUI) render one
3//! uniform [`Report`] instead of N bespoke `XxxAnalysis` types.
4
5use forensicnomicon::report::{
6    Category, Evidence, Finding, Location, Provenance, Report, Severity, Source,
7};
8
9use crate::DiskReport;
10
11/// Coarse forensic category derived from a finding's stable code. A pragmatic
12/// first pass (keyword-based); refined per-analyzer over time.
13fn classify(code: &str) -> Category {
14    let c = code.to_ascii_uppercase();
15    if c.contains("CRC") || c.contains("INTEGRITY") {
16        Category::Integrity
17    } else if c.contains("OVERLAP")
18        || c.contains("OOB")
19        || c.contains("BOUND")
20        || c.contains("CHS")
21        || c.contains("MAP-COUNT")
22    {
23        Category::Structure
24    } else if c.contains("RESIDUAL")
25        || c.contains("SLACK")
26        || c.contains("GAP")
27        || c.contains("CARVE")
28        || c.contains("UNMAPPED")
29        || c.contains("ZEROLEN")
30    {
31        Category::Residue
32    } else if c.contains("HIDDEN")
33        || c.contains("CONCEAL")
34        || c.contains("WIPED")
35        || c.contains("ERASED")
36        || c.contains("PROTECTIVE")
37    {
38        Category::Concealment
39    } else if c.contains("BOOT") {
40        Category::Threat
41    } else {
42        Category::Structure
43    }
44}
45
46macro_rules! map_severity {
47    ($name:ident, $native:path) => {
48        fn $name(s: $native) -> Severity {
49            use $native as S;
50            match s {
51                S::Info => Severity::Info,
52                S::Low => Severity::Low,
53                S::Medium => Severity::Medium,
54                S::High => Severity::High,
55                S::Critical => Severity::Critical,
56            }
57        }
58    };
59}
60map_severity!(mbr_sev, mbr_forensic::Severity);
61map_severity!(gpt_sev, gpt_forensic::Severity);
62map_severity!(apm_sev, apm_forensic::Severity);
63
64/// Normalize an MBR analysis. Findings carry their byte offset as evidence.
65#[must_use]
66pub fn mbr_findings(a: &mbr_forensic::MbrAnalysis) -> Vec<Finding> {
67    a.anomalies
68        .iter()
69        .map(|an| Finding {
70            severity: mbr_sev(an.severity),
71            category: classify(an.code),
72            code: an.code.to_string(),
73            note: an.note.clone(),
74            source: Source {
75                analyzer: "mbr-forensic".to_string(),
76                scope: "MBR".to_string(),
77            },
78            evidence: vec![Evidence {
79                field: "offset".to_string(),
80                value: format!("{:#x}", an.offset),
81                location: Some(Location::ByteOffset(an.offset)),
82            }],
83        })
84        .collect()
85}
86
87/// Normalize a GPT analysis.
88#[must_use]
89pub fn gpt_findings(a: &gpt_forensic::GptAnalysis) -> Vec<Finding> {
90    a.anomalies
91        .iter()
92        .map(|an| Finding {
93            severity: gpt_sev(an.severity),
94            category: classify(an.code),
95            code: an.code.to_string(),
96            note: an.note.clone(),
97            source: Source {
98                analyzer: "gpt-forensic".to_string(),
99                scope: "GPT".to_string(),
100            },
101            evidence: Vec::new(),
102        })
103        .collect()
104}
105
106/// Normalize an Apple Partition Map analysis.
107#[must_use]
108pub fn apm_findings(a: &apm_forensic::ApmAnalysis) -> Vec<Finding> {
109    a.anomalies
110        .iter()
111        .map(|an| Finding {
112            severity: apm_sev(an.severity),
113            category: classify(an.code),
114            code: an.code.to_string(),
115            note: an.note.clone(),
116            source: Source {
117                analyzer: "apm-forensic".to_string(),
118                scope: "APM".to_string(),
119            },
120            evidence: Vec::new(),
121        })
122        .collect()
123}
124
125/// Provenance breadcrumbs from an MBR analysis.
126#[must_use]
127pub fn mbr_provenance(a: &mbr_forensic::MbrAnalysis) -> Vec<Provenance> {
128    vec![
129        Provenance {
130            label: "boot code".to_string(),
131            value: format!("{:?}", a.boot_code_id),
132            source: "mbr-forensic".to_string(),
133        },
134        Provenance {
135            label: "partitioning era".to_string(),
136            value: format!("{:?}", a.era),
137            source: "mbr-forensic".to_string(),
138        },
139        Provenance {
140            label: "disk signature".to_string(),
141            value: format!("{:#010x}", a.disk_serial),
142            source: "mbr-forensic".to_string(),
143        },
144    ]
145}
146
147/// Provenance breadcrumbs from a GPT analysis.
148#[must_use]
149pub fn gpt_provenance(a: &gpt_forensic::GptAnalysis) -> Vec<Provenance> {
150    vec![
151        Provenance {
152            label: "disk GUID".to_string(),
153            value: a.disk_guid.to_string(),
154            source: "gpt-forensic".to_string(),
155        },
156        Provenance {
157            label: "sector size".to_string(),
158            value: format!("{} bytes", a.sector_size),
159            source: "gpt-forensic".to_string(),
160        },
161        Provenance {
162            label: "GPT SHA-256".to_string(),
163            value: a.gpt_sha256.clone(),
164            source: "gpt-forensic".to_string(),
165        },
166    ]
167}
168
169/// Provenance breadcrumbs from an APM analysis.
170#[must_use]
171pub fn apm_provenance(a: &apm_forensic::ApmAnalysis) -> Vec<Provenance> {
172    vec![
173        Provenance {
174            label: "block size".to_string(),
175            value: format!("{} bytes", a.block_size),
176            source: "apm-forensic".to_string(),
177        },
178        Provenance {
179            label: "device blocks".to_string(),
180            value: a.device_block_count.to_string(),
181            source: "apm-forensic".to_string(),
182        },
183    ]
184}
185
186/// Build the unified [`Report`] from a [`DiskReport`]. A GPT disk contributes
187/// both its protective-MBR and parsed-GPT findings and provenance.
188#[must_use]
189pub fn report(disk: &DiskReport) -> Report {
190    let (findings, provenance) = match disk {
191        DiskReport::Apm(a) => (apm_findings(a), apm_provenance(a)),
192        DiskReport::Mbr(m) => (mbr_findings(m), mbr_provenance(m)),
193        DiskReport::Gpt(m) => {
194            let mut findings = mbr_findings(m);
195            let mut provenance = mbr_provenance(m);
196            if let Some(gpt) = &m.gpt {
197                findings.extend(gpt_findings(gpt));
198                provenance.extend(gpt_provenance(gpt));
199            }
200            (findings, provenance)
201        }
202    };
203    Report {
204        findings,
205        provenance,
206        timeline: Vec::new(),
207    }
208}