Skip to main content

mib_rs/
source.rs

1//! MIB source implementations for the loading pipeline.
2//!
3//! A [`Source`] provides access to MIB source documents 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::fmt;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use tracing::debug;
14
15use crate::lower::base_modules;
16use crate::scan;
17
18#[allow(dead_code)]
19mod document;
20
21pub use document::{
22    ByteOffset, BytePosition, Position, PositionEncoding, PositionError, SourceDocument, SourceId,
23    SourceOrigin, SourceRange, SourceRangeError, SourceSet,
24};
25
26/// Default file extensions recognized as MIB files.
27///
28/// The empty string matches files with no extension (e.g., `IF-MIB`).
29pub const DEFAULT_EXTENSIONS: &[&str] = &["", ".mib", ".smi", ".txt", ".my"];
30
31/// Identifies one physical candidate within a [`Source`] implementation.
32///
33/// Candidate identities are scoped to the source that returns them. The same
34/// identity returned for different requested module names tells the loader
35/// that both names refer to the same physical document. An identity must stay
36/// associated with the same origin and content for the duration of a load.
37#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub struct CandidateId(Arc<str>);
39
40impl CandidateId {
41    /// Create a provider-scoped candidate identity.
42    pub fn new(identity: impl Into<Arc<str>>) -> Self {
43        Self(identity.into())
44    }
45
46    /// Return the provider-local identity text.
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50
51    fn scoped(&self, scope: usize) -> Self {
52        Self::new(format!("{scope}:{}:{}", self.0.len(), self.0))
53    }
54}
55
56impl fmt::Display for CandidateId {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        self.0.fmt(formatter)
59    }
60}
61
62impl From<&str> for CandidateId {
63    fn from(identity: &str) -> Self {
64        Self::new(identity)
65    }
66}
67
68impl From<String> for CandidateId {
69    fn from(identity: String) -> Self {
70        Self::new(identity)
71    }
72}
73
74impl From<Arc<str>> for CandidateId {
75    fn from(identity: Arc<str>) -> Self {
76        Self(identity)
77    }
78}
79
80/// A physical source document advertised as a candidate for a module name.
81///
82/// Its provider-scoped [`CandidateId`], physical [`SourceOrigin`], display
83/// label, and immutable bytes are independent. In particular, custom and
84/// in-memory sources do not need to invent filesystem paths.
85#[derive(Clone, Debug)]
86pub struct SourceCandidate {
87    identity: CandidateId,
88    origin: SourceOrigin,
89    label: Arc<str>,
90    bytes: Arc<[u8]>,
91}
92
93impl SourceCandidate {
94    /// Create a source candidate.
95    pub fn new(
96        identity: impl Into<CandidateId>,
97        origin: SourceOrigin,
98        label: impl Into<Arc<str>>,
99        bytes: impl Into<Arc<[u8]>>,
100    ) -> Self {
101        Self {
102            identity: identity.into(),
103            origin,
104            label: label.into(),
105            bytes: bytes.into(),
106        }
107    }
108
109    /// Return this candidate's stable identity within its provider.
110    pub fn identity(&self) -> &CandidateId {
111        &self.identity
112    }
113
114    /// Return the physical origin of the document.
115    pub fn origin(&self) -> &SourceOrigin {
116        &self.origin
117    }
118
119    /// Return the label used to identify the document to users.
120    pub fn label(&self) -> &str {
121        &self.label
122    }
123
124    /// Return the immutable source bytes.
125    pub fn bytes(&self) -> &[u8] {
126        &self.bytes
127    }
128
129    /// Return the shared allocation containing the immutable source bytes.
130    pub fn shared_bytes(&self) -> &Arc<[u8]> {
131        &self.bytes
132    }
133
134    fn with_scoped_identity(mut self, scope: usize) -> Self {
135        self.identity = self.identity.scoped(scope);
136        self
137    }
138}
139
140/// Provides access to MIB files for the loading pipeline.
141///
142/// Implementations must be `Send + Sync` to support parallel loading.
143/// The library ships several constructors:
144///
145/// - [`file()`] / [`files()`] - individual files on disk
146/// - [`dir`] / [`dir_with_config`] - directory tree on disk
147/// - [`dirs()`] - multiple directory trees combined
148/// - [`memory`] / [`memory_modules`] - in-memory content
149/// - [`chain`] - combine arbitrary sources in priority order
150pub trait Source: Send + Sync {
151    /// Look up a module by name and return its first document candidate.
152    ///
153    /// Returns `Ok(None)` if this source does not contain the named module.
154    /// The `name` parameter is the MIB module name (e.g. `"IF-MIB"`), not a
155    /// filename.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`io::Error`] if the underlying storage cannot be read (for
160    /// example, a file I/O failure or permission denial).
161    fn find(&self, name: &str) -> io::Result<Option<SourceCandidate>>;
162
163    /// Iterate over candidates for a module name in precedence order.
164    ///
165    /// Candidates and their I/O errors are produced lazily. This lets callers
166    /// stop after validating an earlier candidate without accessing lower
167    /// priority storage. Each candidate supplies a stable provider-scoped
168    /// identity; returning the same identity for multiple requested names
169    /// associates those names with one physical document. Custom sources that
170    /// expose at most one candidate can rely on this default implementation.
171    fn find_candidates<'a>(
172        &'a self,
173        name: &'a str,
174    ) -> Box<dyn Iterator<Item = io::Result<SourceCandidate>> + 'a> {
175        Box::new(
176            std::iter::once_with(move || self.find(name)).filter_map(|result| result.transpose()),
177        )
178    }
179
180    /// List all module names available from this source.
181    ///
182    /// The returned names should match what [`find`](Source::find) accepts.
183    /// Callers use this to discover modules when no explicit module list is
184    /// provided to the loader.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`io::Error`] if listing fails (e.g. directory read error).
189    fn list_modules(&self) -> io::Result<Vec<String>>;
190}
191
192/// A final fallback source for the embedded SMI foundation modules.
193pub(crate) struct EmbeddedSource;
194
195impl Source for EmbeddedSource {
196    fn find(&self, name: &str) -> io::Result<Option<SourceCandidate>> {
197        Ok(base_modules::embedded_content(name).map(|content| {
198            SourceCandidate::new(
199                name,
200                SourceOrigin::embedded(name),
201                format!("embedded:{name}"),
202                Arc::<[u8]>::from(content),
203            )
204        }))
205    }
206
207    fn list_modules(&self) -> io::Result<Vec<String>> {
208        Ok(base_modules::base_module_names()
209            .iter()
210            .map(|name| (*name).to_string())
211            .collect())
212    }
213}
214
215/// Configuration for directory-based [`Source`] file matching.
216///
217/// Controls which file extensions are recognized as MIB files during
218/// directory indexing. Use [`SourceConfig::default`] for the standard
219/// set ([`DEFAULT_EXTENSIONS`]).
220///
221/// # Examples
222///
223/// ```
224/// let config = mib_rs::source::SourceConfig::default()
225///     .with_extensions(&[".mib", ".txt"]);
226/// ```
227#[derive(Clone)]
228pub struct SourceConfig {
229    extensions: Vec<String>,
230}
231
232impl Default for SourceConfig {
233    fn default() -> Self {
234        SourceConfig {
235            extensions: DEFAULT_EXTENSIONS.iter().map(|s| s.to_string()).collect(),
236        }
237    }
238}
239
240impl SourceConfig {
241    /// Override the default file extensions used to match MIB files.
242    ///
243    /// Extensions are normalized to lowercase with a leading dot.
244    /// An empty string (`""`) matches files with no extension (e.g. `IF-MIB`).
245    pub fn with_extensions(mut self, exts: &[&str]) -> Self {
246        self.extensions = exts
247            .iter()
248            .map(|ext| {
249                let ext = ext.to_lowercase();
250                if !ext.is_empty() && !ext.starts_with('.') {
251                    format!(".{ext}")
252                } else {
253                    ext
254                }
255            })
256            .collect();
257        self
258    }
259}
260
261/// A source backed by a directory tree on disk.
262/// The directory is eagerly indexed at construction time.
263struct DirSource {
264    root: PathBuf,
265    index: HashMap<String, Vec<(usize, PathBuf)>>,
266}
267
268/// Create a [`Source`] that recursively indexes a directory tree.
269///
270/// Module names are derived from file content (scanning for `DEFINITIONS`
271/// headers), not from filenames. When duplicate module names appear, their
272/// files are retained in traversal order and validated when loaded.
273///
274/// The directory is eagerly indexed at construction time, so all file I/O
275/// for discovery happens during this call rather than during later
276/// [`Source::find`] lookups.
277///
278/// Uses [`DEFAULT_EXTENSIONS`] for file matching. For custom extensions,
279/// use [`dir_with_config`].
280///
281/// # Errors
282///
283/// Returns [`io::Error`] if `root` does not exist, is not a directory,
284/// or cannot be read.
285///
286/// # Examples
287///
288/// ```no_run
289/// let src = mib_rs::source::dir("/usr/share/snmp/mibs").unwrap();
290/// let modules = src.list_modules().unwrap();
291/// ```
292pub fn dir(root: impl AsRef<Path>) -> io::Result<Box<dyn Source>> {
293    dir_with_config(root, SourceConfig::default())
294}
295
296/// Create a [`Source`] backed by a directory tree with custom [`SourceConfig`].
297///
298/// Like [`dir`], but allows overriding file extension matching via
299/// [`SourceConfig::with_extensions`].
300///
301/// # Errors
302///
303/// Returns [`io::Error`] if `root` does not exist or is not a directory.
304pub fn dir_with_config(
305    root: impl AsRef<Path>,
306    config: SourceConfig,
307) -> io::Result<Box<dyn Source>> {
308    let root = root.as_ref();
309    let meta = std::fs::metadata(root)?;
310    if !meta.is_dir() {
311        return Err(io::Error::new(
312            io::ErrorKind::InvalidInput,
313            format!("not a directory: {}", root.display()),
314        ));
315    }
316    let index = build_tree_index(root, &config.extensions)?;
317    Ok(Box::new(DirSource {
318        root: root.to_path_buf(),
319        index,
320    }))
321}
322
323/// Create a [`Source`] that chains multiple directory trees.
324///
325/// Equivalent to calling [`dir`] on each root and combining with [`chain`].
326///
327/// # Errors
328///
329/// Returns [`io::Error`] if any root does not exist or is not a directory.
330pub fn dirs(roots: impl IntoIterator<Item = impl AsRef<Path>>) -> io::Result<Box<dyn Source>> {
331    let mut sources = Vec::new();
332    for root in roots {
333        sources.push(dir(root)?);
334    }
335    Ok(chain(sources))
336}
337
338impl Source for DirSource {
339    fn find(&self, name: &str) -> io::Result<Option<SourceCandidate>> {
340        self.find_candidates(name).next().transpose()
341    }
342
343    fn find_candidates<'a>(
344        &'a self,
345        name: &'a str,
346    ) -> Box<dyn Iterator<Item = io::Result<SourceCandidate>> + 'a> {
347        let entries = self.index.get(name).into_iter().flatten();
348        Box::new(entries.filter_map(move |(document_index, rel_path)| {
349            let full_path = self.root.join(rel_path);
350            let content = match std::fs::read(&full_path) {
351                Ok(content) => content,
352                Err(error) => return Some(Err(error)),
353            };
354            // The eagerly built index can become stale if a file changes
355            // before loading. Discard stale candidates without hiding later
356            // files indexed under the same module name.
357            scan::scan_module_names(&content)
358                .iter()
359                .any(|candidate| candidate == name)
360                .then_some(Ok(SourceCandidate::new(
361                    document_index.to_string(),
362                    SourceOrigin::file(full_path.clone()),
363                    full_path.to_string_lossy().into_owned(),
364                    Arc::<[u8]>::from(content),
365                )))
366        }))
367    }
368
369    fn list_modules(&self) -> io::Result<Vec<String>> {
370        let mut names: Vec<String> = self.index.keys().cloned().collect();
371        names.sort();
372        Ok(names)
373    }
374}
375
376/// A source combining multiple sources in order.
377/// Find() tries each source in order, returning the first match.
378struct MultiSource {
379    sources: Vec<Box<dyn Source>>,
380}
381
382/// Combine multiple [`Source`]s into one.
383///
384/// [`Source::find`] tries each source in order, returning the first match.
385/// [`Source::find_candidates`] retains every child candidate in child order so
386/// loaders can continue after an advertisement fails decode validation.
387/// [`Source::list_modules`] aggregates all sources, deduplicating by name.
388pub fn chain(sources: Vec<Box<dyn Source>>) -> Box<dyn Source> {
389    Box::new(MultiSource { sources })
390}
391
392impl Source for MultiSource {
393    fn find(&self, name: &str) -> io::Result<Option<SourceCandidate>> {
394        for (index, src) in self.sources.iter().enumerate() {
395            match src.find(name)? {
396                Some(result) => return Ok(Some(result.with_scoped_identity(index))),
397                None => continue,
398            }
399        }
400        Ok(None)
401    }
402
403    fn find_candidates<'a>(
404        &'a self,
405        name: &'a str,
406    ) -> Box<dyn Iterator<Item = io::Result<SourceCandidate>> + 'a> {
407        Box::new(
408            self.sources
409                .iter()
410                .enumerate()
411                .flat_map(move |(index, source)| {
412                    source.find_candidates(name).map(move |candidate| {
413                        candidate.map(|item| item.with_scoped_identity(index))
414                    })
415                }),
416        )
417    }
418
419    fn list_modules(&self) -> io::Result<Vec<String>> {
420        let mut seen = HashSet::new();
421        let mut names = Vec::new();
422        for src in &self.sources {
423            for name in src.list_modules()? {
424                if seen.insert(name.clone()) {
425                    names.push(name);
426                }
427            }
428        }
429        Ok(names)
430    }
431}
432
433/// Create a [`Source`] from a single MIB file on disk.
434///
435/// The module name is extracted from the file content by scanning for
436/// `DEFINITIONS ::=` headers, just like [`dir`] does for directory trees.
437/// The caller does not need to know or provide the module name.
438///
439/// # Errors
440///
441/// Returns [`io::Error`] if the file cannot be read or does not contain
442/// a valid module definition.
443///
444/// # Examples
445///
446/// ```no_run
447/// let src = mib_rs::source::file("/path/to/IF-MIB.mib").unwrap();
448/// assert!(src.list_modules().unwrap().contains(&"IF-MIB".to_string()));
449/// ```
450pub fn file(path: impl AsRef<Path>) -> io::Result<Box<dyn Source>> {
451    files([path])
452}
453
454/// Create a [`Source`] from multiple MIB files on disk.
455///
456/// Module names are extracted from each file's content by scanning for
457/// `DEFINITIONS ::=` headers. Duplicate module names retain all files in input
458/// order so the loader can validate candidates before applying precedence.
459///
460/// Files without a loadable module header are skipped so they cannot hide a
461/// valid later path.
462///
463/// # Errors
464///
465/// Returns [`io::Error`] if any file cannot be read, or if none of the files
466/// contain a valid module definition.
467pub fn files(paths: impl IntoIterator<Item = impl AsRef<Path>>) -> io::Result<Box<dyn Source>> {
468    let mut modules = HashMap::new();
469    let mut documents = Vec::new();
470    let mut first_path = None;
471    for path in paths {
472        let path = path.as_ref();
473        first_path.get_or_insert_with(|| path.to_path_buf());
474        let content = std::fs::read(path)?;
475        let names = crate::scan::scan_module_names(&content);
476        let document_index = documents.len();
477        let diag_path = path.to_path_buf();
478        documents.push((diag_path.clone(), Arc::<[u8]>::from(content)));
479        for name in names {
480            modules
481                .entry(name)
482                .or_insert_with(Vec::new)
483                .push(document_index);
484        }
485    }
486    if modules.is_empty() {
487        let location = first_path
488            .map(|path| path.display().to_string())
489            .unwrap_or_else(|| "file list".to_string());
490        return Err(io::Error::new(
491            io::ErrorKind::InvalidData,
492            format!("no module definition found in {location}"),
493        ));
494    }
495    Ok(Box::new(FileSource { modules, documents }))
496}
497
498/// A source backed by file contents grouped by advertised module name.
499struct FileSource {
500    modules: HashMap<String, Vec<usize>>,
501    documents: Vec<(PathBuf, Arc<[u8]>)>,
502}
503
504impl Source for FileSource {
505    fn find(&self, name: &str) -> io::Result<Option<SourceCandidate>> {
506        self.find_candidates(name).next().transpose()
507    }
508
509    fn find_candidates<'a>(
510        &'a self,
511        name: &'a str,
512    ) -> Box<dyn Iterator<Item = io::Result<SourceCandidate>> + 'a> {
513        Box::new(
514            self.modules
515                .get(name)
516                .into_iter()
517                .flatten()
518                .map(|&document_index| {
519                    let (path, bytes) = &self.documents[document_index];
520                    Ok(SourceCandidate::new(
521                        document_index.to_string(),
522                        SourceOrigin::file(path.clone()),
523                        path.to_string_lossy().into_owned(),
524                        Arc::clone(bytes),
525                    ))
526                }),
527        )
528    }
529
530    fn list_modules(&self) -> io::Result<Vec<String>> {
531        let mut names: Vec<String> = self.modules.keys().cloned().collect();
532        names.sort();
533        Ok(names)
534    }
535}
536
537/// A source backed by in-memory byte buffers keyed by module name.
538struct MemorySource {
539    modules: HashMap<String, Arc<[u8]>>,
540}
541
542/// Create a [`Source`] backed by a single in-memory MIB module.
543///
544/// Useful for testing or embedding MIB text directly in code.
545///
546/// # Examples
547///
548/// ```
549/// let src = mib_rs::source::memory(
550///     "MY-MIB",
551///     b"MY-MIB DEFINITIONS ::= BEGIN END".as_slice(),
552/// );
553/// assert_eq!(src.list_modules().unwrap(), vec!["MY-MIB"]);
554/// ```
555pub fn memory(name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Box<dyn Source> {
556    memory_modules([(name.into(), bytes.into())])
557}
558
559/// Create a [`Source`] backed by multiple in-memory MIB modules.
560///
561/// Each entry is a `(name, bytes)` pair. Module names must match the
562/// `DEFINITIONS` header inside the corresponding content.
563pub fn memory_modules(
564    modules: impl IntoIterator<Item = (impl Into<String>, impl Into<Vec<u8>>)>,
565) -> Box<dyn Source> {
566    let mut map = HashMap::new();
567    for (name, bytes) in modules {
568        let name = name.into();
569        map.insert(name, Arc::from(bytes.into()));
570    }
571    Box::new(MemorySource { modules: map })
572}
573
574impl Source for MemorySource {
575    fn find(&self, name: &str) -> io::Result<Option<SourceCandidate>> {
576        Ok(self.modules.get(name).map(|bytes| {
577            SourceCandidate::new(
578                name,
579                SourceOrigin::memory(name),
580                format!("<memory:{name}>"),
581                Arc::clone(bytes),
582            )
583        }))
584    }
585
586    fn list_modules(&self) -> io::Result<Vec<String>> {
587        let mut names: Vec<String> = self.modules.keys().cloned().collect();
588        names.sort();
589        Ok(names)
590    }
591}
592
593/// Build a module name -> relative path index by walking a directory tree.
594fn build_tree_index(
595    root: &Path,
596    extensions: &[String],
597) -> io::Result<HashMap<String, Vec<(usize, PathBuf)>>> {
598    let ext_set: HashSet<&str> = extensions.iter().map(|s| s.as_str()).collect();
599    let mut index: HashMap<String, Vec<(usize, PathBuf)>> = HashMap::new();
600    let mut document_index = 0;
601
602    for entry in walkdir::WalkDir::new(root).into_iter() {
603        let entry = match entry {
604            Ok(e) => e,
605            Err(e) => {
606                debug!(
607                    target: "mib_rs::source",
608                    component = "source",
609                    reason = "walkdir_error",
610                    error = %e,
611                    "skipping directory entry",
612                );
613                continue;
614            }
615        };
616
617        if entry.file_type().is_dir() {
618            continue;
619        }
620
621        let path = entry.path();
622        if !has_valid_extension(path, &ext_set) {
623            continue;
624        }
625
626        let content = match std::fs::read(path) {
627            Ok(c) => c,
628            Err(e) => {
629                debug!(
630                    target: "mib_rs::source",
631                    component = "source",
632                    path = %path.display(),
633                    reason = "read_error",
634                    error = %e,
635                    "cannot read file",
636                );
637                continue;
638            }
639        };
640
641        let names = crate::scan::scan_module_names(&content);
642        let rel_path = path.strip_prefix(root).unwrap_or(path).to_path_buf();
643
644        for name in names {
645            index
646                .entry(name)
647                .or_default()
648                .push((document_index, rel_path.clone()));
649        }
650        document_index += 1;
651    }
652
653    Ok(index)
654}
655
656fn has_valid_extension(path: &Path, ext_set: &HashSet<&str>) -> bool {
657    let ext = path
658        .extension()
659        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
660        .unwrap_or_default();
661    ext_set.contains(ext.as_str())
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    #[test]
669    fn extension_check() {
670        let ext_set: HashSet<&str> = vec!["", ".mib", ".smi"].into_iter().collect();
671        assert!(has_valid_extension(Path::new("IF-MIB"), &ext_set));
672        assert!(has_valid_extension(Path::new("test.mib"), &ext_set));
673        assert!(has_valid_extension(Path::new("test.MIB"), &ext_set));
674        assert!(!has_valid_extension(Path::new("test.txt"), &ext_set));
675    }
676
677    #[test]
678    fn candidate_retains_shared_bytes_and_independent_metadata() {
679        let bytes: Arc<[u8]> = Arc::from(&b"contents"[..]);
680        let candidate = SourceCandidate::new(
681            "document-42",
682            SourceOrigin::custom("workspace", "buffer-42"),
683            "ACME-MIB (modified)",
684            Arc::clone(&bytes),
685        );
686
687        assert_eq!(candidate.identity().as_str(), "document-42");
688        assert_eq!(
689            candidate.origin(),
690            &SourceOrigin::custom("workspace", "buffer-42")
691        );
692        assert_eq!(candidate.label(), "ACME-MIB (modified)");
693        assert_eq!(candidate.bytes().as_ptr(), bytes.as_ptr());
694        assert!(Arc::ptr_eq(candidate.shared_bytes(), &bytes));
695    }
696
697    #[test]
698    fn built_in_non_file_sources_use_typed_origins() {
699        let memory = memory("DISPLAY-NAME", b"bytes".as_slice());
700        let memory_candidate = memory.find("DISPLAY-NAME").unwrap().unwrap();
701        assert_eq!(
702            memory_candidate.origin(),
703            &SourceOrigin::memory("DISPLAY-NAME")
704        );
705        assert_eq!(memory_candidate.label(), "<memory:DISPLAY-NAME>");
706
707        let embedded = EmbeddedSource
708            .find("SNMPv2-SMI")
709            .unwrap()
710            .expect("embedded module");
711        assert_eq!(embedded.origin(), &SourceOrigin::embedded("SNMPv2-SMI"));
712        assert_eq!(embedded.label(), "embedded:SNMPv2-SMI");
713    }
714}