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}