1use crate::model::{Attribute, Claim, DeviceKey, Provenance, Value};
9use crate::Consistency;
10use serde::Serialize;
11use std::collections::{BTreeMap, BTreeSet};
12
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
15pub struct ProvenancedValue {
16 pub value: Value,
18 pub provenance: Provenance,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct CorrelatedAttribute {
26 pub attribute: Attribute,
28 pub consistency: Consistency,
30 pub values: Vec<ProvenancedValue>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct DeviceHistory {
37 pub device: DeviceKey,
39 pub attributes: Vec<CorrelatedAttribute>,
41}
42
43#[must_use]
47pub fn correlate(claims: &[Claim]) -> Vec<DeviceHistory> {
48 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 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
110pub 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 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 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 assert_eq!(hist[0].device, DeviceKey("SN1".into()));
306 assert_eq!(hist[1].device, DeviceKey("SN2".into()));
307 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}