1use std::{
2 collections::{HashMap, HashSet},
3 fs,
4 path::{Path, PathBuf},
5 time::SystemTime,
6};
7
8use crate::{
9 MacAddr,
10 error::{RLanLibError, Result},
11 oui::{
12 traits::Oui,
13 types::{OuiData, OuiDataUrl},
14 },
15};
16
17const DATA_URLS: [OuiDataUrl; 5] = [
19 OuiDataUrl {
20 basename: "oui.csv",
21 url: "https://standards-oui.ieee.org/oui/oui.csv",
22 },
23 OuiDataUrl {
24 basename: "mam.csv",
25 url: "https://standards-oui.ieee.org/oui28/mam.csv",
26 },
27 OuiDataUrl {
28 basename: "oui36.csv",
29 url: "https://standards-oui.ieee.org/oui36/oui36.csv",
30 },
31 OuiDataUrl {
32 basename: "cid.csv",
33 url: "https://standards-oui.ieee.org/cid/cid.csv",
34 },
35 OuiDataUrl {
36 basename: "iab.csv",
37 url: "https://standards-oui.ieee.org/iab/iab.csv",
38 },
39];
40
41fn clean_string(s: &str) -> String {
44 s.replace('\u{00A0}', " ").trim().to_string()
45}
46
47pub struct OuiDb {
49 data_dir: PathBuf,
50 csv_paths: Vec<PathBuf>,
51 data: HashMap<String, OuiData>,
52}
53
54impl OuiDb {
55 pub fn new(data_dir: &Path) -> Self {
57 let mut csv_paths = vec![];
58
59 for data_url in DATA_URLS {
60 csv_paths.push(data_dir.join(data_url.basename))
61 }
62
63 Self {
64 data_dir: data_dir.into(),
65 csv_paths,
66 data: HashMap::new(),
67 }
68 }
69
70 pub fn age(&self) -> Option<SystemTime> {
73 let mut time = None;
74
75 for data_url in DATA_URLS {
76 let file = self.data_dir.join(data_url.basename);
77
78 match fs::metadata(file) {
79 Ok(f) => {
80 if let Ok(t) = f.modified() {
81 if time.is_none() {
82 time = Some(t);
83 continue;
84 }
85
86 if let Some(current_t) = time.as_ref()
87 && t < *current_t
88 {
89 time = Some(t)
90 }
91 } else {
92 return None;
93 }
94 }
95 Err(_e) => return None,
96 }
97 }
98
99 time
100 }
101
102 pub fn load_data(&mut self) -> Result<()> {
104 let mut used_ouis = HashSet::new();
105
106 for path in &self.csv_paths {
107 Self::load_csv(&mut self.data, path, &mut used_ouis)?;
108 }
109
110 Ok(())
111 }
112
113 pub fn update(&self) -> Result<()> {
116 for data_url in DATA_URLS {
117 let file_path = self.data_dir.join(data_url.basename);
118 let data = Self::request_oui_data(data_url.url)?;
119
120 std::fs::write(&file_path, data).map_err(|e| {
121 RLanLibError::Oui(format!(
122 "failed to write oui data: {}: {}",
123 file_path.display(),
124 e,
125 ))
126 })?;
127 }
128
129 Ok(())
130 }
131
132 fn load_csv(
135 data: &mut HashMap<String, OuiData>,
136 path: &Path,
137 used_ouis: &mut HashSet<String>,
138 ) -> Result<()> {
139 let mut rdr = csv::Reader::from_path(path).map_err(|e| {
140 RLanLibError::Oui(format!(
141 "failed to load csv data: {} : {}",
142 path.display(),
143 e
144 ))
145 })?;
146
147 for result in rdr.records() {
148 let record = result.map_err(|e| {
149 RLanLibError::Oui(format!(
150 "failed to get csv record: {} : {}",
151 path.display(),
152 e
153 ))
154 })?;
155
156 let oui = clean_string(record.get(1).ok_or_else(|| {
157 RLanLibError::Oui(format!(
158 "missing OUI field in record: {}",
159 path.display()
160 ))
161 })?)
162 .to_ascii_uppercase();
163 let organization =
164 clean_string(record.get(2).ok_or_else(|| {
165 RLanLibError::Oui(format!(
166 "missing organization field in record: {}",
167 path.display()
168 ))
169 })?);
170
171 if used_ouis.contains(&oui) {
172 log::debug!("Discarding duplicate OUI: {oui}: {organization}");
173 continue;
174 }
175
176 used_ouis.insert(oui.clone());
177
178 data.insert(oui, OuiData { organization });
179 }
180
181 Ok(())
182 }
183
184 fn request_oui_data(url: &str) -> Result<String> {
186 let data = ureq::get(url)
187 .header("User-Agent", "Mozilla/5.0 (compatible; r-lanscan)")
188 .call()
189 .map_err(|e| {
190 RLanLibError::Oui(format!(
191 "failed to request oui data from {url}: {}",
192 e
193 ))
194 })?
195 .body_mut()
196 .read_to_string()
197 .map_err(|e| {
198 RLanLibError::Oui(format!(
199 "failed to read oui response body from {url}: {}",
200 e
201 ))
202 })?;
203
204 Ok(data)
205 }
206}
207
208impl Oui for OuiDb {
209 fn lookup(&self, mac: MacAddr) -> Option<OuiData> {
222 let mut result: Option<OuiData> = None;
223
224 let mac_str =
225 mac.to_string().to_ascii_uppercase().replace([':', '-'], "");
226
227 if mac_str.len() >= 9 {
229 result = self.data.get(&mac_str[..9]).cloned();
230 }
231
232 if mac_str.len() >= 7 {
234 result = result.or_else(|| self.data.get(&mac_str[..7]).cloned());
235 }
236
237 if mac_str.len() >= 6 {
239 result = result.or_else(|| self.data.get(&mac_str[..6]).cloned());
240 }
241
242 result
243 }
244}
245
246#[cfg(test)]
247#[path = "./db_tests.rs"]
248mod tests;