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