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}