Skip to main content

disk_forensic/
report.rs

1//! Human-readable text rendering for disk4n6.
2//!
3//! [`render`] is the presentation of the normalized [`forensicnomicon::report::Report`]
4//! (the cross-scheme findings/provenance/timeline view). [`text_report`] renders
5//! the per-scheme structural inventory directly from the analyzers' native typed
6//! structs. All presentation lives here; the analyzer libraries are pure data.
7
8use core::fmt::Write as _;
9
10use forensicnomicon::report::{Report, Severity};
11
12use crate::DiskReport;
13
14/// Severities in descending order, for grouped rendering.
15const SEVERITY_ORDER: [Severity; 5] = [
16    Severity::Critical,
17    Severity::High,
18    Severity::Medium,
19    Severity::Low,
20    Severity::Info,
21];
22
23/// Render the normalized findings [`Report`] as a severity-grouped text block —
24/// the uniform cross-scheme view (a future GUI consumes the same `Report`).
25#[must_use]
26pub fn render(report: &Report) -> String {
27    let mut s = String::new();
28
29    // ── Findings (severity-grouped) ──────────────────────────────────────────
30    if report.findings.is_empty() {
31        s.push_str("Findings: none (clean)\n");
32    } else {
33        let _ = writeln!(s, "Forensic findings ({}):", report.findings.len());
34        for sev in SEVERITY_ORDER {
35            let group = report.findings.iter().filter(|f| f.severity == Some(sev));
36            let mut header_written = false;
37            for f in group {
38                if !header_written {
39                    let _ = writeln!(s, "\n  [{sev}]");
40                    header_written = true;
41                }
42                let _ = writeln!(
43                    s,
44                    "    {}  ({} / {}): {}",
45                    f.code, f.source.analyzer, f.source.scope, f.note
46                );
47                for e in &f.evidence {
48                    let _ = writeln!(s, "        {} = {}", e.field, e.value);
49                }
50            }
51        }
52    }
53
54    // ── Provenance breadcrumbs ───────────────────────────────────────────────
55    if !report.provenance.is_empty() {
56        s.push_str("\nProvenance:\n");
57        for p in &report.provenance {
58            let _ = writeln!(s, "  {}: {}  ({})", p.label, p.value, p.source);
59        }
60    }
61
62    // ── Timeline (the reconstructed biography) ───────────────────────────────
63    if !report.timeline.is_empty() {
64        s.push_str("\nTimeline:\n");
65        for e in &report.timeline {
66            let when = e.when.as_deref().unwrap_or("?");
67            let _ = writeln!(s, "  [{when}] {}  ({})", e.event, e.source);
68        }
69    }
70
71    s
72}
73
74/// Render the per-scheme **structural** inventory (partition/volume layout) from
75/// the analyzers' native typed structs. Anomalies are intentionally omitted —
76/// they are shown uniformly by [`render`] from the normalized findings model.
77/// disk4n6 owns this presentation; the analyzer libraries are pure data.
78#[must_use]
79pub fn text_report(report: &DiskReport) -> String {
80    match report {
81        DiskReport::Apm(a) => apm_structure(a),
82        DiskReport::Mbr(m) => mbr_structure(m),
83        DiskReport::Gpt(m) => {
84            let mut s = mbr_structure(m);
85            if let Some(gpt) = &m.gpt {
86                s.push('\n');
87                s.push_str(&gpt_structure(gpt));
88            }
89            s
90        }
91    }
92}
93
94/// Width of the GPT report's horizontal rule.
95const RULE: usize = 80;
96
97fn mbr_structure(a: &mbr_forensic::MbrAnalysis) -> String {
98    let mut s = String::new();
99    let _ = writeln!(s, "MBR Forensic Analysis");
100    let _ = writeln!(s, "  disk signature : {:#010x}", a.disk_serial);
101    let _ = writeln!(s, "  boot code      : {:?}", a.boot_code_id);
102    let _ = writeln!(s, "  partitioning   : {:?}", a.era);
103    let _ = writeln!(s, "\nPartition table ({} entries):", a.partitions.len());
104    if a.partitions.is_empty() {
105        let _ = writeln!(s, "  (no primary partitions)");
106    }
107    for p in &a.partitions {
108        let fs = match p.detected_fs {
109            Some(fs) => format!("{fs:?}"),
110            None => "-".to_string(),
111        };
112        let _ = writeln!(
113            s,
114            "  [{}] {:<24} LBA {:>12}..={:<12}  fs={}",
115            p.index,
116            p.declared_type.name(),
117            p.lba_start,
118            p.lba_end,
119            fs,
120        );
121    }
122    if let Some(gpt) = &a.gpt {
123        let _ = writeln!(
124            s,
125            "\nGPT cross-check: {} GPT partition entries",
126            gpt.partitions.len()
127        );
128    }
129    s
130}
131
132fn gpt_structure(a: &gpt_forensic::GptAnalysis) -> String {
133    let mut out = String::new();
134    out.push_str("GPT Forensic Analysis\n");
135    out.push_str(&"=".repeat(RULE));
136    out.push('\n');
137    let rev_hi = a.primary.revision >> 16;
138    let rev_lo = a.primary.revision & 0xFFFF;
139    let _ = writeln!(out, "Disk GUID:       {}", a.disk_guid);
140    let _ = writeln!(out, "Revision:        {rev_hi}.{rev_lo}");
141    let _ = writeln!(
142        out,
143        "Header CRC:      {}",
144        if a.primary.header_crc_valid {
145            "valid"
146        } else {
147            "INVALID"
148        }
149    );
150    let _ = writeln!(
151        out,
152        "Usable LBAs:     {}..{}",
153        a.primary.first_usable_lba, a.primary.last_usable_lba
154    );
155    let _ = writeln!(out, "Sector size:     {} bytes", a.sector_size);
156    let _ = writeln!(out, "GPT SHA-256:     {}", a.gpt_sha256);
157    match &a.backup {
158        Some(b) => {
159            let _ = writeln!(out, "Backup GPT:      present (LBA {})", b.my_lba);
160        }
161        None => out.push_str("Backup GPT:      MISSING\n"),
162    }
163    out.push('\n');
164    let _ = writeln!(out, "Partitions ({}):", a.partitions.len());
165    let _ = writeln!(
166        out,
167        "{:<3} {:<31} {:<12} {:<11} NAME",
168        "#", "TYPE", "FIRST LBA", "LAST LBA"
169    );
170    for (i, p) in a.partitions.iter().enumerate() {
171        let ty = p
172            .type_name()
173            .map_or_else(|| p.type_guid.to_string(), ToString::to_string);
174        let _ = writeln!(
175            out,
176            "{i:<3} {ty:<31} {:<12} {:<11} {}",
177            p.first_lba, p.last_lba, p.name
178        );
179    }
180    out
181}
182
183fn apm_structure(a: &apm_forensic::ApmAnalysis) -> String {
184    let mut s = String::new();
185    let _ = writeln!(s, "APM Forensic Analysis");
186    let _ = writeln!(s, "  block size     : {} bytes", a.block_size);
187    let _ = writeln!(s, "  device blocks  : {}", a.device_block_count);
188    let _ = writeln!(s, "\nPartition map ({} entries):", a.partitions.len());
189    if a.partitions.is_empty() {
190        let _ = writeln!(s, "  (no partition entries)");
191    }
192    for (i, p) in a.partitions.iter().enumerate() {
193        let _ = writeln!(
194            s,
195            "  [{}] {:<20} {:<24} blocks {:>10}..={:<10}",
196            i,
197            p.name,
198            p.type_name,
199            p.start_block,
200            p.end_block(),
201        );
202    }
203    s
204}