Skip to main content

peripheral_core/
linux_syslog.rs

1//! Linux kernel-log (`syslog` / `dmesg`) USB device reader.
2//!
3//! The kernel logs each USB enumeration as a short block keyed by a bus id, e.g.
4//! ```text
5//! Mar 24 15:47:28 kernel: [ 2.350118] usb 2-1: New USB device found, idVendor=80ee, idProduct=0021, bcdDevice= 1.00
6//! Mar 24 15:47:28 kernel: [ 2.350143] usb 2-1: Product: USB Tablet
7//! Mar 24 15:47:28 kernel: [ 2.350146] usb 2-1: Manufacturer: VirtualBox
8//! Mar 24 15:47:28 kernel: [ 2.350150] usb 2-1: SerialNumber: 0123ABCD
9//! ```
10//! This reader stitches the block back into a [`DeviceConnection`] (bus, VID/PID, serial,
11//! friendly name, first-seen time). The syslog timestamp is **year-less** (a BSD-format
12//! forensic limitation) and **host-local**, so the caller supplies the reference `year`
13//! and the epoch is a naive-local conversion — the same caveat the setupapi reader carries.
14
15use crate::setupapi::civil_to_epoch;
16use crate::{Bus, DeviceConnection, Provenance, Stamp};
17
18/// Parse Linux kernel USB-enumeration blocks from `syslog` / `dmesg` text.
19///
20/// `year` is the reference year for the year-less BSD timestamps; `file` is recorded in
21/// each record's [`Provenance`].
22#[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
42/// A device block being assembled from consecutive same-`busid` lines.
43struct 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    /// Begin a block from a `New USB device found` line, or `None` for any other line.
55    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    /// Fold a `Product:` / `SerialNumber:` follow-up line for the same bus id.
70    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
114/// Split the `usb <busid>: <message>` portion out of a kernel-log line.
115fn usb_message(line: &str) -> Option<(&str, &str)> {
116    let after = line.find("usb ").map(|i| &line[i + 4..])?;
117    after.split_once(": ")
118}
119
120/// Read a `<tag><4-hex>` field (e.g. `idVendor=80ee`) as a `u16`.
121fn 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
127/// Parse the leading BSD syslog timestamp (`MMM DD HH:MM:SS`, host-local) to epoch
128/// seconds using the supplied `year`.
129fn 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
140/// Three-letter English month abbreviation → 1-12, or `None`.
141fn 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        // Mar 24 15:47:28 with year 2026 (naive-local → epoch).
177        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        // first device: single-digit day (space-padded), no serial line
195        assert_eq!(conns[0].device_serial, None);
196        assert_eq!(conns[0].friendly_name.as_deref(), Some("Tablet"));
197        // second device: a mismatched-bus follow-up is ignored, the right one absorbed
198        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        // a New-device line with an unparseable timestamp still yields a record (no epoch)
208        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}