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::{Finding, Observation, Provenance, Report, Source, TimelineEvent};
6
7use crate::DiskReport;
8
9/// Convert an analyzer's anomalies into canonical findings via the
10/// [`Observation`] trait — the conversion (severity, category, note, evidence,
11/// MITRE, confidence) lives in `forensicnomicon`, not duplicated here.
12fn findings_of<'a, O: Observation + 'a>(
13    anomalies: impl IntoIterator<Item = &'a O>,
14    analyzer: &str,
15    scope: &str,
16) -> Vec<Finding> {
17    anomalies
18        .into_iter()
19        .map(|an| {
20            an.to_finding(Source {
21                analyzer: analyzer.to_string(),
22                scope: scope.to_string(),
23                version: None,
24            })
25        })
26        .collect()
27}
28
29// Findings are categorized with the canonical `Category::from_code` from
30// forensicnomicon — the single source of truth for the code→category taxonomy,
31// shared with every analyzer rather than re-derived here.
32
33// Since 0.4.0 every analyzer re-exports `forensicnomicon::report::Severity` as
34// its own `Severity`, so an anomaly's severity is already the canonical type —
35// no per-scheme translation is needed.
36
37/// Normalize an MBR analysis. Findings carry their byte offset as evidence
38/// (sourced from the analyzer's `Observation::evidence`).
39#[must_use]
40pub fn mbr_findings(a: &mbr_partition_forensic::MbrAnalysis) -> Vec<Finding> {
41    findings_of(&a.anomalies, "mbr-partition-forensic", "MBR")
42}
43
44/// Normalize a GPT analysis.
45#[must_use]
46pub fn gpt_findings(a: &gpt_partition_forensic::GptAnalysis) -> Vec<Finding> {
47    findings_of(&a.anomalies, "gpt-partition-forensic", "GPT")
48}
49
50/// Normalize an Apple Partition Map analysis.
51#[must_use]
52pub fn apm_findings(a: &apm_partition_forensic::ApmAnalysis) -> Vec<Finding> {
53    findings_of(&a.anomalies, "apm-partition-forensic", "APM")
54}
55
56/// Provenance breadcrumbs from an MBR analysis.
57#[must_use]
58pub fn mbr_provenance(a: &mbr_partition_forensic::MbrAnalysis) -> Vec<Provenance> {
59    vec![
60        Provenance {
61            label: "boot code".to_string(),
62            value: format!("{:?}", a.boot_code_id),
63            source: "mbr-partition-forensic".to_string(),
64        },
65        Provenance {
66            label: "partitioning era".to_string(),
67            value: format!("{:?}", a.era),
68            source: "mbr-partition-forensic".to_string(),
69        },
70        Provenance {
71            label: "disk signature".to_string(),
72            value: format!("{:#010x}", a.disk_serial),
73            source: "mbr-partition-forensic".to_string(),
74        },
75    ]
76}
77
78/// Provenance breadcrumbs from a GPT analysis.
79#[must_use]
80pub fn gpt_provenance(a: &gpt_partition_forensic::GptAnalysis) -> Vec<Provenance> {
81    vec![
82        Provenance {
83            label: "disk GUID".to_string(),
84            value: a.disk_guid.to_string(),
85            source: "gpt-partition-forensic".to_string(),
86        },
87        Provenance {
88            label: "sector size".to_string(),
89            value: format!("{} bytes", a.sector_size),
90            source: "gpt-partition-forensic".to_string(),
91        },
92        Provenance {
93            label: "GPT SHA-256".to_string(),
94            value: a.gpt_sha256.clone(),
95            source: "gpt-partition-forensic".to_string(),
96        },
97    ]
98}
99
100/// Provenance breadcrumbs from an APM analysis.
101#[must_use]
102pub fn apm_provenance(a: &apm_partition_forensic::ApmAnalysis) -> Vec<Provenance> {
103    vec![
104        Provenance {
105            label: "block size".to_string(),
106            value: format!("{} bytes", a.block_size),
107            source: "apm-partition-forensic".to_string(),
108        },
109        Provenance {
110            label: "device blocks".to_string(),
111            value: a.device_block_count.to_string(),
112            source: "apm-partition-forensic".to_string(),
113        },
114    ]
115}
116
117/// Normalize an ISO 9660 analysis into findings via the shared [`Observation`]
118/// trait (iso9660-forensic 0.5.0 onward re-exports `report::Severity` and
119/// implements `Observation`, like the rest of the fleet).
120#[must_use]
121pub fn iso_findings(a: &iso9660_forensic::IsoAnalysis) -> Vec<Finding> {
122    findings_of(&a.anomalies, "iso9660-forensic", "ISO 9660")
123}
124
125/// Provenance breadcrumbs from an ISO 9660 volume. Temporal facts (creation,
126/// modification, authoring window) are normalized into the [`iso_timeline`]
127/// instead; empty PVD strings are dropped rather than emitted as noise.
128#[must_use]
129pub fn iso_provenance(a: &iso9660_forensic::IsoAnalysis) -> Vec<Provenance> {
130    let v = &a.volume;
131    let mut entries: Vec<(&str, String)> = vec![
132        ("volume label", v.volume_label.clone()),
133        ("system identifier", v.system_id.clone()),
134        ("volume set", v.volume_set_id.clone()),
135        ("publisher", v.publisher_id.clone()),
136        ("data preparer", v.data_preparer_id.clone()),
137        ("application", v.application_id.clone()),
138        ("sector mode", v.sector_mode.clone()),
139        (
140            "extensions",
141            format!("Rock Ridge: {}, Joliet: {}", v.has_rock_ridge, v.has_joliet),
142        ),
143        ("sessions", v.session_count.to_string()),
144    ];
145    if v.has_enhanced_volume_descriptor {
146        entries.push(("enhanced volume descriptor", "present".to_string()));
147    }
148    if !v.rock_ridge_uids.is_empty() || !v.rock_ridge_gids.is_empty() {
149        entries.push((
150            "Rock Ridge owners",
151            format!("uids {:?}, gids {:?}", v.rock_ridge_uids, v.rock_ridge_gids),
152        ));
153    }
154    if !v.boot_entries.is_empty() {
155        let platforms: Vec<&str> = v.boot_entries.iter().map(|b| b.platform.as_str()).collect();
156        entries.push((
157            "El Torito boot",
158            format!("{} entries ({})", v.boot_entries.len(), platforms.join(", ")),
159        ));
160    }
161    entries
162        .into_iter()
163        .filter(|(_, value)| !value.is_empty())
164        .map(|(label, value)| Provenance {
165            label: label.to_string(),
166            value,
167            source: "iso9660-forensic".to_string(),
168        })
169        .collect()
170}
171
172/// Reconstruct the volume's datable biography from an ISO 9660 analysis: the
173/// PVD creation/modification stamps and the file-recorded-time authoring window.
174#[must_use]
175pub fn iso_timeline(a: &iso9660_forensic::IsoAnalysis) -> Vec<TimelineEvent> {
176    let v = &a.volume;
177    [
178        (&v.creation_time, "ISO 9660 volume created"),
179        (&v.modification_time, "ISO 9660 volume last modified"),
180        (
181            &v.earliest_file_time,
182            "earliest file recorded time (authoring window start)",
183        ),
184        (
185            &v.latest_file_time,
186            "latest file recorded time (authoring window end)",
187        ),
188    ]
189    .into_iter()
190    .filter_map(|(when, event)| {
191        when.as_ref().map(|w| TimelineEvent {
192            when: Some(w.clone()),
193            source: "iso9660-forensic".to_string(),
194            event: event.to_string(),
195        })
196    })
197    .collect()
198}
199
200/// Build the unified [`Report`] from an ISO 9660 analysis.
201#[must_use]
202pub fn iso_report(a: &iso9660_forensic::IsoAnalysis) -> Report {
203    let mut out = Report::default();
204    out.findings = iso_findings(a);
205    out.provenance = iso_provenance(a);
206    out.timeline = iso_timeline(a);
207    out
208}
209
210/// Build the unified [`Report`] from a [`DiskReport`]. A GPT disk contributes
211/// both its protective-MBR and parsed-GPT findings and provenance.
212#[must_use]
213pub fn report(disk: &DiskReport) -> Report {
214    let (findings, provenance) = match disk {
215        DiskReport::Apm(a) => (apm_findings(a), apm_provenance(a)),
216        DiskReport::Mbr(m) => (mbr_findings(m), mbr_provenance(m)),
217        DiskReport::Gpt(m) => {
218            let mut findings = mbr_findings(m);
219            let mut provenance = mbr_provenance(m);
220            if let Some(gpt) = &m.gpt {
221                findings.extend(gpt_findings(gpt));
222                provenance.extend(gpt_provenance(gpt));
223            }
224            (findings, provenance)
225        }
226    };
227    let mut out = Report::default();
228    out.findings = findings;
229    out.provenance = provenance;
230    out
231}