Skip to main content

usb_forensic/sources/
peripheral.rs

1//! Adapter: `peripheral-core` [`DeviceConnection`]s → USB-history [`Claim`]s.
2//!
3//! `peripheral-core` is the device-connection domain reader: today it decodes
4//! `setupapi.dev.log` (first-install times); with its 0.2 registry module it also
5//! decodes USBSTOR/SCSI/USB device instances (first/last connect + last removal). This
6//! adapter maps either into `Claim`s keyed by the device serial, so both flow into the
7//! same correlation engine. A pure mapping over already-decoded records.
8
9use crate::{Attribute, Claim, DeviceKey, HistorySource, Provenance, SourceKind, Value};
10use peripheral_core::DeviceConnection;
11
12/// A [`HistorySource`] over decoded [`DeviceConnection`]s from one origin.
13///
14/// A single `peripheral-core` parse call handles one origin at a time —
15/// `parse_setupapi`, `parse_registry`, or `parse_linux_syslog` — so the caller
16/// knows which [`SourceKind`] every connection in the batch came from and passes
17/// it in. The adapter carries that verbatim onto each [`Claim`]'s provenance,
18/// which is what drives the container / clock-locality reasoning downstream.
19pub struct PeripheralSource<'a> {
20    conns: &'a [DeviceConnection],
21    source: SourceKind,
22}
23
24impl<'a> PeripheralSource<'a> {
25    /// Wrap decoded device connections, all from `source` (e.g.
26    /// [`SourceKind::SetupApi`], [`SourceKind::Usbstor`] for the registry reader,
27    /// or [`SourceKind::LinuxKernelLog`]).
28    #[must_use]
29    pub fn new(conns: &'a [DeviceConnection], source: SourceKind) -> Self {
30        Self { conns, source }
31    }
32}
33
34impl HistorySource for PeripheralSource<'_> {
35    fn claims(&self) -> Vec<Claim> {
36        let mut out = Vec::new();
37        for conn in self.conns {
38            push_conn(conn, self.source, &mut out);
39        }
40        out
41    }
42}
43
44fn push_conn(conn: &DeviceConnection, source: SourceKind, out: &mut Vec<Claim>) {
45    // Key by the instance serial so setupapi and the registry reader (which keys by the
46    // bare instance name) agree on the same device: the explicit `device_serial` when
47    // present, else the last `\`-separated component of the instance id.
48    let device = DeviceKey(if let Some(serial) = &conn.device_serial {
49        serial.clone()
50    } else {
51        let id = &conn.device_instance_id;
52        let start = id.rfind('\\').map_or(0, |i| i + 1);
53        id[start..].to_string()
54    });
55    // Line-oriented sources (setupapi/syslog) locate by file:line; a registry
56    // connection carries the full key path instead (line is 0), so prefer it.
57    let locator = conn
58        .source
59        .key_path
60        .clone()
61        .unwrap_or_else(|| format!("{}:{}", conn.source.file, conn.source.line));
62    let claim = |attribute, value: i64| Claim {
63        device: device.clone(),
64        attribute,
65        value: Value::Timestamp(value),
66        provenance: Provenance {
67            source,
68            locator: locator.clone(),
69        },
70    };
71    if let Some(s) = &conn.first_install {
72        out.push(claim(Attribute::FirstConnected, s.value));
73    }
74    if let Some(s) = &conn.last_arrival {
75        out.push(claim(Attribute::LastConnected, s.value));
76    }
77    if let Some(s) = &conn.last_removal {
78        out.push(claim(Attribute::LastRemoved, s.value));
79    }
80    if let Some(letter) = conn.drive_letter {
81        out.push(Claim {
82            device: device.clone(),
83            attribute: Attribute::DriveLetter,
84            value: Value::Text(format!("{letter}:")),
85            provenance: Provenance {
86                source,
87                locator: locator.clone(),
88            },
89        });
90    }
91    // Surface an MTP/PTP portable device (phone/tablet/camera) — a data-exfil endpoint that
92    // never appears under USBSTOR; peripheral-core classified it from its WUDFWpdMtp service.
93    if conn.bus == peripheral_core::Bus::Mtp {
94        out.push(Claim {
95            device,
96            attribute: Attribute::DeviceClass,
97            value: Value::Text("MTP".to_string()),
98            provenance: Provenance { source, locator },
99        });
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use peripheral_core::setupapi::parse_setupapi;
107    use peripheral_core::Stamp;
108
109    const USBSTOR_HEADER: &str = "[Device Install (Hardware initiated) - \
110        USBSTOR\\Disk&Ven_Generic&Prod_Flash\\7&1c2c4f0a&0 2024/01/02 03:04:05.000]";
111
112    #[test]
113    fn setupapi_connection_yields_first_connected_claim() {
114        let conns = parse_setupapi(USBSTOR_HEADER, "setupapi.dev.log");
115        assert_eq!(conns.len(), 1);
116        let claims = PeripheralSource::new(&conns, SourceKind::SetupApi).claims();
117        let fc = claims
118            .iter()
119            .find(|c| c.attribute == Attribute::FirstConnected)
120            .expect("first-connected claim");
121        // keyed by the last instance-id component (the instance serial).
122        assert_eq!(fc.device, DeviceKey("7&1c2c4f0a&0".to_string()));
123        assert_eq!(fc.provenance.source, SourceKind::SetupApi);
124        // A line-oriented source (no key_path) locates by file:line.
125        assert_eq!(fc.provenance.locator, "setupapi.dev.log:1");
126        assert!(matches!(fc.value, Value::Timestamp(_)));
127    }
128
129    #[test]
130    fn source_kind_is_taken_from_the_caller() {
131        // The bin knows each connection's origin (setupapi vs registry vs Linux) and
132        // stamps it; the adapter faithfully carries whatever SourceKind it is given.
133        let conns = parse_setupapi(USBSTOR_HEADER, "syslog");
134        let claims = PeripheralSource::new(&conns, SourceKind::LinuxKernelLog).claims();
135        assert!(!claims.is_empty());
136        assert!(claims
137            .iter()
138            .all(|c| c.provenance.source == SourceKind::LinuxKernelLog));
139    }
140
141    #[test]
142    fn registry_key_path_is_used_as_the_locator() {
143        // A registry-sourced connection is not line-oriented (line 0) — its locator is
144        // the full key path, so provenance points at the exact hive key.
145        let mut conn = parse_setupapi(USBSTOR_HEADER, "SYSTEM")
146            .pop()
147            .expect("one conn");
148        let key = "ControlSet001\\Enum\\USBSTOR\\Disk&Ven_Generic&Prod_Flash\\7&1c2c4f0a&0";
149        conn.source.key_path = Some(key.to_string());
150        conn.source.line = 0;
151        let conns = [conn];
152        let claims = PeripheralSource::new(&conns, SourceKind::Usbstor).claims();
153        assert_eq!(claims[0].provenance.source, SourceKind::Usbstor);
154        assert_eq!(claims[0].provenance.locator, key);
155    }
156
157    #[test]
158    fn drive_letter_yields_a_drive_letter_claim() {
159        // peripheral-core 0.3's MountedDevices join sets `drive_letter`; the adapter
160        // surfaces it as a `DriveLetter` claim keyed by the same device, so the
161        // correlated record shows the mount (e.g. `E:`).
162        let mut conn = parse_setupapi(USBSTOR_HEADER, "SYSTEM")
163            .pop()
164            .expect("one conn");
165        conn.drive_letter = Some('E');
166        let conns = [conn];
167        let claims = PeripheralSource::new(&conns, SourceKind::Usbstor).claims();
168        let dl = claims
169            .iter()
170            .find(|c| c.attribute == Attribute::DriveLetter)
171            .expect("drive-letter claim");
172        assert_eq!(dl.value, Value::Text("E:".to_string()));
173        assert_eq!(dl.device, DeviceKey("7&1c2c4f0a&0".to_string()));
174        assert_eq!(dl.provenance.source, SourceKind::Usbstor);
175    }
176
177    #[test]
178    fn explicit_device_serial_is_used_as_the_key() {
179        let mut conn = parse_setupapi(USBSTOR_HEADER, "f").pop().expect("one conn");
180        conn.device_serial = Some("AA11BB22".to_string());
181        let conns = [conn];
182        let claims = PeripheralSource::new(&conns, SourceKind::SetupApi).claims();
183        assert_eq!(claims[0].device, DeviceKey("AA11BB22".to_string()));
184    }
185
186    #[test]
187    fn arrival_and_removal_stamps_yield_last_connected_and_removed() {
188        let mut conn = parse_setupapi(USBSTOR_HEADER, "f").pop().expect("one conn");
189        conn.last_arrival = Some(Stamp::inferred(1_700_000_500));
190        conn.last_removal = Some(Stamp::inferred(1_700_000_900));
191        let conns = [conn];
192        let claims = PeripheralSource::new(&conns, SourceKind::SetupApi).claims();
193        assert_eq!(
194            claims
195                .iter()
196                .find(|c| c.attribute == Attribute::LastConnected)
197                .map(|c| &c.value),
198            Some(&Value::Timestamp(1_700_000_500))
199        );
200        assert_eq!(
201            claims
202                .iter()
203                .find(|c| c.attribute == Attribute::LastRemoved)
204                .map(|c| &c.value),
205            Some(&Value::Timestamp(1_700_000_900))
206        );
207    }
208
209    #[test]
210    fn an_mtp_bus_device_emits_a_device_class_claim() {
211        let mut conn = parse_setupapi(USBSTOR_HEADER, "f").pop().expect("one conn");
212        conn.bus = peripheral_core::Bus::Mtp;
213        let conns = [conn];
214        let claims = PeripheralSource::new(&conns, SourceKind::Usbstor).claims();
215        let dc = claims
216            .iter()
217            .find(|c| c.attribute == Attribute::DeviceClass)
218            .expect("device-class claim");
219        assert_eq!(dc.value, Value::Text("MTP".to_string()));
220    }
221
222    #[test]
223    fn a_non_mtp_bus_device_emits_no_device_class_claim() {
224        let conns = parse_setupapi(USBSTOR_HEADER, "f"); // bus classified as Usb
225        let claims = PeripheralSource::new(&conns, SourceKind::Usbstor).claims();
226        assert!(!claims.iter().any(|c| c.attribute == Attribute::DeviceClass));
227    }
228
229    #[test]
230    fn separatorless_instance_id_is_used_whole() {
231        // No `\` in the instance id → the whole string is the key (rfind → None branch).
232        let mut conn = parse_setupapi(USBSTOR_HEADER, "f").pop().expect("one conn");
233        conn.device_serial = None;
234        conn.device_instance_id = "BAREINSTANCE".to_string();
235        let conns = [conn];
236        let claims = PeripheralSource::new(&conns, SourceKind::SetupApi).claims();
237        assert_eq!(claims[0].device, DeviceKey("BAREINSTANCE".to_string()));
238    }
239}