Skip to main content

usb_forensic/sources/
partition_diag.rs

1//! Adapter: `winevt-extract` [`PartitionDiagEvent`]s (Microsoft-Windows-Partition
2//! EID 1006) → USB-history [`Claim`]s.
3//!
4//! The Partition/Diagnostic event log records a disk-arrival event each time the
5//! partition manager scans a disk. Each record independently attests that a device —
6//! identified by its serial number, or failing that its disk GUID — was connected at
7//! the record's time. That is an **event-log** witness of a connection, a different
8//! tamper surface from the registry/setupapi record of the same device, so when the two
9//! agree the correlation core can grade it corroborated. A pure mapping over
10//! already-decoded events; the reader crate did the `.evtx` parsing.
11
12use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
13use winevt_extract::PartitionDiagEvent;
14
15/// A [`HistorySource`] over decoded Partition/Diagnostic disk-arrival events.
16pub struct PartitionDiagSource<'a> {
17    events: &'a [PartitionDiagEvent],
18}
19
20impl<'a> PartitionDiagSource<'a> {
21    /// Wrap decoded EID-1006 events (from `winevt_extract::partition_diag`).
22    #[must_use]
23    pub fn new(events: &'a [PartitionDiagEvent]) -> Self {
24        Self { events }
25    }
26}
27
28impl HistorySource for PartitionDiagSource<'_> {
29    fn claims(&self) -> Vec<Claim> {
30        let mut out = Vec::new();
31        for event in self.events {
32            push_event(event, &mut out);
33        }
34        out
35    }
36}
37
38/// The device identity a record keys on: the device serial when the provider recorded a
39/// non-empty one, else the disk GUID. `None` when neither is present — there is no stable
40/// identity to correlate on, so the record contributes nothing.
41fn device_key(event: &PartitionDiagEvent) -> Option<DeviceKey> {
42    event
43        .serial_number
44        .clone()
45        .filter(|s| !s.is_empty())
46        .or_else(|| event.disk_id.clone())
47        .map(DeviceKey)
48}
49
50fn push_event(event: &PartitionDiagEvent, out: &mut Vec<Claim>) {
51    let Some(device) = device_key(event) else {
52        return;
53    };
54    let locator = format!(
55        "Microsoft-Windows-Partition/Diagnostic#1006 DiskId={}",
56        event.disk_id.as_deref().unwrap_or("?")
57    );
58    let claim = |device, attribute, value| Claim {
59        device,
60        attribute,
61        value,
62        provenance: Provenance {
63            source: SourceKind::PartitionDiag,
64            locator: locator.clone(),
65        },
66    };
67    // The provider timestamp is ISO-8601 UTC; a malformed one is dropped, never turned
68    // into a bogus epoch (a wrong time is worse than a missing one).
69    if let Ok(when) = event.timestamp.parse::<jiff::Timestamp>() {
70        out.push(claim(
71            device.clone(),
72            Attribute::LastConnected,
73            Value::Timestamp(when.as_second()),
74        ));
75    }
76    if let Some(serial) = volume_serial_string(event) {
77        out.push(claim(device, Attribute::VolumeSerial, Value::Text(serial)));
78    }
79}
80
81/// The device's volume serial as a matchable string. A FAT serial is rendered the way a
82/// Shell Link records its `DriveSerialNumber` (`XXXX-XXXX`, the 4-byte join key) so it can
83/// reconcile with LNK file-access on the same volume; an NTFS serial is rendered in its
84/// distinct 8-byte form (`XXXXXXXX-XXXXXXXX`), which by construction cannot collide with a
85/// 4-byte LNK serial. `None` when the VBR carried neither.
86fn volume_serial_string(event: &PartitionDiagEvent) -> Option<String> {
87    if let Some(fat) = event.fat_volume_serial {
88        return Some(format!("{:04X}-{:04X}", fat >> 16, fat & 0xFFFF));
89    }
90    event.ntfs_volume_serial.map(|ntfs| {
91        format!(
92            "{:08X}-{:08X}",
93            (ntfs >> 32) as u32,
94            (ntfs & 0xFFFF_FFFF) as u32
95        )
96    })
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn event(serial: Option<&str>, disk_id: Option<&str>, ts: &str) -> PartitionDiagEvent {
104        PartitionDiagEvent {
105            timestamp: ts.to_string(),
106            event_id: 1006,
107            disk_number: Some(0),
108            bus_type: Some(7),
109            model: Some("Kingston DataTraveler".to_string()),
110            serial_number: serial.map(str::to_owned),
111            disk_id: disk_id.map(str::to_owned),
112            capacity: Some(1_000_000_000),
113            parent_id: Some("USB\\VID_0951&PID_1666\\SN123".to_string()),
114            vbr0_hex: None,
115            fat_volume_serial: None,
116            ntfs_volume_serial: None,
117        }
118    }
119
120    fn claims_for(e: PartitionDiagEvent) -> Vec<Claim> {
121        let evs = [e];
122        PartitionDiagSource::new(&evs).claims()
123    }
124
125    fn volume_serial_claim(claims: &[Claim]) -> Option<&Value> {
126        claims
127            .iter()
128            .find(|c| c.attribute == Attribute::VolumeSerial)
129            .map(|c| &c.value)
130    }
131
132    #[test]
133    fn fat_volume_serial_is_rendered_as_the_lnk_join_key() {
134        // A FAT 4-byte serial is formatted exactly as a Shell Link records it, so it can
135        // reconcile with LNK file-access on the same volume.
136        let mut e = event(Some("SN1"), None, "2020-01-01T00:00:00Z");
137        e.fat_volume_serial = Some(0xDEAD_BEEF);
138        let claims = claims_for(e);
139        assert_eq!(
140            volume_serial_claim(&claims),
141            Some(&Value::Text("DEAD-BEEF".to_string()))
142        );
143    }
144
145    #[test]
146    fn ntfs_volume_serial_is_rendered_as_a_distinct_8_byte_form() {
147        // The NTFS 8-byte serial must not collide with a 4-byte LNK serial → distinct form.
148        let mut e = event(Some("SN2"), None, "2020-01-01T00:00:00Z");
149        e.ntfs_volume_serial = Some(0x36B0_8F15_B08E_DAAF);
150        let claims = claims_for(e);
151        assert_eq!(
152            volume_serial_claim(&claims),
153            Some(&Value::Text("36B08F15-B08EDAAF".to_string()))
154        );
155    }
156
157    #[test]
158    fn no_volume_serial_emits_no_volume_serial_claim() {
159        let claims = claims_for(event(Some("SN3"), None, "2020-01-01T00:00:00Z"));
160        assert_eq!(volume_serial_claim(&claims), None);
161    }
162
163    #[test]
164    fn serial_keyed_event_yields_last_connected() {
165        let claims = claims_for(event(Some("SN123"), Some("{guid}"), "2020-01-01T00:00:00Z"));
166        assert_eq!(claims.len(), 1);
167        assert_eq!(claims[0].device, DeviceKey("SN123".to_string()));
168        assert_eq!(claims[0].attribute, Attribute::LastConnected);
169        assert_eq!(claims[0].value, Value::Timestamp(1_577_836_800));
170        assert_eq!(claims[0].provenance.source, SourceKind::PartitionDiag);
171    }
172
173    #[test]
174    fn empty_serial_falls_back_to_disk_id() {
175        // The real ATA sample logs an empty SerialNumber → key on the disk GUID.
176        let claims = claims_for(event(Some(""), Some("DISK-GUID-1"), "2020-01-01T00:00:00Z"));
177        assert_eq!(claims.len(), 1);
178        assert_eq!(claims[0].device, DeviceKey("DISK-GUID-1".to_string()));
179    }
180
181    #[test]
182    fn missing_serial_falls_back_to_disk_id() {
183        let claims = claims_for(event(None, Some("DISK-GUID-2"), "2020-01-01T00:00:00Z"));
184        assert_eq!(claims[0].device, DeviceKey("DISK-GUID-2".to_string()));
185    }
186
187    #[test]
188    fn no_identity_is_skipped() {
189        // Neither a serial nor a disk GUID → nothing to correlate on.
190        assert!(claims_for(event(Some(""), None, "2020-01-01T00:00:00Z")).is_empty());
191    }
192
193    #[test]
194    fn unparseable_timestamp_is_skipped() {
195        // A malformed TimeCreated must not panic and must not emit a bogus epoch.
196        assert!(claims_for(event(Some("SN9"), None, "not-a-timestamp")).is_empty());
197    }
198
199    #[test]
200    fn subsecond_iso8601_truncates_to_whole_seconds() {
201        // The provider emits sub-second precision; the model is seconds-UTC.
202        let claims = claims_for(event(Some("SN7"), None, "2020-08-01T21:47:55.7235131Z"));
203        assert_eq!(claims[0].value, Value::Timestamp(1_596_318_475));
204    }
205}