1use crate::setupapi::civil_to_epoch;
16use crate::{Bus, DeviceConnection, Provenance, Stamp};
17
18#[must_use]
23pub fn parse_linux_syslog(text: &str, file: &str, year: i64) -> Vec<DeviceConnection> {
24 let mut out = Vec::new();
25 let mut current: Option<Partial> = None;
26 for (idx, line) in text.lines().enumerate() {
27 if let Some(started) = Partial::start(line, year, idx + 1) {
28 if let Some(prev) = current.take() {
29 out.push(prev.finish(file));
30 }
31 current = Some(started);
32 } else if let Some(dev) = current.as_mut() {
33 dev.absorb(line);
34 }
35 }
36 if let Some(dev) = current.take() {
37 out.push(dev.finish(file));
38 }
39 out
40}
41
42struct Partial {
44 busid: String,
45 vid: Option<u16>,
46 pid: Option<u16>,
47 epoch: Option<i64>,
48 line: usize,
49 product: Option<String>,
50 serial: Option<String>,
51}
52
53impl Partial {
54 fn start(line: &str, year: i64, line_no: usize) -> Option<Self> {
56 let (busid, msg) = usb_message(line)?;
57 let rest = msg.strip_prefix("New USB device found,")?;
58 Some(Self {
59 busid: busid.to_string(),
60 vid: hex_field(rest, "idVendor="),
61 pid: hex_field(rest, "idProduct="),
62 epoch: bsd_epoch(line, year),
63 line: line_no,
64 product: None,
65 serial: None,
66 })
67 }
68
69 fn absorb(&mut self, line: &str) {
71 let Some((busid, msg)) = usb_message(line) else {
72 return;
73 };
74 if busid != self.busid {
75 return;
76 }
77 if let Some(product) = msg.strip_prefix("Product: ") {
78 self.product = Some(product.trim().to_string());
79 } else if let Some(serial) = msg.strip_prefix("SerialNumber: ") {
80 self.serial = Some(serial.trim().to_string());
81 }
82 }
83
84 fn finish(self, file: &str) -> DeviceConnection {
85 DeviceConnection {
86 bus: Bus::Usb,
87 device_class_guid: None,
88 vid: self.vid,
89 pid: self.pid,
90 device_serial: self.serial,
91 serial_is_os_generated: false,
92 friendly_name: self.product,
93 device_instance_id: format!("usb/{}", self.busid),
94 first_install: self.epoch.map(Stamp::authoritative),
95 last_install: None,
96 last_arrival: None,
97 last_removal: None,
98 parent_id_prefix: None,
99 volume_guid: None,
100 drive_letter: None,
101 volume_serial: None,
102 disk_signature: None,
103 dma_capable: Bus::Usb.is_dma_capable(),
104 mitre: Vec::new(),
105 source: Provenance {
106 file: file.to_string(),
107 line: self.line,
108 key_path: None,
109 },
110 }
111 }
112}
113
114fn usb_message(line: &str) -> Option<(&str, &str)> {
116 let after = line.find("usb ").map(|i| &line[i + 4..])?;
117 after.split_once(": ")
118}
119
120fn hex_field(text: &str, tag: &str) -> Option<u16> {
122 let start = text.find(tag)? + tag.len();
123 let hex = text.get(start..start + 4)?;
124 u16::from_str_radix(hex, 16).ok()
125}
126
127fn bsd_epoch(line: &str, year: i64) -> Option<i64> {
130 let mut tok = line.split_whitespace();
131 let month = month_number(tok.next()?)?;
132 let day: i64 = tok.next()?.parse().ok()?;
133 let mut hms = tok.next()?.split(':');
134 let hour: i64 = hms.next()?.parse().ok()?;
135 let min: i64 = hms.next()?.parse().ok()?;
136 let sec: i64 = hms.next()?.parse().ok()?;
137 civil_to_epoch(year, month, day, hour, min, sec)
138}
139
140fn month_number(name: &str) -> Option<i64> {
142 const MONTHS: [&str; 12] = [
143 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
144 ];
145 MONTHS
146 .iter()
147 .position(|&m| m == name)
148 .map(|i| i64::try_from(i).unwrap_or(0) + 1)
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used)]
153mod tests {
154 use super::*;
155
156 const BLOCK: &str = "\
157Mar 24 15:47:28 kernel: [ 1.501698] usb 2-1: new full-speed USB device number 2 using ohci-pci
158Mar 24 15:47:28 kernel: [ 2.350118] usb 2-1: New USB device found, idVendor=0781, idProduct=5583, bcdDevice= 1.00
159Mar 24 15:47:28 kernel: [ 2.350126] usb 2-1: New USB device strings: Mfr=1, Product=3, SerialNumber=2
160Mar 24 15:47:28 kernel: [ 2.350143] usb 2-1: Product: Ultra Fit
161Mar 24 15:47:28 kernel: [ 2.350146] usb 2-1: Manufacturer: SanDisk
162Mar 24 15:47:28 kernel: [ 2.350150] usb 2-1: SerialNumber: 0123ABCD4567
163Mar 24 15:47:29 kernel: [ 2.400000] some unrelated line without a device";
164
165 #[test]
166 fn parses_a_usb_block_into_a_connection() {
167 let conns = parse_linux_syslog(BLOCK, "syslog", 2026);
168 assert_eq!(conns.len(), 1);
169 let c = &conns[0];
170 assert_eq!(c.bus, Bus::Usb);
171 assert_eq!(c.vid, Some(0x0781));
172 assert_eq!(c.pid, Some(0x5583));
173 assert_eq!(c.device_serial.as_deref(), Some("0123ABCD4567"));
174 assert_eq!(c.friendly_name.as_deref(), Some("Ultra Fit"));
175 assert_eq!(c.device_instance_id, "usb/2-1");
176 assert_eq!(
178 c.first_install.as_ref().map(|s| s.value),
179 civil_to_epoch(2026, 3, 24, 15, 47, 28)
180 );
181 assert_eq!(c.source.line, 2);
182 }
183
184 #[test]
185 fn two_devices_and_a_serialless_device() {
186 let text = "\
187Jan 2 00:00:00 kernel: usb 1-1: New USB device found, idVendor=abcd, idProduct=0001
188Jan 2 00:00:00 kernel: usb 1-1: Product: Tablet
189Feb 15 12:00:00 kernel: usb 3-2: New USB device found, idVendor=1234, idProduct=5678
190Feb 15 12:00:00 kernel: usb 9-9: Product: WrongBus
191Feb 15 12:00:00 kernel: usb 3-2: SerialNumber: SN9";
192 let conns = parse_linux_syslog(text, "dmesg", 2026);
193 assert_eq!(conns.len(), 2);
194 assert_eq!(conns[0].device_serial, None);
196 assert_eq!(conns[0].friendly_name.as_deref(), Some("Tablet"));
197 assert_eq!(conns[1].device_serial.as_deref(), Some("SN9"));
199 assert_eq!(conns[1].friendly_name, None);
200 }
201
202 #[test]
203 fn malformed_lines_and_bad_month_never_panic() {
204 assert!(parse_linux_syslog("", "f", 2026).is_empty());
205 assert!(parse_linux_syslog("not a kernel line at all\n\0\0", "f", 2026).is_empty());
206 assert_eq!(month_number("Zzz"), None);
207 let c = &parse_linux_syslog(
209 "Zzz 99 99:99:99 kernel: usb 2-1: New USB device found, idVendor=0781, idProduct=5583",
210 "f",
211 2026,
212 )[0];
213 assert_eq!(c.first_install, None);
214 assert_eq!(c.vid, Some(0x0781));
215 }
216}