Skip to main content

r_lanlib/oui/
db.rs

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
17/// IEEE OUI CSV data sources indexed by assignment type.
18const 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
41/// Normalises a raw CSV field value by replacing non-breaking spaces
42/// and trimming whitespace.
43fn clean_string(s: &str) -> String {
44    s.replace('\u{00A0}', " ").trim().to_string()
45}
46
47/// In-memory OUI database backed by locally cached IEEE CSV files.
48pub struct OuiDb {
49    data_dir: PathBuf,
50    csv_paths: Vec<PathBuf>,
51    data: HashMap<String, OuiData>,
52}
53
54impl OuiDb {
55    /// Creates a new `OuiDb` pointing at `data_dir` for cached CSV files.
56    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    /// Returns the modification time of the oldest cached CSV file,
71    /// or `None` if any file is missing or its mtime is unavailable.
72    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    /// Loads all cached CSV files into the in-memory lookup table.
103    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    /// Downloads fresh OUI data from all IEEE sources and writes them to
114    /// `data_dir`.
115    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    /// Parses a single OUI CSV file and inserts records into `data`,
133    /// skipping any duplicate OUI prefixes already present in `used_ouis`.
134    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    /// Fetches raw CSV text from the given IEEE URL.
185    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    /// Retrieve the OUI record for a given MAC address.
210    ///
211    /// IEEE assigns OUI prefixes at three granularities:
212    ///
213    /// - **MA-L** (MAC Address Large, `oui.csv`): 24-bit prefix → 6 hex chars
214    /// - **MA-M** (MAC Address Medium, `mam.csv`): 28-bit prefix → 7 hex chars
215    /// - **MA-S** (MAC Address Small, `oui36.csv`/`iab.csv`): 36-bit prefix
216    ///   → 9 hex chars
217    ///
218    /// We try the most-specific prefix first so that a narrower assignment
219    /// (e.g. MA-S) takes precedence over a broader one (e.g. MA-L) for the
220    /// same MAC address.
221    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        // MA-S: 36-bit / 9 hex chars
228        if mac_str.len() >= 9 {
229            result = self.data.get(&mac_str[..9]).cloned();
230        }
231
232        // MA-M: 28-bit / 7 hex chars
233        if mac_str.len() >= 7 {
234            result = result.or_else(|| self.data.get(&mac_str[..7]).cloned());
235        }
236
237        // MA-L: 24-bit / 6 hex chars
238        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;