Skip to main content

netscli_core/
oui.rs

1use once_cell::sync::OnceCell;
2use serde_json::Value;
3use std::collections::HashMap;
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8static OUI_MAP: OnceCell<HashMap<String, String>> = OnceCell::new();
9
10// Embedded copy of the vendor DB that ships inside the crate, so
11// `cargo install netscli` and release binaries without a data/ dir
12// next to them still get working vendor lookups. Overridable via
13// NETSCLI_OUI_PATH or any of the on-disk candidates below.
14const EMBEDDED_OUI: &[u8] = include_bytes!("../data/oui.min.json.gz");
15
16pub fn lookup_vendor(mac: &str) -> Option<String> {
17    let map = OUI_MAP.get_or_init(load_oui);
18    let prefix = oui_prefix(mac)?;
19    map.get(&prefix).cloned()
20}
21
22/// First 6 hex digits of a MAC, uppercased, separators removed.
23///
24/// Takes the first 6 *characters*, not the first 6 bytes. This is public
25/// API, so a caller can hand us anything: `&key[..6]` panicked whenever
26/// byte 6 landed mid-codepoint (e.g. "aabbc€", where € occupies bytes
27/// 5..8). Returns `None` for anything that isn't at least 6 hex digits,
28/// which also rejects the non-MAC input that used to reach the map
29/// lookup.
30fn oui_prefix(mac: &str) -> Option<String> {
31    let prefix: String = mac
32        .chars()
33        .filter(|c| !matches!(c, ':' | '-' | '.'))
34        .take(6)
35        .collect();
36
37    if prefix.chars().count() == 6 && prefix.chars().all(|c| c.is_ascii_hexdigit()) {
38        Some(prefix.to_ascii_uppercase())
39    } else {
40        None
41    }
42}
43
44fn load_oui() -> HashMap<String, String> {
45    let mut candidates: Vec<PathBuf> = Vec::new();
46
47    if let Ok(path) = std::env::var("NETSCLI_OUI_PATH") {
48        candidates.push(PathBuf::from(path));
49    }
50
51    if let Ok(exe) = std::env::current_exe() {
52        if let Some(dir) = exe.parent() {
53            candidates.push(dir.join("oui.min.json.gz"));
54            candidates.push(dir.join("oui.json"));
55            candidates.push(dir.join("data").join("oui.min.json.gz"));
56            candidates.push(dir.join("data").join("oui.json"));
57        }
58    }
59
60    candidates.push(PathBuf::from("data/oui.min.json.gz"));
61    candidates.push(PathBuf::from("data/oui.json"));
62
63    for cand in &candidates {
64        if let Some(map) = read_map(cand) {
65            return map;
66        }
67    }
68
69    // No disk copy found. Fall through to the embedded copy so
70    // `cargo install` users still get working vendor lookups.
71    parse_gz(EMBEDDED_OUI).unwrap_or_default()
72}
73
74fn read_map(path: &Path) -> Option<HashMap<String, String>> {
75    let is_gz = path
76        .extension()
77        .and_then(|s| s.to_str())
78        .is_some_and(|ext| ext.eq_ignore_ascii_case("gz"));
79
80    if is_gz {
81        let bytes = fs::read(path).ok()?;
82        parse_gz(&bytes)
83    } else {
84        let file = fs::File::open(path).ok()?;
85        let mut data = String::new();
86        std::io::Read::take(file, MAX_OUI_BYTES)
87            .read_to_string(&mut data)
88            .ok()?;
89        parse_json(&data)
90    }
91}
92
93/// Ceiling on the OUI dataset, compressed or not.
94///
95/// The bundled file is 1.3 MB decompressed, so this leaves room for a much
96/// larger vendor list while refusing an implausible one. `NETSCLI_OUI_PATH`
97/// points this at an arbitrary file, and the gzip path amplifies: a few
98/// hundred KB on disk can decompress without limit into a `String`.
99const MAX_OUI_BYTES: u64 = 32 * 1024 * 1024;
100
101fn parse_gz(bytes: &[u8]) -> Option<HashMap<String, String>> {
102    let decoder = flate2::read::GzDecoder::new(bytes);
103    let mut buf = String::new();
104    // Bounded read: a truncated result fails `parse_json` rather than being
105    // silently accepted as a short vendor list.
106    std::io::Read::take(decoder, MAX_OUI_BYTES)
107        .read_to_string(&mut buf)
108        .ok()?;
109    parse_json(&buf)
110}
111
112fn parse_json(data: &str) -> Option<HashMap<String, String>> {
113    let json: Value = serde_json::from_str(data).ok()?;
114    let mut map = HashMap::new();
115    if let Some(obj) = json.as_object() {
116        for (k, v) in obj {
117            if let Some(s) = v.as_str() {
118                // Same char-vs-byte hazard as `lookup_vendor`: keys come
119                // from a JSON file that may be user-supplied via
120                // NETSCLI_OUI_PATH, so a multi-byte key must not panic.
121                if let Some(prefix) = oui_prefix(k) {
122                    map.insert(prefix, s.to_string());
123                }
124            }
125        }
126    }
127    Some(map)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::{lookup_vendor, oui_prefix};
133
134    #[test]
135    fn normalizes_common_mac_separators() {
136        for mac in ["AA:BB:CC:DD:EE:FF", "aa-bb-cc-dd-ee-ff", "aabbccddeeff"] {
137            assert_eq!(oui_prefix(mac).as_deref(), Some("AABBCC"), "input: {mac}");
138        }
139    }
140
141    #[test]
142    fn multibyte_input_does_not_panic() {
143        // Regression: `&key[..6]` panicked here because '€' is 3 bytes,
144        // so byte index 6 fell inside it.
145        assert_eq!(oui_prefix("aabbc€"), None);
146        assert_eq!(lookup_vendor("aabbc€"), None);
147        for mac in ["€€€€€€", "日本語テスト", "aa:bb:c€"] {
148            assert!(lookup_vendor(mac).is_none(), "input: {mac}");
149        }
150    }
151
152    #[test]
153    fn rejects_short_and_non_hex_input() {
154        assert_eq!(oui_prefix("aabb"), None);
155        assert_eq!(oui_prefix(""), None);
156        assert_eq!(oui_prefix("zzzzzz"), None);
157        assert_eq!(oui_prefix("not-a-mac-at-all"), None);
158    }
159}