1use crate::{DeviceHistory, Value};
6use forensicnomicon::report::Finding;
7
8#[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; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let year = yoe + era * 400;
23 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; 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#[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 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#[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
99fn cell(text: &str) -> String {
101 text.replace('|', "\\|")
102}
103
104#[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 assert_eq!(format_epoch(0), "1970-01-01 00:00:00 UTC"); assert_eq!(format_epoch(1_681_760_520), "2023-04-17 19:42:00 UTC"); 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 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 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 assert_eq!(cell("a|b"), "a\\|b");
317 }
318}