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
10const 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
22fn 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 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
93const 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 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 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 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}