Skip to main content

peripheral_core/
usb_ids.rs

1//! USB ID resolution — `VID`/`PID` → vendor / product **name**.
2//!
3//! A **non-authoritative enrichment** over [`DeviceConnection`](crate::DeviceConnection):
4//! the raw numeric `vid`/`pid` parsed from evidence stay the authoritative values;
5//! a resolved name is only a lookup convenience for the analyst.
6//!
7//! Two sources, no third-party data vendored into the crate:
8//! - [`UsbIdDb::common`] — a small **hand-authored** table of common,
9//!   forensically-relevant USB vendors (individual `VID → name` *facts*, which are
10//!   not copyrightable). Zero-config; resolves the devices seen most in casework.
11//! - [`UsbIdDb::parse`] — parses the full linux-usb.org `usb.ids` text format when
12//!   the operator supplies it at runtime. `usb.ids` (© Stephen J. Gowdy, dual
13//!   GPL-2.0 / BSD-3-Clause) is **not** bundled — load it from a path/env at run time.
14
15use std::collections::BTreeMap;
16
17/// A USB-ID lookup table: `VID → vendor name` and `(VID, PID) → product name`.
18#[derive(Debug, Default, Clone)]
19pub struct UsbIdDb {
20    vendors: BTreeMap<u16, String>,
21    products: BTreeMap<(u16, u16), String>,
22}
23
24impl UsbIdDb {
25    /// Parse the linux-usb.org `usb.ids` text format (operator-supplied at runtime).
26    ///
27    /// Lenient and panic-free: comment (`#`) and blank lines are skipped, a vendor
28    /// is `VVVV␠␠Name` at column 0, a product is `␉PPPP␠␠Name` (one tab), interface
29    /// lines (two tabs) and non-vendor sections (`C`, `AT`, `HID`, `VT`, …) are
30    /// skipped, and a product only attaches to the vendor currently in scope.
31    #[must_use]
32    pub fn parse(text: &str) -> Self {
33        let mut db = Self::default();
34        let mut current_vendor: Option<u16> = None;
35        for line in text.lines() {
36            if line.is_empty() || line.starts_with('#') {
37                continue;
38            }
39            if let Some(rest) = line.strip_prefix('\t') {
40                // one leading tab = product; two = interface line (skip).
41                if rest.starts_with('\t') {
42                    continue;
43                }
44                if let (Some(vid), Some((pid, name))) = (current_vendor, parse_id_line(rest)) {
45                    db.products.insert((vid, pid), name.to_owned());
46                }
47                continue;
48            }
49            // Column-0 line: a vendor (4 hex + two spaces) or a section header.
50            if let Some((vid, name)) = parse_id_line(line) {
51                db.vendors.insert(vid, name.to_owned());
52                current_vendor = Some(vid);
53            } else {
54                current_vendor = None; // section header ends the vendor's scope.
55            }
56        }
57        db
58    }
59
60    /// The built-in, hand-authored table of common forensic-relevant USB vendors.
61    ///
62    /// Individual `VID → name` facts (not the copyrightable `usb.ids` compilation);
63    /// zero-config coverage of the storage/controller vendors seen most in casework.
64    /// For full coverage, load `usb.ids` at runtime via [`parse`](Self::parse).
65    #[must_use]
66    pub fn common() -> Self {
67        let mut db = Self::default();
68        for v in forensicnomicon_core::usb_vendors::COMMON_USB_VENDORS {
69            db.vendors.insert(v.vid, v.name.to_owned());
70        }
71        db
72    }
73
74    /// Resolve a vendor id to its name, if known.
75    #[must_use]
76    pub fn vendor_name(&self, vid: u16) -> Option<&str> {
77        self.vendors.get(&vid).map(String::as_str)
78    }
79
80    /// Resolve a (vendor, product) pair to the product name, if known.
81    #[must_use]
82    pub fn product_name(&self, vid: u16, pid: u16) -> Option<&str> {
83        self.products.get(&(vid, pid)).map(String::as_str)
84    }
85
86    /// Number of vendors in the table.
87    #[must_use]
88    pub fn vendor_count(&self) -> usize {
89        self.vendors.len()
90    }
91
92    /// `true` when no vendors are loaded.
93    #[must_use]
94    pub fn is_empty(&self) -> bool {
95        self.vendors.is_empty()
96    }
97}
98
99/// Parse a `VVVV␠␠Name` id line into `(id, name)`. Panic-free (no slice indexing);
100/// returns `None` unless the first four chars are hex followed by exactly two spaces
101/// and a non-empty name — which cleanly rejects section headers (`C 00`, `VT 0100`).
102fn parse_id_line(s: &str) -> Option<(u16, &str)> {
103    let id = u16::from_str_radix(s.get(0..4)?, 16).ok()?;
104    if s.get(4..6)? != "  " {
105        return None;
106    }
107    let name = s.get(6..)?.trim_end();
108    (!name.is_empty()).then_some((id, name))
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    // Real lines from the linux-usb.org usb.ids format, incl. a trailing
116    // non-vendor section whose product line must NOT leak to the prior vendor.
117    const FIXTURE: &str = "# comment line\n\
1180781  SanDisk Corp.\n\
119\t0001  SDDR-05a ImageMate CompactFlash Reader\n\
120\t0002  SDDR-31 ImageMate II CompactFlash Reader\n\
1210951  Kingston Technology\n\
122\t1666  DataTraveler 100 G3/G4\n\
123C 00  (Defined at Interface level)\n\
124\t01  Audio\n";
125
126    #[test]
127    fn parses_vendor_name() {
128        let db = UsbIdDb::parse(FIXTURE);
129        assert_eq!(db.vendor_name(0x0781), Some("SanDisk Corp."));
130    }
131
132    #[test]
133    fn parses_product_name() {
134        let db = UsbIdDb::parse(FIXTURE);
135        assert_eq!(
136            db.product_name(0x0781, 0x0001),
137            Some("SDDR-05a ImageMate CompactFlash Reader")
138        );
139    }
140
141    #[test]
142    fn unknown_vendor_is_none() {
143        let db = UsbIdDb::parse(FIXTURE);
144        assert_eq!(db.vendor_name(0xFFFF), None);
145    }
146
147    #[test]
148    fn section_products_do_not_leak_to_previous_vendor() {
149        // `\t01 Audio` sits under `C 00` (an interface-class section), not under
150        // Kingston — it must not become Kingston product 0x0001.
151        let db = UsbIdDb::parse(FIXTURE);
152        assert_eq!(db.product_name(0x0951, 0x0001), None);
153        assert_eq!(
154            db.product_name(0x0951, 0x1666),
155            Some("DataTraveler 100 G3/G4")
156        );
157    }
158
159    #[test]
160    fn comment_and_blank_lines_ignored() {
161        let db = UsbIdDb::parse(FIXTURE);
162        assert_eq!(db.vendor_count(), 2);
163        assert!(!db.is_empty());
164    }
165
166    #[test]
167    fn common_table_resolves_a_known_vendor() {
168        let db = UsbIdDb::common();
169        assert_eq!(db.vendor_name(0x0781), Some("SanDisk Corp."));
170        assert!(db.vendor_count() >= 20);
171    }
172
173    #[test]
174    fn interface_lines_are_skipped_not_read_as_products() {
175        // Two leading tabs is an interface line inside a product. Reading it as
176        // a product would register 0x0781:0x0000 "Mass Storage" — a device that
177        // does not exist. Real usb.ids nests these under most storage devices.
178        let db = UsbIdDb::parse("0781  SanDisk Corp.\n\t5583  Ultra Fit\n\t\t00  Mass Storage\n");
179        assert_eq!(db.product_name(0x0781, 0x5583), Some("Ultra Fit"));
180        assert_eq!(db.product_name(0x0781, 0x0000), None);
181    }
182
183    #[test]
184    fn four_hex_chars_alone_do_not_make_an_id_line() {
185        // Both lines open with four valid hex characters and are still rejected,
186        // so they exercise the separator check rather than the hex parse — the
187        // section headers in FIXTURE (`C 00`) fail earlier, at the hex step.
188        let db = UsbIdDb::parse("0781 SanDisk Corp.\n0951\tKingston\n");
189        assert_eq!(db.vendor_count(), 0);
190        assert!(db.is_empty());
191        assert_eq!(db.vendor_name(0x0781), None);
192    }
193
194    #[test]
195    fn a_wider_separator_is_accepted_and_keeps_the_surplus_in_the_name() {
196        // Current behaviour, asserted so a change is visible rather than silent.
197        // `parse_id_line`'s doc says "exactly two spaces", but the check reads
198        // only positions 4..6, so a third space falls into the name and survives
199        // — `trim_end` does not touch a leading one. usb.ids uses exactly two
200        // spaces throughout, so no real line reaches this; if the name is ever
201        // trimmed at the front, this assertion is where it shows up.
202        let db = UsbIdDb::parse("0abc   Three Spaces\n");
203        assert_eq!(db.vendor_count(), 1);
204        assert_eq!(db.vendor_name(0x0abc), Some(" Three Spaces"));
205    }
206}