use crate::model::{Attribute, Claim, DeviceKey, Provenance, Value};
use crate::Consistency;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ProvenancedValue {
pub value: Value,
pub provenance: Provenance,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CorrelatedAttribute {
pub attribute: Attribute,
pub consistency: Consistency,
pub values: Vec<ProvenancedValue>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DeviceHistory {
pub device: DeviceKey,
pub attributes: Vec<CorrelatedAttribute>,
}
#[must_use]
pub fn correlate(claims: &[Claim]) -> Vec<DeviceHistory> {
let mut grouped: BTreeMap<DeviceKey, BTreeMap<Attribute, Vec<ProvenancedValue>>> =
BTreeMap::new();
for c in claims {
grouped
.entry(c.device.clone())
.or_default()
.entry(c.attribute)
.or_default()
.push(ProvenancedValue {
value: c.value.clone(),
provenance: c.provenance.clone(),
});
}
grouped
.into_iter()
.map(|(device, attrs)| {
let attributes = attrs
.into_iter()
.map(|(attribute, mut values)| {
values.sort();
DeviceHistory::grade(attribute, values)
})
.collect();
DeviceHistory { device, attributes }
})
.collect()
}
impl DeviceHistory {
fn grade(attribute: Attribute, values: Vec<ProvenancedValue>) -> CorrelatedAttribute {
let containers: BTreeSet<_> = values
.iter()
.map(|v| v.provenance.source.container())
.collect();
let distinct_values: BTreeSet<&Value> = values.iter().map(|v| &v.value).collect();
let consistency = if containers.len() < 2 {
Consistency::SingleSource
} else if distinct_values.len() == 1 {
Consistency::Corroborated
} else {
Consistency::Conflicting
};
CorrelatedAttribute {
attribute,
consistency,
values,
}
}
}
pub fn to_jsonl(histories: &[DeviceHistory]) -> Result<String, serde_json::Error> {
let mut out = String::new();
for h in histories {
out.push_str(&serde_json::to_string(h)?);
out.push('\n');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::SourceKind;
fn claim(dev: &str, attr: Attribute, val: Value, src: SourceKind, loc: &str) -> Claim {
Claim {
device: DeviceKey(dev.to_string()),
attribute: attr,
value: val,
provenance: Provenance {
source: src,
locator: loc.to_string(),
},
}
}
#[test]
fn single_source_is_graded_single_source() {
let claims = [claim(
"SN1",
Attribute::FirstConnected,
Value::Timestamp(1_700_000_000),
SourceKind::Usbstor,
"USBSTOR\\Disk&Ven",
)];
let hist = correlate(&claims);
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].device, DeviceKey("SN1".to_string()));
assert_eq!(hist[0].attributes[0].consistency, Consistency::SingleSource);
}
#[test]
fn two_sources_that_agree_are_corroborated() {
let ts = Value::Timestamp(1_700_000_000);
let claims = [
claim(
"SN1",
Attribute::FirstConnected,
ts.clone(),
SourceKind::Usbstor,
"k",
),
claim(
"SN1",
Attribute::FirstConnected,
ts,
SourceKind::SetupApi,
"setupapi.dev.log:42",
),
];
let hist = correlate(&claims);
assert_eq!(hist[0].attributes[0].consistency, Consistency::Corroborated);
assert_eq!(hist[0].attributes[0].values.len(), 2);
}
#[test]
fn volume_serial_join_across_lnk_and_event_log_corroborates() {
let vol = Value::Text("DEAD-BEEF".into());
let claims = [
claim(
"DEAD-BEEF",
Attribute::VolumeSerial,
vol.clone(),
SourceKind::Lnk,
"secret.lnk",
),
claim(
"DEAD-BEEF",
Attribute::VolumeSerial,
vol,
SourceKind::PartitionDiag,
"evtx:1006",
),
];
let hist = correlate(&claims);
assert_eq!(hist[0].attributes[0].consistency, Consistency::Corroborated);
}
#[test]
fn same_container_agreement_is_not_tamper_independent_corroboration() {
let ts = Value::Timestamp(1_700_000_000);
let claims = [
claim(
"SN1",
Attribute::FirstConnected,
ts.clone(),
SourceKind::Usbstor,
"USBSTOR\\...",
),
claim(
"SN1",
Attribute::FirstConnected,
ts,
SourceKind::MountedDevices,
"MountedDevices\\...",
),
];
let hist = correlate(&claims);
assert_eq!(hist[0].attributes[0].consistency, Consistency::SingleSource);
}
#[test]
fn two_sources_that_disagree_are_conflicting() {
let claims = [
claim(
"SN1",
Attribute::FirstConnected,
Value::Timestamp(1_700_000_000),
SourceKind::Usbstor,
"k",
),
claim(
"SN1",
Attribute::FirstConnected,
Value::Timestamp(1_699_999_000),
SourceKind::SetupApi,
"l",
),
];
let hist = correlate(&claims);
assert_eq!(hist[0].attributes[0].consistency, Consistency::Conflicting);
}
#[test]
fn two_claims_from_the_same_source_are_not_corroboration() {
let ts = Value::Timestamp(1_700_000_000);
let claims = [
claim(
"SN1",
Attribute::FirstConnected,
ts.clone(),
SourceKind::Usbstor,
"k1",
),
claim(
"SN1",
Attribute::FirstConnected,
ts,
SourceKind::Usbstor,
"k2",
),
];
let hist = correlate(&claims);
assert_eq!(hist[0].attributes[0].consistency, Consistency::SingleSource);
}
#[test]
fn groups_by_device_and_attribute_deterministically() {
let claims = [
claim(
"SN2",
Attribute::VolumeName,
Value::Text("KINGSTON".into()),
SourceKind::MountedDevices,
"m",
),
claim(
"SN1",
Attribute::LastConnected,
Value::Timestamp(1_700_000_500),
SourceKind::PartitionDiag,
"e",
),
claim(
"SN1",
Attribute::VolumeSerial,
Value::Text("A1B2".into()),
SourceKind::MountedDevices,
"m",
),
];
let hist = correlate(&claims);
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].device, DeviceKey("SN1".into()));
assert_eq!(hist[1].device, DeviceKey("SN2".into()));
let attrs: Vec<Attribute> = hist[0].attributes.iter().map(|a| a.attribute).collect();
assert_eq!(attrs, [Attribute::LastConnected, Attribute::VolumeSerial]);
}
#[test]
fn to_jsonl_is_one_object_per_line_and_valid_json() {
let claims = [
claim(
"SN1",
Attribute::FirstConnected,
Value::Timestamp(1_700_000_000),
SourceKind::Usbstor,
"k",
),
claim(
"SN2",
Attribute::VolumeName,
Value::Text("DISK".into()),
SourceKind::MountedDevices,
"m",
),
];
let hist = correlate(&claims);
let jsonl = to_jsonl(&hist).unwrap();
let lines: Vec<&str> = jsonl.lines().collect();
assert_eq!(lines.len(), 2);
for line in lines {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(v.get("device").is_some());
assert!(v.get("attributes").is_some());
}
}
}