Skip to main content

usb_forensic/
timeline.rs

1//! Aggregate super-timeline: a single chronological stream of every timestamped event
2//! across all correlated devices — the cross-device view an examiner scans to see what
3//! happened when, regardless of which device or which source recorded it.
4//!
5//! Per-device histories answer "what do we know about this device?"; the super-timeline
6//! answers the orthogonal question "what happened on this system, in order?" It is a pure
7//! projection over the already-correlated [`DeviceHistory`] set — it adds no new evidence
8//! and makes no consistency claim, only an ordering of timestamped facts.
9
10use crate::correlate::DeviceHistory;
11use crate::model::{Attribute, DeviceKey, SourceKind, Value};
12use serde::Serialize;
13
14/// One timestamped event on the aggregate timeline.
15///
16/// Field order matters: `when` is first so the derived [`Ord`] sorts chronologically,
17/// with the remaining fields breaking ties deterministically (diffable, reproducible).
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
19pub struct TimelineEvent {
20    /// Event time, epoch seconds UTC — the primary sort key.
21    pub when: i64,
22    /// The device the event is about.
23    pub device: DeviceKey,
24    /// Which attribute this event marks (`FirstConnected`, `LastRemoved`, …).
25    pub attribute: Attribute,
26    /// The source that recorded it.
27    pub source: SourceKind,
28    /// Precise locator within that source (key path, log line, …).
29    pub locator: String,
30}
31
32/// Build the aggregate super-timeline: every timestamped value across all devices, in
33/// chronological order (ties broken deterministically). Non-timestamp attributes (volume
34/// name/serial, accessed file) carry no time and are omitted — this is the
35/// *when-did-what-happen* view, not the full per-device record.
36#[must_use]
37pub fn super_timeline(histories: &[DeviceHistory]) -> Vec<TimelineEvent> {
38    let mut events: Vec<TimelineEvent> = Vec::new();
39    for history in histories {
40        for attr in &history.attributes {
41            for pv in &attr.values {
42                if let Value::Timestamp(when) = pv.value {
43                    events.push(TimelineEvent {
44                        when,
45                        device: history.device.clone(),
46                        attribute: attr.attribute,
47                        source: pv.provenance.source,
48                        locator: pv.provenance.locator.clone(),
49                    });
50                }
51            }
52        }
53    }
54    events.sort();
55    events
56}
57
58/// Serialize the super-timeline as JSONL — one event per line, chronological, greppable
59/// and pipeable.
60pub fn timeline_to_jsonl(events: &[TimelineEvent]) -> Result<String, serde_json::Error> {
61    let mut out = String::new();
62    for event in events {
63        out.push_str(&serde_json::to_string(event)?);
64        out.push('\n');
65    }
66    Ok(out)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::correlate;
73    use crate::model::{Attribute, Claim, DeviceKey, Provenance, SourceKind, Value};
74
75    fn ts_claim(device: &str, attr: Attribute, when: i64, src: SourceKind, loc: &str) -> Claim {
76        Claim {
77            device: DeviceKey(device.into()),
78            attribute: attr,
79            value: Value::Timestamp(when),
80            provenance: Provenance {
81                source: src,
82                locator: loc.into(),
83            },
84        }
85    }
86
87    #[test]
88    fn events_are_ordered_chronologically_across_devices() {
89        let claims = vec![
90            ts_claim(
91                "B",
92                Attribute::LastRemoved,
93                3_000,
94                SourceKind::SetupApi,
95                "b-rm",
96            ),
97            ts_claim(
98                "A",
99                Attribute::FirstConnected,
100                1_000,
101                SourceKind::Usbstor,
102                "a-fc",
103            ),
104            ts_claim(
105                "A",
106                Attribute::LastConnected,
107                2_000,
108                SourceKind::Usbstor,
109                "a-lc",
110            ),
111        ];
112        let histories = correlate(&claims);
113        let tl = super_timeline(&histories);
114        let times: Vec<i64> = tl.iter().map(|e| e.when).collect();
115        assert_eq!(times, vec![1_000, 2_000, 3_000]);
116        assert_eq!(tl[0].device, DeviceKey("A".into()));
117        assert_eq!(tl[0].attribute, Attribute::FirstConnected);
118        assert_eq!(tl[2].device, DeviceKey("B".into()));
119    }
120
121    #[test]
122    fn non_timestamp_values_are_omitted() {
123        let claims = vec![
124            Claim {
125                device: DeviceKey("A".into()),
126                attribute: Attribute::VolumeName,
127                value: Value::Text("KINGSTON".into()),
128                provenance: Provenance {
129                    source: SourceKind::Usbstor,
130                    locator: "vn".into(),
131                },
132            },
133            ts_claim(
134                "A",
135                Attribute::FirstConnected,
136                1_000,
137                SourceKind::Usbstor,
138                "fc",
139            ),
140        ];
141        let tl = super_timeline(&correlate(&claims));
142        assert_eq!(tl.len(), 1);
143        assert_eq!(tl[0].attribute, Attribute::FirstConnected);
144    }
145
146    #[test]
147    fn equal_times_break_ties_deterministically_by_device() {
148        let claims = vec![
149            ts_claim(
150                "Z",
151                Attribute::FirstConnected,
152                500,
153                SourceKind::Usbstor,
154                "z",
155            ),
156            ts_claim(
157                "A",
158                Attribute::FirstConnected,
159                500,
160                SourceKind::Usbstor,
161                "a",
162            ),
163        ];
164        let tl = super_timeline(&correlate(&claims));
165        assert_eq!(tl.len(), 2);
166        assert_eq!(tl[0].device, DeviceKey("A".into()));
167        assert_eq!(tl[1].device, DeviceKey("Z".into()));
168    }
169
170    #[test]
171    fn jsonl_is_one_line_per_event_and_round_trips_the_fields() {
172        let claims = vec![
173            ts_claim(
174                "A",
175                Attribute::FirstConnected,
176                1_000,
177                SourceKind::Usbstor,
178                "a-fc",
179            ),
180            ts_claim(
181                "A",
182                Attribute::LastRemoved,
183                2_000,
184                SourceKind::SetupApi,
185                "a-rm",
186            ),
187        ];
188        let tl = super_timeline(&correlate(&claims));
189        let jsonl = timeline_to_jsonl(&tl).expect("serialize");
190        let lines: Vec<&str> = jsonl.lines().collect();
191        assert_eq!(lines.len(), 2);
192        // each line is a standalone JSON object carrying the event fields verbatim.
193        assert!(lines[0].contains("\"when\":1000"));
194        assert!(lines[0].contains("\"FirstConnected\""));
195        assert!(lines[0].contains("\"Usbstor\""));
196        assert!(lines[1].contains("\"when\":2000"));
197        assert!(lines[1].contains("\"a-rm\""));
198    }
199
200    #[test]
201    fn empty_histories_yield_an_empty_timeline() {
202        assert!(super_timeline(&[]).is_empty());
203        assert_eq!(timeline_to_jsonl(&[]).expect("serialize"), "");
204    }
205}