Skip to main content

mib_rs/
source.rs

1//! MIB source implementations for the loading pipeline.
2//!
3//! A [`Source`] provides access to MIB file content by module name. The library
4//! ships with directory-tree, in-memory, and chained multi-source
5//! implementations.
6
7use std::collections::{HashMap, HashSet};
8use std::io;
9use std::path::{Path, PathBuf};
10
11use tracing::debug;
12
13/// Default file extensions recognized as MIB files.
14///
15/// The empty string matches files with no extension (e.g., `IF-MIB`).
16pub const DEFAULT_EXTENSIONS: &[&str] = &["", ".mib", ".smi", ".txt", ".my"];
17
18/// The content and location of a found MIB file.
19///
20/// Returned by [`Source::find`] when a module is located.
21pub struct FindResult {
22    /// Raw file content (bytes, not necessarily UTF-8).
23    pub content: Vec<u8>,
24    /// Path used in diagnostic messages to identify the source.
25    ///
26    /// For on-disk sources this is the absolute file path. For in-memory
27    /// sources it is a synthetic label like `<memory:MY-MIB>`.
28    pub path: PathBuf,
29}
30
31/// Provides access to MIB files for the loading pipeline.
32///
33/// Implementations must be `Send + Sync` to support parallel loading.
34/// The library ships several constructors:
35///
36/// - [`file()`] / [`files()`] - individual files on disk
37/// - [`dir`] / [`dir_with_config`] - directory tree on disk
38/// - [`dirs()`] - multiple directory trees combined
39/// - [`memory`] / [`memory_modules`] - in-memory content
40/// - [`chain`] - combine arbitrary sources in priority order
41pub trait Source: Send + Sync {
42    /// Look up a module by name and return its content and source path.
43    ///
44    /// Returns `Ok(None)` if this source does not contain the named module.
45    /// The `name` parameter is the MIB module name (e.g. `"IF-MIB"`), not a
46    /// filename.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`io::Error`] if the underlying storage cannot be read (e.g.
51    /// file I/O failure, permission denied).
52    fn find(&self, name: &str) -> io::Result<Option<FindResult>>;
53
54    /// List all module names available from this source.
55    ///
56    /// The returned names should match what [`find`](Source::find) accepts.
57    /// Callers use this to discover modules when no explicit module list is
58    /// provided to the loader.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`io::Error`] if listing fails (e.g. directory read error).
63    fn list_modules(&self) -> io::Result<Vec<String>>;
64}
65
66/// Configuration for directory-based [`Source`] file matching.
67///
68/// Controls which file extensions are recognized as MIB files during
69/// directory indexing. Use [`SourceConfig::default`] for the standard
70/// set ([`DEFAULT_EXTENSIONS`]).
71///
72/// # Examples
73///
74/// ```
75/// let config = mib_rs::source::SourceConfig::default()
76///     .with_extensions(&[".mib", ".txt"]);
77/// ```
78#[derive(Clone)]
79pub struct SourceConfig {
80    extensions: Vec<String>,
81}
82
83impl Default for SourceConfig {
84    fn default() -> Self {
85        SourceConfig {
86            extensions: DEFAULT_EXTENSIONS.iter().map(|s| s.to_string()).collect(),
87        }
88    }
89}
90
91impl SourceConfig {
92    /// Override the default file extensions used to match MIB files.
93    ///
94    /// Extensions are normalized to lowercase with a leading dot.
95    /// An empty string (`""`) matches files with no extension (e.g. `IF-MIB`).
96    pub fn with_extensions(mut self, exts: &[&str]) -> Self {
97        self.extensions = exts
98            .iter()
99            .map(|ext| {
100                let ext = ext.to_lowercase();
101                if !ext.is_empty() && !ext.starts_with('.') {
102                    format!(".{ext}")
103                } else {
104                    ext
105                }
106            })
107            .collect();
108        self
109    }
110}
111
112/// A source backed by a directory tree on disk.
113/// The directory is eagerly indexed at construction time.
114struct DirSource {
115    root: PathBuf,
116    index: HashMap<String, PathBuf>,
117}
118
119/// Create a [`Source`] that recursively indexes a directory tree.
120///
121/// Module names are derived from file content (scanning for `DEFINITIONS`
122/// headers), not from filenames. When duplicate module names appear, the
123/// first file encountered wins.
124///
125/// The directory is eagerly indexed at construction time, so all file I/O
126/// for discovery happens during this call rather than during later
127/// [`Source::find`] lookups.
128///
129/// Uses [`DEFAULT_EXTENSIONS`] for file matching. For custom extensions,
130/// use [`dir_with_config`].
131///
132/// # Errors
133///
134/// Returns [`io::Error`] if `root` does not exist, is not a directory,
135/// or cannot be read.
136///
137/// # Examples
138///
139/// ```no_run
140/// let src = mib_rs::source::dir("/usr/share/snmp/mibs").unwrap();
141/// let modules = src.list_modules().unwrap();
142/// ```
143pub fn dir(root: impl AsRef<Path>) -> io::Result<Box<dyn Source>> {
144    dir_with_config(root, SourceConfig::default())
145}
146
147/// Create a [`Source`] backed by a directory tree with custom [`SourceConfig`].
148///
149/// Like [`dir`], but allows overriding file extension matching via
150/// [`SourceConfig::with_extensions`].
151///
152/// # Errors
153///
154/// Returns [`io::Error`] if `root` does not exist or is not a directory.
155pub fn dir_with_config(
156    root: impl AsRef<Path>,
157    config: SourceConfig,
158) -> io::Result<Box<dyn Source>> {
159    let root = root.as_ref();
160    let meta = std::fs::metadata(root)?;
161    if !meta.is_dir() {
162        return Err(io::Error::new(
163            io::ErrorKind::InvalidInput,
164            format!("not a directory: {}", root.display()),
165        ));
166    }
167    let index = build_tree_index(root, &config.extensions)?;
168    Ok(Box::new(DirSource {
169        root: root.to_path_buf(),
170        index,
171    }))
172}
173
174/// Create a [`Source`] that chains multiple directory trees.
175///
176/// Equivalent to calling [`dir`] on each root and combining with [`chain`].
177///
178/// # Errors
179///
180/// Returns [`io::Error`] if any root does not exist or is not a directory.
181pub fn dirs(roots: impl IntoIterator<Item = impl AsRef<Path>>) -> io::Result<Box<dyn Source>> {
182    let mut sources = Vec::new();
183    for root in roots {
184        sources.push(dir(root)?);
185    }
186    Ok(chain(sources))
187}
188
189impl Source for DirSource {
190    fn find(&self, name: &str) -> io::Result<Option<FindResult>> {
191        let rel_path = match self.index.get(name) {
192            Some(p) => p,
193            None => return Ok(None),
194        };
195        let full_path = self.root.join(rel_path);
196        let content = std::fs::read(&full_path)?;
197        Ok(Some(FindResult {
198            content,
199            path: full_path,
200        }))
201    }
202
203    fn list_modules(&self) -> io::Result<Vec<String>> {
204        let mut names: Vec<String> = self.index.keys().cloned().collect();
205        names.sort();
206        Ok(names)
207    }
208}
209
210/// A source combining multiple sources in order.
211/// Find() tries each source in order, returning the first match.
212struct MultiSource {
213    sources: Vec<Box<dyn Source>>,
214}
215
216/// Combine multiple [`Source`]s into one.
217///
218/// [`Source::find`] tries each source in order, returning the first match.
219/// [`Source::list_modules`] aggregates all sources, deduplicating by name.
220pub fn chain(sources: Vec<Box<dyn Source>>) -> Box<dyn Source> {
221    Box::new(MultiSource { sources })
222}
223
224impl Source for MultiSource {
225    fn find(&self, name: &str) -> io::Result<Option<FindResult>> {
226        for src in &self.sources {
227            match src.find(name)? {
228                Some(result) => return Ok(Some(result)),
229                None => continue,
230            }
231        }
232        Ok(None)
233    }
234
235    fn list_modules(&self) -> io::Result<Vec<String>> {
236        let mut seen = HashSet::new();
237        let mut names = Vec::new();
238        for src in &self.sources {
239            for name in src.list_modules()? {
240                if seen.insert(name.clone()) {
241                    names.push(name);
242                }
243            }
244        }
245        Ok(names)
246    }
247}
248
249/// Create a [`Source`] from a single MIB file on disk.
250///
251/// The module name is extracted from the file content by scanning for
252/// `DEFINITIONS ::=` headers, just like [`dir`] does for directory trees.
253/// The caller does not need to know or provide the module name.
254///
255/// # Errors
256///
257/// Returns [`io::Error`] if the file cannot be read or does not contain
258/// a valid module definition.
259///
260/// # Examples
261///
262/// ```no_run
263/// let src = mib_rs::source::file("/path/to/IF-MIB.mib").unwrap();
264/// assert!(src.list_modules().unwrap().contains(&"IF-MIB".to_string()));
265/// ```
266pub fn file(path: impl AsRef<Path>) -> io::Result<Box<dyn Source>> {
267    files([path])
268}
269
270/// Create a [`Source`] from multiple MIB files on disk.
271///
272/// Module names are extracted from each file's content by scanning for
273/// `DEFINITIONS ::=` headers. When duplicate module names appear across
274/// files, the first file wins.
275///
276/// # Errors
277///
278/// Returns [`io::Error`] if any file cannot be read or contains no
279/// valid module definition.
280pub fn files(paths: impl IntoIterator<Item = impl AsRef<Path>>) -> io::Result<Box<dyn Source>> {
281    let mut modules = HashMap::new();
282    for path in paths {
283        let path = path.as_ref();
284        let content = std::fs::read(path)?;
285        let names = crate::scan::scan_module_names(&content);
286        if names.is_empty() {
287            return Err(io::Error::new(
288                io::ErrorKind::InvalidData,
289                format!("no module definition found in {}", path.display()),
290            ));
291        }
292        let diag_path = path.to_path_buf();
293        for name in names {
294            modules
295                .entry(name)
296                .or_insert_with(|| (diag_path.clone(), content.clone()));
297        }
298    }
299    Ok(Box::new(MemorySource { modules }))
300}
301
302/// A source backed by in-memory byte buffers keyed by module name.
303struct MemorySource {
304    modules: HashMap<String, (PathBuf, Vec<u8>)>,
305}
306
307/// Create a [`Source`] backed by a single in-memory MIB module.
308///
309/// Useful for testing or embedding MIB text directly in code.
310///
311/// # Examples
312///
313/// ```
314/// let src = mib_rs::source::memory(
315///     "MY-MIB",
316///     b"MY-MIB DEFINITIONS ::= BEGIN END".as_slice(),
317/// );
318/// assert_eq!(src.list_modules().unwrap(), vec!["MY-MIB"]);
319/// ```
320pub fn memory(name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Box<dyn Source> {
321    memory_modules([(name.into(), bytes.into())])
322}
323
324/// Create a [`Source`] backed by multiple in-memory MIB modules.
325///
326/// Each entry is a `(name, bytes)` pair. Module names must match the
327/// `DEFINITIONS` header inside the corresponding content.
328pub fn memory_modules(
329    modules: impl IntoIterator<Item = (impl Into<String>, impl Into<Vec<u8>>)>,
330) -> Box<dyn Source> {
331    let mut map = HashMap::new();
332    for (name, bytes) in modules {
333        let name = name.into();
334        map.insert(
335            name.clone(),
336            (PathBuf::from(format!("<memory:{name}>")), bytes.into()),
337        );
338    }
339    Box::new(MemorySource { modules: map })
340}
341
342impl Source for MemorySource {
343    fn find(&self, name: &str) -> io::Result<Option<FindResult>> {
344        Ok(self.modules.get(name).map(|(path, content)| FindResult {
345            content: content.clone(),
346            path: path.clone(),
347        }))
348    }
349
350    fn list_modules(&self) -> io::Result<Vec<String>> {
351        let mut names: Vec<String> = self.modules.keys().cloned().collect();
352        names.sort();
353        Ok(names)
354    }
355}
356
357/// Build a module name -> relative path index by walking a directory tree.
358fn build_tree_index(root: &Path, extensions: &[String]) -> io::Result<HashMap<String, PathBuf>> {
359    let ext_set: HashSet<&str> = extensions.iter().map(|s| s.as_str()).collect();
360    let mut index = HashMap::new();
361
362    for entry in walkdir::WalkDir::new(root).into_iter() {
363        let entry = match entry {
364            Ok(e) => e,
365            Err(e) => {
366                debug!(
367                    target: "mib_rs::source",
368                    component = "source",
369                    reason = "walkdir_error",
370                    error = %e,
371                    "skipping directory entry",
372                );
373                continue;
374            }
375        };
376
377        if entry.file_type().is_dir() {
378            continue;
379        }
380
381        let path = entry.path();
382        if !has_valid_extension(path, &ext_set) {
383            continue;
384        }
385
386        let content = match std::fs::read(path) {
387            Ok(c) => c,
388            Err(e) => {
389                debug!(
390                    target: "mib_rs::source",
391                    component = "source",
392                    path = %path.display(),
393                    reason = "read_error",
394                    error = %e,
395                    "cannot read file",
396                );
397                continue;
398            }
399        };
400
401        let names = crate::scan::scan_module_names(&content);
402        let rel_path = path.strip_prefix(root).unwrap_or(path).to_path_buf();
403
404        for name in names {
405            index.entry(name).or_insert_with(|| rel_path.clone());
406        }
407    }
408
409    Ok(index)
410}
411
412fn has_valid_extension(path: &Path, ext_set: &HashSet<&str>) -> bool {
413    let ext = path
414        .extension()
415        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
416        .unwrap_or_default();
417    ext_set.contains(ext.as_str())
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn extension_check() {
426        let ext_set: HashSet<&str> = vec!["", ".mib", ".smi"].into_iter().collect();
427        assert!(has_valid_extension(Path::new("IF-MIB"), &ext_set));
428        assert!(has_valid_extension(Path::new("test.mib"), &ext_set));
429        assert!(has_valid_extension(Path::new("test.MIB"), &ext_set));
430        assert!(!has_valid_extension(Path::new("test.txt"), &ext_set));
431    }
432}