Skip to main content

usb_forensic/
correlate.rs

1//! The correlation core: group atomic [`Claim`]s into per-device histories and grade
2//! each attribute's cross-source [`Consistency`].
3//!
4//! This is the source-agnostic moat. Every artifact in `docs/feature-parity.md` becomes
5//! "emit `Claim`s from a new source adapter"; the grading logic here does not change as
6//! sources are added.
7
8use crate::model::{Attribute, Claim, DeviceKey, Provenance, Value};
9use crate::Consistency;
10use serde::Serialize;
11use std::collections::{BTreeMap, BTreeSet};
12
13/// A value together with where it came from.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
15pub struct ProvenancedValue {
16    /// The reported value.
17    pub value: Value,
18    /// Its source and locator.
19    pub provenance: Provenance,
20}
21
22/// One device attribute after cross-source correlation: the grade plus every
23/// provenanced value that fed it (retained for verification and reporting).
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct CorrelatedAttribute {
26    /// Which attribute this is.
27    pub attribute: Attribute,
28    /// How well the sources agree on it.
29    pub consistency: Consistency,
30    /// Every value seen, with provenance, sorted deterministically.
31    pub values: Vec<ProvenancedValue>,
32}
33
34/// A device's full correlated history — one per distinct [`DeviceKey`].
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct DeviceHistory {
37    /// The device identity.
38    pub device: DeviceKey,
39    /// Its attributes, sorted deterministically.
40    pub attributes: Vec<CorrelatedAttribute>,
41}
42
43/// Correlate atomic claims into per-device histories, grading each attribute by how
44/// well its independent sources agree. Output is deterministic (by device key, then
45/// attribute, then value+provenance) so runs are diffable and reproducible.
46#[must_use]
47pub fn correlate(claims: &[Claim]) -> Vec<DeviceHistory> {
48    // device -> attribute -> provenanced values, all in deterministic order.
49    let mut grouped: BTreeMap<DeviceKey, BTreeMap<Attribute, Vec<ProvenancedValue>>> =
50        BTreeMap::new();
51    for c in claims {
52        grouped
53            .entry(c.device.clone())
54            .or_default()
55            .entry(c.attribute)
56            .or_default()
57            .push(ProvenancedValue {
58                value: c.value.clone(),
59                provenance: c.provenance.clone(),
60            });
61    }
62
63    grouped
64        .into_iter()
65        .map(|(device, attrs)| {
66            let attributes = attrs
67                .into_iter()
68                .map(|(attribute, mut values)| {
69                    values.sort();
70                    DeviceHistory::grade(attribute, values)
71                })
72                .collect();
73            DeviceHistory { device, attributes }
74        })
75        .collect()
76}
77
78impl DeviceHistory {
79    /// Grade one attribute's values by how well its *tamper-independent* sources agree.
80    ///
81    /// The rule is general — it holds for any attribute and any set of sources.
82    /// Independence is counted by storage *container* ([`ArtifactContainer`]), not
83    /// recording mechanism ([`SourceKind`]): sources sharing one container share one
84    /// tamper surface, so their agreement is not tamper-independent corroboration.
85    /// Fewer than two distinct containers cannot corroborate (`SingleSource`); two or
86    /// more containers reporting a single value agree (`Corroborated`); two or more
87    /// reporting differing values disagree (`Conflicting`). It is a description of the
88    /// evidence, not a verdict on it.
89    fn grade(attribute: Attribute, values: Vec<ProvenancedValue>) -> CorrelatedAttribute {
90        let containers: BTreeSet<_> = values
91            .iter()
92            .map(|v| v.provenance.source.container())
93            .collect();
94        let distinct_values: BTreeSet<&Value> = values.iter().map(|v| &v.value).collect();
95        let consistency = if containers.len() < 2 {
96            Consistency::SingleSource
97        } else if distinct_values.len() == 1 {
98            Consistency::Corroborated
99        } else {
100            Consistency::Conflicting
101        };
102        CorrelatedAttribute {
103            attribute,
104            consistency,
105            values,
106        }
107    }
108}
109
110/// Serialize histories as JSONL — one JSON object per line: pipeable, greppable,
111/// bounded-memory. Machine output is faithful and never truncated.
112///
113/// # Errors
114/// Propagates any `serde_json` serialization error.
115pub fn to_jsonl(histories: &[DeviceHistory]) -> Result<String, serde_json::Error> {
116    let mut out = String::new();
117    for h in histories {
118        out.push_str(&serde_json::to_string(h)?);
119        out.push('\n');
120    }
121    Ok(out)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::model::SourceKind;
128
129    fn claim(dev: &str, attr: Attribute, val: Value, src: SourceKind, loc: &str) -> Claim {
130        Claim {
131            device: DeviceKey(dev.to_string()),
132            attribute: attr,
133            value: val,
134            provenance: Provenance {
135                source: src,
136                locator: loc.to_string(),
137            },
138        }
139    }
140
141    #[test]
142    fn single_source_is_graded_single_source() {
143        let claims = [claim(
144            "SN1",
145            Attribute::FirstConnected,
146            Value::Timestamp(1_700_000_000),
147            SourceKind::Usbstor,
148            "USBSTOR\\Disk&Ven",
149        )];
150        let hist = correlate(&claims);
151        assert_eq!(hist.len(), 1);
152        assert_eq!(hist[0].device, DeviceKey("SN1".to_string()));
153        assert_eq!(hist[0].attributes[0].consistency, Consistency::SingleSource);
154    }
155
156    #[test]
157    fn two_sources_that_agree_are_corroborated() {
158        let ts = Value::Timestamp(1_700_000_000);
159        let claims = [
160            claim(
161                "SN1",
162                Attribute::FirstConnected,
163                ts.clone(),
164                SourceKind::Usbstor,
165                "k",
166            ),
167            claim(
168                "SN1",
169                Attribute::FirstConnected,
170                ts,
171                SourceKind::SetupApi,
172                "setupapi.dev.log:42",
173            ),
174        ];
175        let hist = correlate(&claims);
176        assert_eq!(hist[0].attributes[0].consistency, Consistency::Corroborated);
177        assert_eq!(hist[0].attributes[0].values.len(), 2);
178    }
179
180    #[test]
181    fn volume_serial_join_across_lnk_and_event_log_corroborates() {
182        // An LNK's volume serial and the Partition/Diagnostic log's volume serial for
183        // the same volume are in different containers (LnkFile vs EventLog) — an
184        // independent cross-container corroboration of the volume identity.
185        let vol = Value::Text("DEAD-BEEF".into());
186        let claims = [
187            claim(
188                "DEAD-BEEF",
189                Attribute::VolumeSerial,
190                vol.clone(),
191                SourceKind::Lnk,
192                "secret.lnk",
193            ),
194            claim(
195                "DEAD-BEEF",
196                Attribute::VolumeSerial,
197                vol,
198                SourceKind::PartitionDiag,
199                "evtx:1006",
200            ),
201        ];
202        let hist = correlate(&claims);
203        assert_eq!(hist[0].attributes[0].consistency, Consistency::Corroborated);
204    }
205
206    #[test]
207    fn same_container_agreement_is_not_tamper_independent_corroboration() {
208        // USBSTOR and MountedDevices are different recording mechanisms but the SAME
209        // storage container (the SYSTEM hive) — one hive tamper corrupts both, so their
210        // agreement is not tamper-independent corroboration. Grade conservatively.
211        let ts = Value::Timestamp(1_700_000_000);
212        let claims = [
213            claim(
214                "SN1",
215                Attribute::FirstConnected,
216                ts.clone(),
217                SourceKind::Usbstor,
218                "USBSTOR\\...",
219            ),
220            claim(
221                "SN1",
222                Attribute::FirstConnected,
223                ts,
224                SourceKind::MountedDevices,
225                "MountedDevices\\...",
226            ),
227        ];
228        let hist = correlate(&claims);
229        assert_eq!(hist[0].attributes[0].consistency, Consistency::SingleSource);
230    }
231
232    #[test]
233    fn two_sources_that_disagree_are_conflicting() {
234        let claims = [
235            claim(
236                "SN1",
237                Attribute::FirstConnected,
238                Value::Timestamp(1_700_000_000),
239                SourceKind::Usbstor,
240                "k",
241            ),
242            claim(
243                "SN1",
244                Attribute::FirstConnected,
245                Value::Timestamp(1_699_999_000),
246                SourceKind::SetupApi,
247                "l",
248            ),
249        ];
250        let hist = correlate(&claims);
251        assert_eq!(hist[0].attributes[0].consistency, Consistency::Conflicting);
252    }
253
254    #[test]
255    fn two_claims_from_the_same_source_are_not_corroboration() {
256        let ts = Value::Timestamp(1_700_000_000);
257        let claims = [
258            claim(
259                "SN1",
260                Attribute::FirstConnected,
261                ts.clone(),
262                SourceKind::Usbstor,
263                "k1",
264            ),
265            claim(
266                "SN1",
267                Attribute::FirstConnected,
268                ts,
269                SourceKind::Usbstor,
270                "k2",
271            ),
272        ];
273        let hist = correlate(&claims);
274        assert_eq!(hist[0].attributes[0].consistency, Consistency::SingleSource);
275    }
276
277    #[test]
278    fn groups_by_device_and_attribute_deterministically() {
279        let claims = [
280            claim(
281                "SN2",
282                Attribute::VolumeName,
283                Value::Text("KINGSTON".into()),
284                SourceKind::MountedDevices,
285                "m",
286            ),
287            claim(
288                "SN1",
289                Attribute::LastConnected,
290                Value::Timestamp(1_700_000_500),
291                SourceKind::PartitionDiag,
292                "e",
293            ),
294            claim(
295                "SN1",
296                Attribute::VolumeSerial,
297                Value::Text("A1B2".into()),
298                SourceKind::MountedDevices,
299                "m",
300            ),
301        ];
302        let hist = correlate(&claims);
303        assert_eq!(hist.len(), 2);
304        // sorted by device key
305        assert_eq!(hist[0].device, DeviceKey("SN1".into()));
306        assert_eq!(hist[1].device, DeviceKey("SN2".into()));
307        // SN1's attributes sorted by declaration order (LastConnected < VolumeSerial)
308        let attrs: Vec<Attribute> = hist[0].attributes.iter().map(|a| a.attribute).collect();
309        assert_eq!(attrs, [Attribute::LastConnected, Attribute::VolumeSerial]);
310    }
311
312    #[test]
313    fn to_jsonl_is_one_object_per_line_and_valid_json() {
314        let claims = [
315            claim(
316                "SN1",
317                Attribute::FirstConnected,
318                Value::Timestamp(1_700_000_000),
319                SourceKind::Usbstor,
320                "k",
321            ),
322            claim(
323                "SN2",
324                Attribute::VolumeName,
325                Value::Text("DISK".into()),
326                SourceKind::MountedDevices,
327                "m",
328            ),
329        ];
330        let hist = correlate(&claims);
331        let jsonl = to_jsonl(&hist).unwrap();
332        let lines: Vec<&str> = jsonl.lines().collect();
333        assert_eq!(lines.len(), 2);
334        for line in lines {
335            let v: serde_json::Value = serde_json::from_str(line).unwrap();
336            assert!(v.get("device").is_some());
337            assert!(v.get("attributes").is_some());
338        }
339    }
340}