Skip to main content

usb_forensic/
render.rs

1//! Human view: a readable results grid (one block per device). The machine view is
2//! [`to_jsonl`](crate::to_jsonl) — faithful and round-trippable; this view renders for
3//! eyes (datetimes formatted, consistency labelled, values unwrapped).
4
5use crate::{DeviceHistory, Value};
6use forensicnomicon::report::Finding;
7
8/// Format a Unix-epoch-seconds timestamp as `YYYY-MM-DD HH:MM:SS UTC`.
9///
10/// Uses Howard Hinnant's `civil_from_days` (public-domain), with `div_euclid` so it is
11/// correct and branch-total for any `i64` — no dependency, validated against `date(1)`.
12#[must_use]
13pub fn format_epoch(secs: i64) -> String {
14    let days = secs.div_euclid(86_400);
15    let tod = secs.rem_euclid(86_400);
16    let (hour, min, sec) = (tod / 3600, (tod % 3600) / 60, tod % 60);
17
18    let z = days + 719_468;
19    let era = z.div_euclid(146_097);
20    let doe = z - era * 146_097; // [0, 146096]
21    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
22    let year = yoe + era * 400;
23    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
24    let mp = (5 * doy + 2) / 153; // [0, 11]
25    let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
26    let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
27    let year = if month <= 2 { year + 1 } else { year };
28
29    format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02} UTC")
30}
31
32fn render_value(value: &Value) -> String {
33    match value {
34        Value::Timestamp(secs) => format_epoch(*secs),
35        Value::Text(text) => text.clone(),
36    }
37}
38
39/// Render device histories as a human-readable results grid.
40#[must_use]
41pub fn render_table(histories: &[DeviceHistory]) -> String {
42    use std::fmt::Write as _;
43    let mut out = String::new();
44    for history in histories {
45        // writeln! into a String is infallible; the discarded Result satisfies the
46        // format-push-string lint without an unwrap.
47        let _ = writeln!(out, "Device: {}", history.device.0);
48        for attr in &history.attributes {
49            let values: Vec<String> = attr
50                .values
51                .iter()
52                .map(|pv| render_value(&pv.value))
53                .collect();
54            let _ = writeln!(
55                out,
56                "  {:<16} {:<13} {}",
57                format!("{:?}", attr.attribute),
58                attr.consistency.label(),
59                values.join(", ")
60            );
61        }
62    }
63    out
64}
65
66/// Render the opened/accessed-files report: for every device with recovered file accesses
67/// (LNK/jump-list `AccessedFile` claims), the files opened from it, each with its source
68/// locator. This answers "which files were touched on which stick" — the exfil view. A
69/// device with no file accesses is omitted; the whole report is empty when none were found.
70#[must_use]
71pub fn render_accessed_files(histories: &[DeviceHistory]) -> String {
72    use crate::model::Attribute;
73    use std::fmt::Write as _;
74    let mut out = String::new();
75    for history in histories {
76        let files: Vec<&crate::ProvenancedValue> = history
77            .attributes
78            .iter()
79            .filter(|a| a.attribute == Attribute::AccessedFile)
80            .flat_map(|a| a.values.iter())
81            .collect();
82        if files.is_empty() {
83            continue;
84        }
85        let _ = writeln!(out, "Device: {}", history.device.0);
86        for pv in files {
87            let _ = writeln!(
88                out,
89                "  {}\t[{:?} {}]",
90                render_value(&pv.value),
91                pv.provenance.source,
92                pv.provenance.locator
93            );
94        }
95    }
96    out
97}
98
99/// Neutralize `|` so a value cannot break a Markdown table cell.
100fn cell(text: &str) -> String {
101    text.replace('|', "\\|")
102}
103
104/// Render a court-oriented Markdown forensic report: an executive summary, a per-device
105/// provenance table (every value with its source and locator), the graded findings, and
106/// a methodology/limitations note. Convert to PDF/DOCX downstream (e.g. `pandoc`).
107///
108/// Follows the expert-witness discipline: values are observed facts; findings are
109/// observations ("consistent with"), never legal conclusions.
110#[must_use]
111pub fn render_report(histories: &[DeviceHistory], findings: &[Finding]) -> String {
112    use std::fmt::Write as _;
113    let mut r = String::new();
114    let _ = writeln!(r, "# USB Device History — Forensic Report\n");
115    let _ = writeln!(r, "## Executive Summary\n");
116    let _ = writeln!(
117        r,
118        "Reconstructed {} device history record(s) from correlated evidence; \
119         {} finding(s) surfaced. Every reported value retains its source and locator. \
120         Findings are observations (\"consistent with\"), not conclusions.\n",
121        histories.len(),
122        findings.len()
123    );
124
125    let _ = writeln!(r, "## Devices\n");
126    for history in histories {
127        let _ = writeln!(r, "### Device: {}\n", history.device.0);
128        let _ = writeln!(r, "| Attribute | Consistency | Value | Source | Locator |");
129        let _ = writeln!(r, "|---|---|---|---|---|");
130        for attr in &history.attributes {
131            for pv in &attr.values {
132                let _ = writeln!(
133                    r,
134                    "| {:?} | {} | {} | {:?} | {} |",
135                    attr.attribute,
136                    attr.consistency.label(),
137                    cell(&render_value(&pv.value)),
138                    pv.provenance.source,
139                    cell(&pv.provenance.locator),
140                );
141            }
142        }
143        let _ = writeln!(r);
144    }
145
146    let _ = writeln!(r, "## Findings\n");
147    if findings.is_empty() {
148        let _ = writeln!(r, "No cross-source conflicts or corroborations surfaced.\n");
149    } else {
150        for finding in findings {
151            let _ = writeln!(
152                r,
153                "- **[{:?}] {}** — {}",
154                finding.severity, finding.code, finding.note
155            );
156        }
157        let _ = writeln!(r);
158    }
159
160    let _ = writeln!(r, "## Methodology & Limitations\n");
161    let _ = writeln!(
162        r,
163        "- Values are graded by tamper-independent storage container: agreement across \
164         sources sharing one container is not counted as corroboration.\n\
165         - A conflict is reported as \"not consistent with\", never as proven tampering; \
166         every value is shown with its source so it can be independently verified.\n\
167         - The findings are observations of the evidence; the Court may draw its own \
168         conclusions."
169    );
170    r
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::model::{Attribute, DeviceKey, Provenance, SourceKind};
177    use crate::{audit, correlate, Claim};
178
179    #[test]
180    fn format_epoch_matches_the_date_oracle() {
181        // `date -u -r <epoch>` ground truth.
182        assert_eq!(format_epoch(0), "1970-01-01 00:00:00 UTC"); // month<=2 branch
183        assert_eq!(format_epoch(1_681_760_520), "2023-04-17 19:42:00 UTC"); // month>2 branch
184        assert_eq!(format_epoch(1_600_357_894), "2020-09-17 15:51:34 UTC");
185    }
186
187    #[test]
188    fn table_renders_a_block_per_device_with_formatted_values() {
189        let claims = [
190            Claim {
191                device: DeviceKey("SN1".into()),
192                attribute: Attribute::FirstConnected,
193                value: Value::Timestamp(1_681_760_520),
194                provenance: Provenance {
195                    source: SourceKind::SetupApi,
196                    locator: "l".into(),
197                },
198            },
199            Claim {
200                device: DeviceKey("SN1".into()),
201                attribute: Attribute::VolumeName,
202                value: Value::Text("KINGSTON".into()),
203                provenance: Provenance {
204                    source: SourceKind::MountedDevices,
205                    locator: "m".into(),
206                },
207            },
208        ];
209        let table = render_table(&correlate(&claims));
210        assert!(table.contains("Device: SN1"));
211        assert!(
212            table.contains("2023-04-17 19:42:00 UTC"),
213            "timestamp rendered human"
214        );
215        assert!(table.contains("KINGSTON"), "text value rendered");
216        assert!(table.contains("single-source"), "consistency labelled");
217    }
218
219    fn claim(dev: &str, attr: Attribute, v: Value, src: SourceKind, loc: &str) -> Claim {
220        Claim {
221            device: DeviceKey(dev.into()),
222            attribute: attr,
223            value: v,
224            provenance: Provenance {
225                source: src,
226                locator: loc.into(),
227            },
228        }
229    }
230
231    #[test]
232    fn accessed_files_lists_opened_files_per_device_and_omits_devices_without_any() {
233        let claims = [
234            claim(
235                "SN1",
236                Attribute::AccessedFile,
237                Value::Text("E:\\secret.docx".into()),
238                SourceKind::Lnk,
239                "recent\\secret.lnk",
240            ),
241            // A device with only a connect time and no file access is omitted.
242            claim(
243                "SN2",
244                Attribute::FirstConnected,
245                Value::Timestamp(1),
246                SourceKind::Usbstor,
247                "k",
248            ),
249        ];
250        let report = render_accessed_files(&correlate(&claims));
251        assert!(report.contains("Device: SN1"));
252        assert!(report.contains("E:\\secret.docx"));
253        assert!(report.contains("recent\\secret.lnk"), "locator shown");
254        assert!(
255            !report.contains("SN2"),
256            "device with no file access omitted"
257        );
258    }
259
260    #[test]
261    fn accessed_files_is_empty_when_no_files_were_opened() {
262        let claims = [claim(
263            "SN1",
264            Attribute::FirstConnected,
265            Value::Timestamp(1),
266            SourceKind::Usbstor,
267            "k",
268        )];
269        assert!(render_accessed_files(&correlate(&claims)).is_empty());
270    }
271
272    #[test]
273    fn report_has_provenance_table_and_findings_with_hedged_language() {
274        // Two containers disagreeing → a conflict finding.
275        let claims = [
276            claim(
277                "SN1",
278                Attribute::FirstConnected,
279                Value::Timestamp(1_681_760_520),
280                SourceKind::Usbstor,
281                "k",
282            ),
283            claim(
284                "SN1",
285                Attribute::FirstConnected,
286                Value::Timestamp(1_600_357_894),
287                SourceKind::SetupApi,
288                "setupapi:9",
289            ),
290        ];
291        let histories = correlate(&claims);
292        let report = render_report(&histories, &audit(&histories));
293        assert!(report.contains("# USB Device History — Forensic Report"));
294        assert!(report.contains("### Device: SN1"));
295        assert!(report.contains("| Attribute | Consistency | Value | Source | Locator |"));
296        assert!(
297            report.contains("setupapi:9"),
298            "locator appears in the provenance table"
299        );
300        assert!(
301            report.contains("USB-TIMESTAMP-CONFLICT"),
302            "the conflict finding is listed"
303        );
304        assert!(
305            report.contains("consistent with"),
306            "hedged, non-conclusive language"
307        );
308        assert!(report.contains("Court may draw its own conclusions"));
309    }
310
311    #[test]
312    fn report_with_no_findings_says_so() {
313        let report = render_report(&[], &[]);
314        assert!(report.contains("No cross-source conflicts or corroborations surfaced."));
315        // pipe-guard: a value containing '|' is neutralized.
316        assert_eq!(cell("a|b"), "a\\|b");
317    }
318}