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!(
159                "{} entries ({})",
160                v.boot_entries.len(),
161                platforms.join(", ")
162            ),
163        ));
164    }
165    entries
166        .into_iter()
167        .filter(|(_, value)| !value.is_empty())
168        .map(|(label, value)| Provenance {
169            label: label.to_string(),
170            value,
171            source: "iso9660-forensic".to_string(),
172        })
173        .collect()
174}
175
176/// Reconstruct the volume's datable biography from an ISO 9660 analysis: the
177/// PVD creation/modification stamps and the file-recorded-time authoring window.
178#[must_use]
179pub fn iso_timeline(a: &iso9660_forensic::IsoAnalysis) -> Vec<TimelineEvent> {
180    let v = &a.volume;
181    [
182        (&v.creation_time, "ISO 9660 volume created"),
183        (&v.modification_time, "ISO 9660 volume last modified"),
184        (
185            &v.earliest_file_time,
186            "earliest file recorded time (authoring window start)",
187        ),
188        (
189            &v.latest_file_time,
190            "latest file recorded time (authoring window end)",
191        ),
192    ]
193    .into_iter()
194    .filter_map(|(when, event)| {
195        when.as_ref().map(|w| TimelineEvent {
196            when: Some(w.clone()),
197            source: "iso9660-forensic".to_string(),
198            event: event.to_string(),
199        })
200    })
201    .collect()
202}
203
204/// Build the unified [`Report`] from an ISO 9660 analysis.
205#[must_use]
206pub fn iso_report(a: &iso9660_forensic::IsoAnalysis) -> Report {
207    let mut out = Report::default();
208    out.findings = iso_findings(a);
209    out.provenance = iso_provenance(a);
210    out.timeline = iso_timeline(a);
211    out
212}
213
214/// Build the unified [`Report`] from a [`DiskReport`]. A GPT disk contributes
215/// both its protective-MBR and parsed-GPT findings and provenance.
216#[must_use]
217pub fn report(disk: &DiskReport) -> Report {
218    let (findings, provenance) = match disk {
219        DiskReport::Apm(a) => (apm_findings(a), apm_provenance(a)),
220        DiskReport::Mbr(m) => (mbr_findings(m), mbr_provenance(m)),
221        DiskReport::Gpt(m) => {
222            let mut findings = mbr_findings(m);
223            let mut provenance = mbr_provenance(m);
224            if let Some(gpt) = &m.gpt {
225                findings.extend(gpt_findings(gpt));
226                provenance.extend(gpt_provenance(gpt));
227            }
228            (findings, provenance)
229        }
230    };
231    let mut out = Report::default();
232    out.findings = findings;
233    out.provenance = provenance;
234    out
235}