Skip to main content

async_snmp/agent/
vacm.rs

1//! View-based Access Control Model (RFC 3415).
2//!
3//! VACM controls access to MIB objects based on who is making the request
4//! and what they are trying to access. It implements fine-grained access control
5//! through a three-table architecture.
6//!
7//! # Overview
8//!
9//! VACM (View-based Access Control Model) is the standard access control mechanism
10//! for `SNMPv3`, though it can also be used with SNMPv1/v2c. It answers the question:
11//! "Can this user perform this operation on this OID?"
12//!
13//! # Architecture
14//!
15//! VACM controls access through three tables:
16//!
17//! 1. **Security-to-Group Table**: Maps (securityModel, securityName) to groupName.
18//!    This groups users/communities with similar access rights.
19//!
20//! 2. **Access Table**: Maps (groupName, contextPrefix, securityModel, securityLevel)
21//!    to view names for read, write, and notify operations.
22//!
23//! 3. **View Tree Family Table**: Defines views as collections of OID subtrees,
24//!    with optional inclusion/exclusion and wildcard masks.
25//!
26//! # Basic Example
27//!
28//! Configure read-only access for "public" community:
29//!
30//! ```rust
31//! use async_snmp::agent::{Agent, SecurityModel, VacmBuilder};
32//! use async_snmp::oid;
33//!
34//! # fn example() {
35//! let vacm = VacmBuilder::new()
36//!     // Map "public" community to "readonly_group"
37//!     .group("public", SecurityModel::V2c, "readonly_group")
38//!     // Grant read access to full_view
39//!     .access("readonly_group", |a| a.read_view("full_view"))
40//!     // Define what OIDs are in full_view
41//!     .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
42//!     .build();
43//! # }
44//! ```
45//!
46//! # Read/Write Access Example
47//!
48//! Configure different access levels for different users:
49//!
50//! ```rust
51//! use async_snmp::agent::{Agent, SecurityModel, VacmBuilder};
52//! use async_snmp::message::SecurityLevel;
53//! use async_snmp::oid;
54//!
55//! # fn example() {
56//! let vacm = VacmBuilder::new()
57//!     // Read-only community
58//!     .group("public", SecurityModel::V2c, "readers")
59//!     // Read-write community
60//!     .group("private", SecurityModel::V2c, "writers")
61//!     // SNMPv3 admin user
62//!     .group("admin", SecurityModel::Usm, "admins")
63//!
64//!     // Readers can only read
65//!     .access("readers", |a| a
66//!         .read_view("system_view"))
67//!
68//!     // Writers can read everything and write to ifAdminStatus
69//!     .access("writers", |a| a
70//!         .read_view("full_view")
71//!         .write_view("if_admin_view"))
72//!
73//!     // Admins require encryption and can read/write everything
74//!     .access("admins", |a| a
75//!         .security_model(SecurityModel::Usm)
76//!         .security_level(SecurityLevel::AuthPriv)
77//!         .read_view("full_view")
78//!         .write_view("full_view"))
79//!
80//!     // Define views
81//!     .view("system_view", |v| v
82//!         .include(oid!(1, 3, 6, 1, 2, 1, 1)))  // system MIB only
83//!     .view("full_view", |v| v
84//!         .include(oid!(1, 3, 6, 1)))           // everything
85//!     .view("if_admin_view", |v| v
86//!         .include(oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 7)))  // ifAdminStatus
87//!     .build();
88//! # }
89//! ```
90//!
91//! # View Exclusions
92//!
93//! Views can exclude specific subtrees from a broader include:
94//!
95//! ```rust
96//! use async_snmp::agent::View;
97//! use async_snmp::oid;
98//!
99//! // Include all of system MIB except sysServices
100//! let view = View::new()
101//!     .include(oid!(1, 3, 6, 1, 2, 1, 1))        // system MIB
102//!     .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7));    // except sysServices
103//!
104//! assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));   // sysDescr.0 - allowed
105//! assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7, 0)));  // sysServices.0 - blocked
106//! ```
107//!
108//! # Wildcard Masks
109//!
110//! Masks allow matching OIDs with wildcards at specific positions:
111//!
112//! ```rust
113//! use async_snmp::agent::ViewSubtree;
114//! use async_snmp::oid;
115//!
116//! // Match ifDescr for any interface index (ifDescr.*)
117//! // OID: 1.3.6.1.2.1.2.2.1.2 (10 arcs, indices 0-9)
118//! // Mask: 0xFF 0xC0 = 11111111 11000000 (arcs 0-9 must match, 10+ wildcard)
119//! let subtree = ViewSubtree {
120//!     oid: oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2),  // ifDescr
121//!     mask: vec![0xFF, 0xC0],
122//!     included: true,
123//! };
124//!
125//! // Matches any interface index
126//! assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)));    // ifDescr.1
127//! assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 100)));  // ifDescr.100
128//!
129//! // Does not match different columns
130//! assert!(!subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 3, 1)));   // ifType.1
131//! ```
132//!
133//! # Integration with Agent
134//!
135//! Use [`AgentBuilder::vacm()`](super::AgentBuilder::vacm) to configure VACM:
136//!
137//! ```rust,no_run
138//! use async_snmp::agent::{Agent, SecurityModel};
139//! use async_snmp::oid;
140//!
141//! # async fn example() -> Result<(), Box<async_snmp::Error>> {
142//! let agent = Agent::builder()
143//!     .bind("0.0.0.0:161")
144//!     .community(b"public")
145//!     .community(b"private")
146//!     .vacm(|v| v
147//!         .group("public", SecurityModel::V2c, "readonly")
148//!         .group("private", SecurityModel::V2c, "readwrite")
149//!         .access("readonly", |a| a.read_view("all"))
150//!         .access("readwrite", |a| a.read_view("all").write_view("all"))
151//!         .view("all", |v| v.include(oid!(1, 3, 6, 1))))
152//!     .build()
153//!     .await?;
154//! # Ok(())
155//! # }
156//! ```
157//!
158//! # Access Denied Behavior
159//!
160//! When VACM denies access:
161//! - **`SNMPv1`**: Returns `noSuchName` error
162//! - **SNMPv2c/v3 GET**: Returns `noAccess` error or `NoSuchObject` per RFC 3416
163//! - **SNMPv2c/v3 SET**: Returns `noAccess` error
164
165use std::collections::HashMap;
166
167use bytes::Bytes;
168
169use crate::message::SecurityLevel;
170use crate::oid::Oid;
171
172pub use crate::handler::SecurityModel;
173
174/// Context matching mode for access entries.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub(crate) enum ContextMatch {
177    /// Exact context name match.
178    #[default]
179    Exact,
180    /// Context name prefix match.
181    Prefix,
182}
183
184/// Result of checking whether a subtree is in a view.
185///
186/// This 3-state result enables optimizations for GETBULK/GETNEXT operations
187/// by distinguishing between definite inclusion, definite exclusion, and
188/// mixed/ambiguous subtrees that require per-OID checking.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum ViewCheckResult {
191    /// The queried OID and all its descendants are definitely in the view.
192    Included,
193    /// The queried OID and all its descendants are definitely not in the view.
194    Excluded,
195    /// The subtree has mixed permissions - some descendants are included,
196    /// others are excluded. Per-OID access checks are required.
197    Ambiguous,
198}
199
200/// A view is a collection of OID subtrees defining accessible objects.
201///
202/// Views are used by VACM to determine which OIDs a user can access.
203/// Each view consists of included and/or excluded subtrees.
204///
205/// # Example
206///
207/// ```rust
208/// use async_snmp::agent::View;
209/// use async_snmp::oid;
210///
211/// // Create a view that includes the system MIB but excludes sysContact
212/// let view = View::new()
213///     .include(oid!(1, 3, 6, 1, 2, 1, 1))        // system MIB
214///     .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 4));    // sysContact
215///
216/// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));   // sysDescr.0
217/// assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 4, 0)));  // sysContact.0
218/// assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 2)));        // interfaces MIB
219/// ```
220#[derive(Debug, Clone, Default)]
221pub struct View {
222    subtrees: Vec<ViewSubtree>,
223}
224
225impl View {
226    /// Create a new empty view.
227    ///
228    /// An empty view contains no OIDs. Add subtrees with [`include()`](View::include)
229    /// or [`exclude()`](View::exclude).
230    #[must_use]
231    pub fn new() -> Self {
232        Self::default()
233    }
234
235    /// Add an included subtree to the view.
236    ///
237    /// All OIDs starting with `oid` will be included in the view,
238    /// unless excluded by a later [`exclude()`](View::exclude) call.
239    ///
240    /// # Example
241    ///
242    /// ```rust
243    /// use async_snmp::agent::View;
244    /// use async_snmp::oid;
245    ///
246    /// let view = View::new()
247    ///     .include(oid!(1, 3, 6, 1, 2, 1))  // MIB-2
248    ///     .include(oid!(1, 3, 6, 1, 4, 1)); // enterprises
249    ///
250    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
251    /// assert!(view.contains(&oid!(1, 3, 6, 1, 4, 1, 99999, 1)));
252    /// ```
253    #[must_use]
254    pub fn include(mut self, oid: Oid) -> Self {
255        self.subtrees.push(ViewSubtree {
256            oid,
257            mask: Vec::new(),
258            included: true,
259        });
260        self
261    }
262
263    /// Add an included subtree with a wildcard mask.
264    ///
265    /// The mask allows wildcards at specific OID arc positions.
266    /// See [`ViewSubtree::mask`] for mask format details.
267    ///
268    /// # Example
269    ///
270    /// ```rust
271    /// use async_snmp::agent::View;
272    /// use async_snmp::oid;
273    ///
274    /// // Include ifDescr for any interface (mask makes arc 10 a wildcard)
275    /// let view = View::new()
276    ///     .include_masked(
277    ///         oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2),  // ifDescr
278    ///         vec![0xFF, 0xC0]  // First 10 arcs must match
279    ///     );
280    ///
281    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)));   // ifDescr.1
282    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 100))); // ifDescr.100
283    /// ```
284    #[must_use]
285    pub fn include_masked(mut self, oid: Oid, mask: Vec<u8>) -> Self {
286        self.subtrees.push(ViewSubtree {
287            oid,
288            mask,
289            included: true,
290        });
291        self
292    }
293
294    /// Add an excluded subtree to the view.
295    ///
296    /// OIDs starting with `oid` will be excluded, even if they match
297    /// an included subtree. Exclusions take precedence.
298    ///
299    /// # Example
300    ///
301    /// ```rust
302    /// use async_snmp::agent::View;
303    /// use async_snmp::oid;
304    ///
305    /// let view = View::new()
306    ///     .include(oid!(1, 3, 6, 1, 2, 1, 1))     // system MIB
307    ///     .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 6)); // except sysLocation
308    ///
309    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));  // sysDescr
310    /// assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 6, 0))); // sysLocation
311    /// ```
312    #[must_use]
313    pub fn exclude(mut self, oid: Oid) -> Self {
314        self.subtrees.push(ViewSubtree {
315            oid,
316            mask: Vec::new(),
317            included: false,
318        });
319        self
320    }
321
322    /// Add an excluded subtree with a wildcard mask.
323    ///
324    /// See [`include_masked()`](View::include_masked) for mask usage.
325    #[must_use]
326    pub fn exclude_masked(mut self, oid: Oid, mask: Vec<u8>) -> Self {
327        self.subtrees.push(ViewSubtree {
328            oid,
329            mask,
330            included: false,
331        });
332        self
333    }
334
335    /// Check if an OID is in this view.
336    ///
337    /// Per RFC 3415 Section 5, when multiple subtrees match an OID,
338    /// the longest matching subtree determines inclusion/exclusion.
339    /// At equal lengths, exclude wins.
340    ///
341    /// # Example
342    ///
343    /// ```rust
344    /// use async_snmp::agent::View;
345    /// use async_snmp::oid;
346    ///
347    /// let view = View::new()
348    ///     .include(oid!(1, 3, 6, 1, 2, 1))
349    ///     .exclude(oid!(1, 3, 6, 1, 2, 1, 25));  // host resources
350    ///
351    /// assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
352    /// assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 25, 1, 0)));
353    /// assert!(!view.contains(&oid!(1, 3, 6, 1, 4, 1)));  // not included
354    /// ```
355    #[must_use]
356    pub fn contains(&self, oid: &Oid) -> bool {
357        let mut best_len: Option<usize> = None;
358        let mut best_included = false;
359
360        for subtree in &self.subtrees {
361            if subtree.matches(oid) {
362                let len = subtree.oid.len();
363                match best_len {
364                    Some(prev) if len < prev => {}
365                    Some(prev) if len == prev && !subtree.included => {
366                        // Equal length: exclude wins (conservative)
367                        best_included = false;
368                    }
369                    _ => {
370                        best_len = Some(len);
371                        best_included = subtree.included;
372                    }
373                }
374            }
375        }
376
377        best_included
378    }
379
380    /// Check subtree access status with 3-state result.
381    ///
382    /// Unlike [`contains()`](View::contains) which checks a single OID,
383    /// this method determines the access status for an entire subtree.
384    /// This enables optimizations for GETBULK/GETNEXT operations.
385    ///
386    /// Returns:
387    /// - [`ViewCheckResult::Included`]: OID and all descendants are accessible
388    /// - [`ViewCheckResult::Excluded`]: OID and all descendants are not accessible
389    /// - [`ViewCheckResult::Ambiguous`]: Mixed permissions, check each OID individually
390    #[must_use]
391    pub fn check_subtree(&self, oid: &Oid) -> ViewCheckResult {
392        // Find the longest covering match (RFC 3415 longest-match semantics)
393        let mut best_covering_len: Option<usize> = None;
394        let mut best_covering_included = false;
395        let mut has_child_include = false;
396        let mut has_child_exclude = false;
397
398        let query_arcs = oid.arcs();
399
400        for subtree in &self.subtrees {
401            if subtree.matches(oid) {
402                let len = subtree.oid.len();
403                match best_covering_len {
404                    Some(prev) if len < prev => {}
405                    Some(prev) if len == prev && !subtree.included => {
406                        best_covering_included = false;
407                    }
408                    _ => {
409                        best_covering_len = Some(len);
410                        best_covering_included = subtree.included;
411                    }
412                }
413            }
414
415            let subtree_arcs = subtree.oid.arcs();
416            if subtree_arcs.len() > query_arcs.len()
417                && subtree_arcs[..query_arcs.len()] == *query_arcs
418            {
419                if subtree.included {
420                    has_child_include = true;
421                } else {
422                    has_child_exclude = true;
423                }
424            }
425        }
426
427        match (best_covering_len.is_some(), best_covering_included) {
428            (true, false) => {
429                if has_child_include {
430                    return ViewCheckResult::Ambiguous;
431                }
432                return ViewCheckResult::Excluded;
433            }
434            (true, true) => {
435                if has_child_exclude {
436                    return ViewCheckResult::Ambiguous;
437                }
438                return ViewCheckResult::Included;
439            }
440            _ => {}
441        }
442
443        if has_child_include {
444            return ViewCheckResult::Ambiguous;
445        }
446
447        ViewCheckResult::Excluded
448    }
449}
450
451/// A subtree in a view with optional mask.
452#[derive(Debug, Clone)]
453pub struct ViewSubtree {
454    /// Base OID of subtree.
455    pub oid: Oid,
456    /// Bit mask for wildcard matching (empty = exact match).
457    ///
458    /// Each bit position corresponds to an arc in the OID:
459    /// - Bit 7 (MSB) of byte 0 = arc 0
460    /// - Bit 6 of byte 0 = arc 1
461    /// - etc.
462    ///
463    /// A bit value of 1 means the arc must match exactly.
464    /// A bit value of 0 means any value is accepted (wildcard).
465    pub mask: Vec<u8>,
466    /// Include (true) or exclude (false) this subtree.
467    pub included: bool,
468}
469
470impl ViewSubtree {
471    /// Check if an OID matches this subtree (with mask).
472    #[must_use]
473    pub fn matches(&self, oid: &Oid) -> bool {
474        let subtree_arcs = self.oid.arcs();
475        let oid_arcs = oid.arcs();
476
477        // OID must be at least as long as subtree
478        if oid_arcs.len() < subtree_arcs.len() {
479            return false;
480        }
481
482        // Check each arc against mask
483        for (i, &subtree_arc) in subtree_arcs.iter().enumerate() {
484            let mask_bit = if i / 8 < self.mask.len() {
485                (self.mask[i / 8] >> (7 - (i % 8))) & 1
486            } else {
487                1 // Default: exact match required
488            };
489
490            if mask_bit == 1 && oid_arcs[i] != subtree_arc {
491                return false;
492            }
493            // mask_bit == 0: wildcard, any value matches
494        }
495
496        true
497    }
498}
499
500/// Access table entry.
501#[derive(Debug, Clone)]
502pub struct VacmAccessEntry {
503    /// Group name this entry applies to.
504    pub group_name: Bytes,
505    /// Context prefix for matching.
506    pub context_prefix: Bytes,
507    /// Security model (or Any for wildcard).
508    pub security_model: SecurityModel,
509    /// Minimum security level required.
510    pub security_level: SecurityLevel,
511    /// Context matching mode.
512    pub(crate) context_match: ContextMatch,
513    /// View name for read access.
514    pub read_view: Bytes,
515    /// View name for write access.
516    pub write_view: Bytes,
517    /// View name for notify access (traps/informs).
518    pub notify_view: Bytes,
519}
520
521/// Builder for access entries.
522///
523/// Configure what views a group can access for different operations.
524/// Typically used via [`VacmBuilder::access()`].
525///
526/// # Example
527///
528/// ```rust
529/// use async_snmp::agent::{SecurityModel, VacmBuilder};
530/// use async_snmp::message::SecurityLevel;
531/// use async_snmp::oid;
532///
533/// let vacm = VacmBuilder::new()
534///     .group("admin", SecurityModel::Usm, "admin_group")
535///     .access("admin_group", |a| a
536///         .security_model(SecurityModel::Usm)
537///         .security_level(SecurityLevel::AuthPriv)
538///         .read_view("full_view")
539///         .write_view("config_view")
540///         .notify_view("trap_view"))
541///     .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
542///     .view("config_view", |v| v.include(oid!(1, 3, 6, 1, 4, 1)))
543///     .view("trap_view", |v| v.include(oid!(1, 3, 6, 1)))
544///     .build();
545/// ```
546pub struct AccessEntryBuilder {
547    group_name: Bytes,
548    context_prefix: Bytes,
549    security_model: SecurityModel,
550    security_level: SecurityLevel,
551    context_match: ContextMatch,
552    read_view: Bytes,
553    write_view: Bytes,
554    notify_view: Bytes,
555}
556
557impl AccessEntryBuilder {
558    /// Create a new access entry builder for a group.
559    pub fn new(group_name: impl Into<Bytes>) -> Self {
560        Self {
561            group_name: group_name.into(),
562            context_prefix: Bytes::new(),
563            security_model: SecurityModel::Any,
564            security_level: SecurityLevel::NoAuthNoPriv,
565            context_match: ContextMatch::Exact,
566            read_view: Bytes::new(),
567            write_view: Bytes::new(),
568            notify_view: Bytes::new(),
569        }
570    }
571
572    /// Set the context prefix for matching.
573    ///
574    /// Context is an `SNMPv3` concept that allows partitioning MIB views.
575    /// Most deployments use an empty context (the default).
576    #[must_use]
577    pub fn context_prefix(mut self, prefix: impl Into<Bytes>) -> Self {
578        self.context_prefix = prefix.into();
579        self
580    }
581
582    /// Set the security model this entry applies to.
583    ///
584    /// Default is [`SecurityModel::Any`] which matches all models.
585    #[must_use]
586    pub fn security_model(mut self, model: SecurityModel) -> Self {
587        self.security_model = model;
588        self
589    }
590
591    /// Set the minimum security level required.
592    ///
593    /// Requests with lower security levels will be denied access.
594    /// Default is [`SecurityLevel::NoAuthNoPriv`].
595    ///
596    /// # Example
597    ///
598    /// ```rust
599    /// use async_snmp::agent::{SecurityModel, VacmBuilder};
600    /// use async_snmp::message::SecurityLevel;
601    /// use async_snmp::oid;
602    ///
603    /// let vacm = VacmBuilder::new()
604    ///     .group("admin", SecurityModel::Usm, "secure_group")
605    ///     .access("secure_group", |a| a
606    ///         // Require authentication and encryption
607    ///         .security_level(SecurityLevel::AuthPriv)
608    ///         .read_view("full_view"))
609    ///     .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
610    ///     .build();
611    /// ```
612    #[must_use]
613    pub fn security_level(mut self, level: SecurityLevel) -> Self {
614        self.security_level = level;
615        self
616    }
617
618    /// Set context matching to prefix mode.
619    ///
620    /// When enabled, the context prefix is matched against the start of
621    /// the request context name rather than requiring an exact match.
622    /// The default is exact matching.
623    #[must_use]
624    pub fn context_match_prefix(mut self) -> Self {
625        self.context_match = ContextMatch::Prefix;
626        self
627    }
628
629    /// Set the read view name.
630    ///
631    /// The view must be defined with [`VacmBuilder::view()`].
632    /// If not set, read operations are denied.
633    #[must_use]
634    pub fn read_view(mut self, view: impl Into<Bytes>) -> Self {
635        self.read_view = view.into();
636        self
637    }
638
639    /// Set the write view name.
640    ///
641    /// The view must be defined with [`VacmBuilder::view()`].
642    /// If not set, write (SET) operations are denied.
643    #[must_use]
644    pub fn write_view(mut self, view: impl Into<Bytes>) -> Self {
645        self.write_view = view.into();
646        self
647    }
648
649    /// Set the notify view name.
650    ///
651    /// Used for trap/inform generation (not access control).
652    /// The view must be defined with [`VacmBuilder::view()`].
653    #[must_use]
654    pub fn notify_view(mut self, view: impl Into<Bytes>) -> Self {
655        self.notify_view = view.into();
656        self
657    }
658
659    /// Build the access entry.
660    pub fn build(self) -> VacmAccessEntry {
661        VacmAccessEntry {
662            group_name: self.group_name,
663            context_prefix: self.context_prefix,
664            security_model: self.security_model,
665            security_level: self.security_level,
666            context_match: self.context_match,
667            read_view: self.read_view,
668            write_view: self.write_view,
669            notify_view: self.notify_view,
670        }
671    }
672}
673
674/// VACM configuration.
675#[derive(Debug, Clone, Default)]
676pub struct VacmConfig {
677    /// (securityModel, securityName) → groupName
678    security_to_group: HashMap<(SecurityModel, Bytes), Bytes>,
679    /// Access table entries.
680    access_entries: Vec<VacmAccessEntry>,
681    /// viewName → View
682    views: HashMap<Bytes, View>,
683}
684
685impl VacmConfig {
686    /// Create a new empty VACM configuration.
687    #[must_use]
688    pub fn new() -> Self {
689        Self::default()
690    }
691
692    /// Map a security name to a group for a specific security model.
693    pub fn add_group(
694        &mut self,
695        security_name: impl Into<Bytes>,
696        security_model: SecurityModel,
697        group_name: impl Into<Bytes>,
698    ) {
699        self.security_to_group
700            .insert((security_model, security_name.into()), group_name.into());
701    }
702
703    /// Add an access entry.
704    pub fn add_access(&mut self, entry: VacmAccessEntry) {
705        self.access_entries.push(entry);
706    }
707
708    /// Add a view.
709    pub fn add_view(&mut self, name: impl Into<Bytes>, view: View) {
710        self.views.insert(name.into(), view);
711    }
712
713    /// Resolve group name for a request.
714    #[must_use]
715    pub fn get_group(&self, model: SecurityModel, name: &[u8]) -> Option<&Bytes> {
716        // Try exact model match first, then fall back to Any.
717        // Iterate to avoid allocating a Bytes for the lookup key.
718        let mut any_match = None;
719        for ((entry_model, entry_name), group) in &self.security_to_group {
720            if entry_name.as_ref() == name {
721                if *entry_model == model {
722                    return Some(group);
723                } else if *entry_model == SecurityModel::Any {
724                    any_match = Some(group);
725                }
726            }
727        }
728        any_match
729    }
730
731    /// Get access entry for context.
732    ///
733    /// Returns the best matching entry per RFC 3415 Section 4 (vacmAccessTable DESCRIPTION).
734    /// Selection uses a 4-tier preference order:
735    /// 1. Prefer specific securityModel over Any
736    /// 2. Prefer exact contextMatch over prefix
737    /// 3. Prefer longer contextPrefix
738    /// 4. Prefer higher securityLevel
739    #[must_use]
740    pub fn get_access(
741        &self,
742        group: &[u8],
743        context: &[u8],
744        model: SecurityModel,
745        level: SecurityLevel,
746    ) -> Option<&VacmAccessEntry> {
747        self.access_entries
748            .iter()
749            .filter(|e| {
750                e.group_name.as_ref() == group
751                    && self.context_matches(&e.context_prefix, context, e.context_match)
752                    && (e.security_model == model || e.security_model == SecurityModel::Any)
753                    && level >= e.security_level
754            })
755            .max_by_key(|e| {
756                // RFC 3415 Section 4 preference order (tuple comparison is lexicographic)
757                let model_score: u8 = u8::from(e.security_model == model);
758                let match_score: u8 = u8::from(e.context_match == ContextMatch::Exact);
759                let prefix_len = e.context_prefix.len();
760                let level_score = e.security_level as u8;
761                (model_score, match_score, prefix_len, level_score)
762            })
763    }
764
765    /// Check if context matches the prefix.
766    fn context_matches(&self, prefix: &[u8], context: &[u8], mode: ContextMatch) -> bool {
767        match mode {
768            ContextMatch::Exact => prefix == context,
769            ContextMatch::Prefix => context.starts_with(prefix),
770        }
771    }
772
773    /// Check if OID access is permitted.
774    #[must_use]
775    pub fn check_access(&self, view_name: Option<&Bytes>, oid: &Oid) -> bool {
776        let Some(view_name) = view_name else {
777            return false;
778        };
779
780        if view_name.is_empty() {
781            return false;
782        }
783
784        let Some(view) = self.views.get(view_name) else {
785            return false;
786        };
787
788        view.contains(oid)
789    }
790}
791
792/// Builder for VACM configuration.
793///
794/// Use this to configure access control for your SNMP agent. The typical
795/// workflow is:
796///
797/// 1. Map security names (communities/usernames) to groups with [`group()`](VacmBuilder::group)
798/// 2. Define access rules for groups with [`access()`](VacmBuilder::access)
799/// 3. Define views (OID collections) with [`view()`](VacmBuilder::view)
800/// 4. Build with [`build()`](VacmBuilder::build)
801///
802/// # Example
803///
804/// ```rust
805/// use async_snmp::agent::{SecurityModel, VacmBuilder};
806/// use async_snmp::message::SecurityLevel;
807/// use async_snmp::oid;
808///
809/// let vacm = VacmBuilder::new()
810///     // Step 1: Map security names to groups
811///     .group("public", SecurityModel::V2c, "readers")
812///     .group("admin", SecurityModel::Usm, "admins")
813///
814///     // Step 2: Define access for each group
815///     .access("readers", |a| a
816///         .read_view("system_view"))
817///     .access("admins", |a| a
818///         .security_level(SecurityLevel::AuthPriv)
819///         .read_view("full_view")
820///         .write_view("full_view"))
821///
822///     // Step 3: Define views
823///     .view("system_view", |v| v
824///         .include(oid!(1, 3, 6, 1, 2, 1, 1)))
825///     .view("full_view", |v| v
826///         .include(oid!(1, 3, 6, 1)))
827///
828///     // Step 4: Build
829///     .build();
830/// ```
831pub struct VacmBuilder {
832    config: VacmConfig,
833}
834
835impl VacmBuilder {
836    /// Create a new VACM builder.
837    #[must_use]
838    pub fn new() -> Self {
839        Self {
840            config: VacmConfig::new(),
841        }
842    }
843
844    /// Map a security name to a group.
845    ///
846    /// The security name is:
847    /// - For SNMPv1/v2c: the community string
848    /// - For `SNMPv3`: the USM username
849    ///
850    /// Multiple security names can map to the same group.
851    ///
852    /// # Example
853    ///
854    /// ```rust
855    /// use async_snmp::agent::{SecurityModel, VacmBuilder};
856    ///
857    /// let vacm = VacmBuilder::new()
858    ///     // Multiple communities in same group
859    ///     .group("public", SecurityModel::V2c, "readonly")
860    ///     .group("monitor", SecurityModel::V2c, "readonly")
861    ///     // Different users in different groups
862    ///     .group("admin", SecurityModel::Usm, "admin_group")
863    ///     .build();
864    /// ```
865    #[must_use]
866    pub fn group(
867        mut self,
868        security_name: impl Into<Bytes>,
869        security_model: SecurityModel,
870        group_name: impl Into<Bytes>,
871    ) -> Self {
872        self.config
873            .add_group(security_name, security_model, group_name);
874        self
875    }
876
877    /// Add an access entry using a builder function.
878    ///
879    /// Access entries define what views a group can use for read, write,
880    /// and notify operations. Use the closure to configure the entry.
881    ///
882    /// # Example
883    ///
884    /// ```rust
885    /// use async_snmp::agent::{SecurityModel, VacmBuilder};
886    /// use async_snmp::message::SecurityLevel;
887    /// use async_snmp::oid;
888    ///
889    /// let vacm = VacmBuilder::new()
890    ///     .group("public", SecurityModel::V2c, "readers")
891    ///     .access("readers", |a| a
892    ///         .security_model(SecurityModel::V2c)
893    ///         .security_level(SecurityLevel::NoAuthNoPriv)
894    ///         .read_view("system_view")
895    ///         // No write_view = read-only
896    ///     )
897    ///     .view("system_view", |v| v.include(oid!(1, 3, 6, 1, 2, 1, 1)))
898    ///     .build();
899    /// ```
900    #[must_use]
901    pub fn access<F>(mut self, group_name: impl Into<Bytes>, configure: F) -> Self
902    where
903        F: FnOnce(AccessEntryBuilder) -> AccessEntryBuilder,
904    {
905        let builder = AccessEntryBuilder::new(group_name);
906        let entry = configure(builder).build();
907        self.config.add_access(entry);
908        self
909    }
910
911    /// Add a view using a builder function.
912    ///
913    /// Views define collections of OID subtrees. Use the closure to add
914    /// included and excluded subtrees.
915    ///
916    /// # Example
917    ///
918    /// ```rust
919    /// use async_snmp::agent::VacmBuilder;
920    /// use async_snmp::oid;
921    ///
922    /// let vacm = VacmBuilder::new()
923    ///     .view("system_only", |v| v
924    ///         .include(oid!(1, 3, 6, 1, 2, 1, 1)))  // system MIB
925    ///     .view("all_except_private", |v| v
926    ///         .include(oid!(1, 3, 6, 1))
927    ///         .exclude(oid!(1, 3, 6, 1, 4, 1, 99999)))  // exclude our enterprise
928    ///     .build();
929    /// ```
930    #[must_use]
931    pub fn view<F>(mut self, name: impl Into<Bytes>, configure: F) -> Self
932    where
933        F: FnOnce(View) -> View,
934    {
935        let view = configure(View::new());
936        self.config.add_view(name, view);
937        self
938    }
939
940    /// Build the VACM configuration.
941    #[must_use]
942    pub fn build(self) -> VacmConfig {
943        self.config
944    }
945}
946
947impl Default for VacmBuilder {
948    fn default() -> Self {
949        Self::new()
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use crate::oid;
957
958    #[test]
959    fn test_view_contains_simple() {
960        let view = View::new().include(oid!(1, 3, 6, 1, 2, 1)); // system MIB
961
962        // OID within the subtree
963        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
964        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 2, 1, 1)));
965
966        // OID exactly at subtree
967        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1)));
968
969        // OID outside the subtree
970        assert!(!view.contains(&oid!(1, 3, 6, 1, 4, 1)));
971        assert!(!view.contains(&oid!(1, 3, 6, 1, 2)));
972    }
973
974    #[test]
975    fn test_view_exclude() {
976        let view = View::new()
977            .include(oid!(1, 3, 6, 1, 2, 1)) // system MIB
978            .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7)); // sysServices
979
980        // Included OIDs
981        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
982        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));
983
984        // Excluded OID
985        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7)));
986        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7, 0)));
987    }
988
989    #[test]
990    fn test_view_longest_match_wins() {
991        // RFC 3415 Section 5: when multiple subtrees match, longest match wins.
992        // include(1.3.6.1) + exclude(1.3.6.1.2) + include(1.3.6.1.2.1)
993        // For OID 1.3.6.1.2.1.1.0, all three match. Longest is include(1.3.6.1.2.1),
994        // so the OID should be accessible.
995        let view = View::new()
996            .include(oid!(1, 3, 6, 1))
997            .exclude(oid!(1, 3, 6, 1, 2))
998            .include(oid!(1, 3, 6, 1, 2, 1));
999
1000        // Longest match is include(1.3.6.1.2.1), so this should be included
1001        assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
1002
1003        // OID under the exclude but not re-included
1004        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 3, 1, 0)));
1005
1006        // OID only under the top-level include, not under exclude
1007        assert!(view.contains(&oid!(1, 3, 6, 1, 4, 1, 0)));
1008    }
1009
1010    #[test]
1011    fn test_view_longest_match_exclude_wins() {
1012        // When longest match is an exclude, OID should be excluded
1013        let view = View::new()
1014            .include(oid!(1, 3, 6, 1))
1015            .exclude(oid!(1, 3, 6, 1, 2, 1));
1016
1017        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
1018        assert!(view.contains(&oid!(1, 3, 6, 1, 4, 1, 0)));
1019    }
1020
1021    #[test]
1022    fn test_view_equal_length_exclude_wins() {
1023        // When include and exclude have equal match length, exclude should win
1024        // (conservative interpretation: deny beats allow at same specificity)
1025        let view = View::new()
1026            .include(oid!(1, 3, 6, 1, 2, 1))
1027            .exclude(oid!(1, 3, 6, 1, 2, 1));
1028
1029        assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
1030    }
1031
1032    #[test]
1033    fn test_view_subtree_mask() {
1034        // Create a view that matches ifDescr.* (any interface index)
1035        // The subtree OID is ifDescr (1.3.6.1.2.1.2.2.1.2) with 10 arcs (indices 0-9)
1036        // We want arcs 0-9 to match exactly, and arc 10+ to be wildcard
1037        // Mask: 0xFF = 11111111 (arcs 0-7 must match)
1038        //       0xC0 = 11000000 (arcs 8-9 must match, 10-15 wildcard)
1039        let subtree = ViewSubtree {
1040            oid: oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2), // ifDescr
1041            mask: vec![0xFF, 0xC0],                  // 11111111 11000000 - arcs 0-9 must match
1042            included: true,
1043        };
1044
1045        // Should match with any interface index in position 10
1046        assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)));
1047        assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 999)));
1048
1049        // Should not match if arc 9 differs (the "2" in ifDescr)
1050        assert!(!subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 3, 1)));
1051    }
1052
1053    #[test]
1054    fn test_vacm_group_lookup() {
1055        let mut config = VacmConfig::new();
1056        config.add_group("public", SecurityModel::V2c, "readonly_group");
1057        config.add_group("admin", SecurityModel::Usm, "admin_group");
1058
1059        assert_eq!(
1060            config.get_group(SecurityModel::V2c, b"public"),
1061            Some(&Bytes::from_static(b"readonly_group"))
1062        );
1063        assert_eq!(
1064            config.get_group(SecurityModel::Usm, b"admin"),
1065            Some(&Bytes::from_static(b"admin_group"))
1066        );
1067        assert_eq!(config.get_group(SecurityModel::V1, b"public"), None);
1068    }
1069
1070    #[test]
1071    fn test_vacm_group_any_model() {
1072        let mut config = VacmConfig::new();
1073        config.add_group("universal", SecurityModel::Any, "universal_group");
1074
1075        // Should match any security model
1076        assert_eq!(
1077            config.get_group(SecurityModel::V1, b"universal"),
1078            Some(&Bytes::from_static(b"universal_group"))
1079        );
1080        assert_eq!(
1081            config.get_group(SecurityModel::V2c, b"universal"),
1082            Some(&Bytes::from_static(b"universal_group"))
1083        );
1084    }
1085
1086    #[test]
1087    fn test_vacm_access_lookup() {
1088        let mut config = VacmConfig::new();
1089        config.add_access(VacmAccessEntry {
1090            group_name: Bytes::from_static(b"readonly_group"),
1091            context_prefix: Bytes::new(),
1092            security_model: SecurityModel::Any,
1093            security_level: SecurityLevel::NoAuthNoPriv,
1094            context_match: ContextMatch::Exact,
1095            read_view: Bytes::from_static(b"full_view"),
1096            write_view: Bytes::new(),
1097            notify_view: Bytes::new(),
1098        });
1099
1100        let access = config.get_access(
1101            b"readonly_group",
1102            b"",
1103            SecurityModel::V2c,
1104            SecurityLevel::NoAuthNoPriv,
1105        );
1106        assert!(access.is_some());
1107        assert_eq!(access.unwrap().read_view, Bytes::from_static(b"full_view"));
1108    }
1109
1110    #[test]
1111    fn test_vacm_access_security_level() {
1112        let mut config = VacmConfig::new();
1113        config.add_access(VacmAccessEntry {
1114            group_name: Bytes::from_static(b"admin_group"),
1115            context_prefix: Bytes::new(),
1116            security_model: SecurityModel::Usm,
1117            security_level: SecurityLevel::AuthPriv, // Require encryption
1118            context_match: ContextMatch::Exact,
1119            read_view: Bytes::from_static(b"full_view"),
1120            write_view: Bytes::from_static(b"full_view"),
1121            notify_view: Bytes::new(),
1122        });
1123
1124        // Should not match with lower security level
1125        let access = config.get_access(
1126            b"admin_group",
1127            b"",
1128            SecurityModel::Usm,
1129            SecurityLevel::AuthNoPriv,
1130        );
1131        assert!(access.is_none());
1132
1133        // Should match with required level
1134        let access = config.get_access(
1135            b"admin_group",
1136            b"",
1137            SecurityModel::Usm,
1138            SecurityLevel::AuthPriv,
1139        );
1140        assert!(access.is_some());
1141    }
1142
1143    #[test]
1144    fn test_vacm_check_access() {
1145        let mut config = VacmConfig::new();
1146        config.add_view("full_view", View::new().include(oid!(1, 3, 6, 1)));
1147
1148        assert!(config.check_access(
1149            Some(&Bytes::from_static(b"full_view")),
1150            &oid!(1, 3, 6, 1, 2, 1, 1, 0),
1151        ));
1152
1153        // Empty view name = no access
1154        assert!(!config.check_access(Some(&Bytes::new()), &oid!(1, 3, 6, 1, 2, 1, 1, 0),));
1155
1156        // None = no access
1157        assert!(!config.check_access(None, &oid!(1, 3, 6, 1, 2, 1, 1, 0),));
1158
1159        // Unknown view = no access
1160        assert!(!config.check_access(
1161            Some(&Bytes::from_static(b"unknown_view")),
1162            &oid!(1, 3, 6, 1, 2, 1, 1, 0),
1163        ));
1164    }
1165
1166    #[test]
1167    fn test_vacm_builder() {
1168        let config = VacmBuilder::new()
1169            .group("public", SecurityModel::V2c, "readonly_group")
1170            .group("admin", SecurityModel::Usm, "admin_group")
1171            .access("readonly_group", |a| {
1172                a.context_prefix("")
1173                    .security_model(SecurityModel::Any)
1174                    .security_level(SecurityLevel::NoAuthNoPriv)
1175                    .read_view("full_view")
1176            })
1177            .access("admin_group", |a| {
1178                a.security_model(SecurityModel::Usm)
1179                    .security_level(SecurityLevel::AuthPriv)
1180                    .read_view("full_view")
1181                    .write_view("full_view")
1182            })
1183            .view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
1184            .build();
1185
1186        assert!(config.get_group(SecurityModel::V2c, b"public").is_some());
1187        assert!(config.get_group(SecurityModel::Usm, b"admin").is_some());
1188    }
1189
1190    // RFC 3415 Section 4 preference order tests
1191    // The vacmAccessTable DESCRIPTION specifies a 4-tier preference order:
1192    // 1. Prefer specific securityModel over Any
1193    // 2. Prefer exact contextMatch over prefix
1194    // 3. Prefer longer contextPrefix
1195    // 4. Prefer higher securityLevel
1196
1197    #[test]
1198    fn test_vacm_access_prefers_specific_security_model_over_any() {
1199        // Tier 1: Specific securityModel should be preferred over Any
1200        let mut config = VacmConfig::new();
1201
1202        // Add entry with Any security model
1203        config.add_access(VacmAccessEntry {
1204            group_name: Bytes::from_static(b"test_group"),
1205            context_prefix: Bytes::new(),
1206            security_model: SecurityModel::Any,
1207            security_level: SecurityLevel::NoAuthNoPriv,
1208            context_match: ContextMatch::Exact,
1209            read_view: Bytes::from_static(b"any_view"),
1210            write_view: Bytes::new(),
1211            notify_view: Bytes::new(),
1212        });
1213
1214        // Add entry with specific V2c security model
1215        config.add_access(VacmAccessEntry {
1216            group_name: Bytes::from_static(b"test_group"),
1217            context_prefix: Bytes::new(),
1218            security_model: SecurityModel::V2c,
1219            security_level: SecurityLevel::NoAuthNoPriv,
1220            context_match: ContextMatch::Exact,
1221            read_view: Bytes::from_static(b"v2c_view"),
1222            write_view: Bytes::new(),
1223            notify_view: Bytes::new(),
1224        });
1225
1226        // Query with V2c - should get the specific V2c entry
1227        let access = config
1228            .get_access(
1229                b"test_group",
1230                b"",
1231                SecurityModel::V2c,
1232                SecurityLevel::NoAuthNoPriv,
1233            )
1234            .expect("should find access entry");
1235        assert_eq!(
1236            access.read_view,
1237            Bytes::from_static(b"v2c_view"),
1238            "should prefer specific security model over Any"
1239        );
1240    }
1241
1242    #[test]
1243    fn test_vacm_access_prefers_exact_context_match_over_prefix() {
1244        // Tier 2: Exact contextMatch should be preferred over prefix match
1245        let mut config = VacmConfig::new();
1246
1247        // Add entry with prefix context match
1248        config.add_access(VacmAccessEntry {
1249            group_name: Bytes::from_static(b"test_group"),
1250            context_prefix: Bytes::from_static(b"ctx"),
1251            security_model: SecurityModel::Any,
1252            security_level: SecurityLevel::NoAuthNoPriv,
1253            context_match: ContextMatch::Prefix,
1254            read_view: Bytes::from_static(b"prefix_view"),
1255            write_view: Bytes::new(),
1256            notify_view: Bytes::new(),
1257        });
1258
1259        // Add entry with exact context match (same prefix)
1260        config.add_access(VacmAccessEntry {
1261            group_name: Bytes::from_static(b"test_group"),
1262            context_prefix: Bytes::from_static(b"ctx"),
1263            security_model: SecurityModel::Any,
1264            security_level: SecurityLevel::NoAuthNoPriv,
1265            context_match: ContextMatch::Exact,
1266            read_view: Bytes::from_static(b"exact_view"),
1267            write_view: Bytes::new(),
1268            notify_view: Bytes::new(),
1269        });
1270
1271        // Query with exact context "ctx" - should get the exact match entry
1272        let access = config
1273            .get_access(
1274                b"test_group",
1275                b"ctx",
1276                SecurityModel::V2c,
1277                SecurityLevel::NoAuthNoPriv,
1278            )
1279            .expect("should find access entry");
1280        assert_eq!(
1281            access.read_view,
1282            Bytes::from_static(b"exact_view"),
1283            "should prefer exact context match over prefix"
1284        );
1285    }
1286
1287    #[test]
1288    fn test_vacm_access_prefers_longer_context_prefix() {
1289        // Tier 3: Longer contextPrefix should be preferred
1290        let mut config = VacmConfig::new();
1291
1292        // Add entry with shorter context prefix
1293        config.add_access(VacmAccessEntry {
1294            group_name: Bytes::from_static(b"test_group"),
1295            context_prefix: Bytes::from_static(b"ctx"),
1296            security_model: SecurityModel::Any,
1297            security_level: SecurityLevel::NoAuthNoPriv,
1298            context_match: ContextMatch::Prefix,
1299            read_view: Bytes::from_static(b"short_view"),
1300            write_view: Bytes::new(),
1301            notify_view: Bytes::new(),
1302        });
1303
1304        // Add entry with longer context prefix
1305        config.add_access(VacmAccessEntry {
1306            group_name: Bytes::from_static(b"test_group"),
1307            context_prefix: Bytes::from_static(b"ctx_longer"),
1308            security_model: SecurityModel::Any,
1309            security_level: SecurityLevel::NoAuthNoPriv,
1310            context_match: ContextMatch::Prefix,
1311            read_view: Bytes::from_static(b"long_view"),
1312            write_view: Bytes::new(),
1313            notify_view: Bytes::new(),
1314        });
1315
1316        // Query with context that matches both - should get the longer prefix
1317        let access = config
1318            .get_access(
1319                b"test_group",
1320                b"ctx_longer_suffix",
1321                SecurityModel::V2c,
1322                SecurityLevel::NoAuthNoPriv,
1323            )
1324            .expect("should find access entry");
1325        assert_eq!(
1326            access.read_view,
1327            Bytes::from_static(b"long_view"),
1328            "should prefer longer context prefix"
1329        );
1330    }
1331
1332    #[test]
1333    fn test_vacm_access_prefers_higher_security_level() {
1334        // Tier 4: Higher securityLevel should be preferred
1335        let mut config = VacmConfig::new();
1336
1337        // Add entry with NoAuthNoPriv
1338        config.add_access(VacmAccessEntry {
1339            group_name: Bytes::from_static(b"test_group"),
1340            context_prefix: Bytes::new(),
1341            security_model: SecurityModel::Any,
1342            security_level: SecurityLevel::NoAuthNoPriv,
1343            context_match: ContextMatch::Exact,
1344            read_view: Bytes::from_static(b"noauth_view"),
1345            write_view: Bytes::new(),
1346            notify_view: Bytes::new(),
1347        });
1348
1349        // Add entry with AuthNoPriv
1350        config.add_access(VacmAccessEntry {
1351            group_name: Bytes::from_static(b"test_group"),
1352            context_prefix: Bytes::new(),
1353            security_model: SecurityModel::Any,
1354            security_level: SecurityLevel::AuthNoPriv,
1355            context_match: ContextMatch::Exact,
1356            read_view: Bytes::from_static(b"auth_view"),
1357            write_view: Bytes::new(),
1358            notify_view: Bytes::new(),
1359        });
1360
1361        // Add entry with AuthPriv
1362        config.add_access(VacmAccessEntry {
1363            group_name: Bytes::from_static(b"test_group"),
1364            context_prefix: Bytes::new(),
1365            security_model: SecurityModel::Any,
1366            security_level: SecurityLevel::AuthPriv,
1367            context_match: ContextMatch::Exact,
1368            read_view: Bytes::from_static(b"authpriv_view"),
1369            write_view: Bytes::new(),
1370            notify_view: Bytes::new(),
1371        });
1372
1373        // Query with AuthPriv - should get the AuthPriv entry (highest matching)
1374        let access = config
1375            .get_access(
1376                b"test_group",
1377                b"",
1378                SecurityModel::V2c,
1379                SecurityLevel::AuthPriv,
1380            )
1381            .expect("should find access entry");
1382        assert_eq!(
1383            access.read_view,
1384            Bytes::from_static(b"authpriv_view"),
1385            "should prefer higher security level"
1386        );
1387    }
1388
1389    #[test]
1390    fn test_vacm_access_preference_tier_ordering() {
1391        // Test that tier 1 takes precedence over tier 2, which takes precedence
1392        // over tier 3, which takes precedence over tier 4.
1393        let mut config = VacmConfig::new();
1394
1395        // Entry: Any model, prefix match, short prefix, high security
1396        config.add_access(VacmAccessEntry {
1397            group_name: Bytes::from_static(b"test_group"),
1398            context_prefix: Bytes::from_static(b"ctx"),
1399            security_model: SecurityModel::Any,
1400            security_level: SecurityLevel::AuthPriv, // highest security
1401            context_match: ContextMatch::Prefix,
1402            read_view: Bytes::from_static(b"any_prefix_short_high"),
1403            write_view: Bytes::new(),
1404            notify_view: Bytes::new(),
1405        });
1406
1407        // Entry: Specific model, prefix match, short prefix, low security
1408        // Tier 1 (specific model) should beat tier 4 (high security)
1409        config.add_access(VacmAccessEntry {
1410            group_name: Bytes::from_static(b"test_group"),
1411            context_prefix: Bytes::from_static(b"ctx"),
1412            security_model: SecurityModel::V2c,
1413            security_level: SecurityLevel::NoAuthNoPriv,
1414            context_match: ContextMatch::Prefix,
1415            read_view: Bytes::from_static(b"v2c_prefix_short_low"),
1416            write_view: Bytes::new(),
1417            notify_view: Bytes::new(),
1418        });
1419
1420        // Query - specific model (V2c) should win over Any even though Any has higher security
1421        let access = config
1422            .get_access(
1423                b"test_group",
1424                b"ctx_test",
1425                SecurityModel::V2c,
1426                SecurityLevel::AuthPriv,
1427            )
1428            .expect("should find access entry");
1429        assert_eq!(
1430            access.read_view,
1431            Bytes::from_static(b"v2c_prefix_short_low"),
1432            "tier 1 (specific model) should take precedence over tier 4 (security level)"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_vacm_access_preference_context_match_over_prefix_length() {
1438        // Tier 2 (exact match) should beat tier 3 (longer prefix)
1439        let mut config = VacmConfig::new();
1440
1441        // Entry: prefix match with longer prefix
1442        config.add_access(VacmAccessEntry {
1443            group_name: Bytes::from_static(b"test_group"),
1444            context_prefix: Bytes::from_static(b"context"),
1445            security_model: SecurityModel::Any,
1446            security_level: SecurityLevel::NoAuthNoPriv,
1447            context_match: ContextMatch::Prefix,
1448            read_view: Bytes::from_static(b"long_prefix_view"),
1449            write_view: Bytes::new(),
1450            notify_view: Bytes::new(),
1451        });
1452
1453        // Entry: exact match with shorter prefix
1454        config.add_access(VacmAccessEntry {
1455            group_name: Bytes::from_static(b"test_group"),
1456            context_prefix: Bytes::from_static(b"ctx"),
1457            security_model: SecurityModel::Any,
1458            security_level: SecurityLevel::NoAuthNoPriv,
1459            context_match: ContextMatch::Exact,
1460            read_view: Bytes::from_static(b"short_exact_view"),
1461            write_view: Bytes::new(),
1462            notify_view: Bytes::new(),
1463        });
1464
1465        // Query with "ctx" - exact match should win even though it's shorter
1466        let access = config
1467            .get_access(
1468                b"test_group",
1469                b"ctx",
1470                SecurityModel::V2c,
1471                SecurityLevel::NoAuthNoPriv,
1472            )
1473            .expect("should find access entry");
1474        assert_eq!(
1475            access.read_view,
1476            Bytes::from_static(b"short_exact_view"),
1477            "tier 2 (exact match) should take precedence over tier 3 (longer prefix)"
1478        );
1479    }
1480
1481    #[test]
1482    fn test_vacm_access_preference_prefix_length_over_security() {
1483        // Tier 3 (longer prefix) should beat tier 4 (higher security)
1484        let mut config = VacmConfig::new();
1485
1486        // Entry: short prefix with high security
1487        config.add_access(VacmAccessEntry {
1488            group_name: Bytes::from_static(b"test_group"),
1489            context_prefix: Bytes::from_static(b"ctx"),
1490            security_model: SecurityModel::Any,
1491            security_level: SecurityLevel::AuthPriv,
1492            context_match: ContextMatch::Prefix,
1493            read_view: Bytes::from_static(b"short_high_sec"),
1494            write_view: Bytes::new(),
1495            notify_view: Bytes::new(),
1496        });
1497
1498        // Entry: longer prefix with low security
1499        config.add_access(VacmAccessEntry {
1500            group_name: Bytes::from_static(b"test_group"),
1501            context_prefix: Bytes::from_static(b"ctx_test"),
1502            security_model: SecurityModel::Any,
1503            security_level: SecurityLevel::NoAuthNoPriv,
1504            context_match: ContextMatch::Prefix,
1505            read_view: Bytes::from_static(b"long_low_sec"),
1506            write_view: Bytes::new(),
1507            notify_view: Bytes::new(),
1508        });
1509
1510        // Query - longer prefix should win even though short prefix has higher security
1511        let access = config
1512            .get_access(
1513                b"test_group",
1514                b"ctx_test_suffix",
1515                SecurityModel::V2c,
1516                SecurityLevel::AuthPriv,
1517            )
1518            .expect("should find access entry");
1519        assert_eq!(
1520            access.read_view,
1521            Bytes::from_static(b"long_low_sec"),
1522            "tier 3 (longer prefix) should take precedence over tier 4 (security level)"
1523        );
1524    }
1525
1526    #[test]
1527    fn test_vacm_access_all_tiers_combined() {
1528        // Test with multiple entries that differ in all tiers
1529        let mut config = VacmConfig::new();
1530
1531        // Entry 1: Any, prefix, short, NoAuth
1532        config.add_access(VacmAccessEntry {
1533            group_name: Bytes::from_static(b"test_group"),
1534            context_prefix: Bytes::from_static(b"a"),
1535            security_model: SecurityModel::Any,
1536            security_level: SecurityLevel::NoAuthNoPriv,
1537            context_match: ContextMatch::Prefix,
1538            read_view: Bytes::from_static(b"entry1"),
1539            write_view: Bytes::new(),
1540            notify_view: Bytes::new(),
1541        });
1542
1543        // Entry 2: V2c (specific), exact, short, NoAuth - should win for "a" context
1544        config.add_access(VacmAccessEntry {
1545            group_name: Bytes::from_static(b"test_group"),
1546            context_prefix: Bytes::from_static(b"a"),
1547            security_model: SecurityModel::V2c,
1548            security_level: SecurityLevel::NoAuthNoPriv,
1549            context_match: ContextMatch::Exact,
1550            read_view: Bytes::from_static(b"entry2"),
1551            write_view: Bytes::new(),
1552            notify_view: Bytes::new(),
1553        });
1554
1555        let access = config
1556            .get_access(
1557                b"test_group",
1558                b"a",
1559                SecurityModel::V2c,
1560                SecurityLevel::NoAuthNoPriv,
1561            )
1562            .expect("should find access entry");
1563        assert_eq!(
1564            access.read_view,
1565            Bytes::from_static(b"entry2"),
1566            "specific model + exact match should win"
1567        );
1568    }
1569
1570    // Tests that verify preference ordering is independent of insertion order
1571    #[test]
1572    fn test_vacm_access_exact_wins_regardless_of_insertion_order() {
1573        // Add exact first, prefix second - exact should still win
1574        let mut config = VacmConfig::new();
1575
1576        config.add_access(VacmAccessEntry {
1577            group_name: Bytes::from_static(b"test_group"),
1578            context_prefix: Bytes::from_static(b"ctx"),
1579            security_model: SecurityModel::Any,
1580            security_level: SecurityLevel::NoAuthNoPriv,
1581            context_match: ContextMatch::Exact,
1582            read_view: Bytes::from_static(b"exact_view"),
1583            write_view: Bytes::new(),
1584            notify_view: Bytes::new(),
1585        });
1586
1587        config.add_access(VacmAccessEntry {
1588            group_name: Bytes::from_static(b"test_group"),
1589            context_prefix: Bytes::from_static(b"ctx"),
1590            security_model: SecurityModel::Any,
1591            security_level: SecurityLevel::NoAuthNoPriv,
1592            context_match: ContextMatch::Prefix,
1593            read_view: Bytes::from_static(b"prefix_view"),
1594            write_view: Bytes::new(),
1595            notify_view: Bytes::new(),
1596        });
1597
1598        let access = config
1599            .get_access(
1600                b"test_group",
1601                b"ctx",
1602                SecurityModel::V2c,
1603                SecurityLevel::NoAuthNoPriv,
1604            )
1605            .expect("should find access entry");
1606        assert_eq!(
1607            access.read_view,
1608            Bytes::from_static(b"exact_view"),
1609            "exact match should win regardless of insertion order"
1610        );
1611    }
1612
1613    #[test]
1614    fn test_vacm_access_higher_security_wins_regardless_of_insertion_order() {
1615        // Add higher security first, lower second - higher should still win
1616        let mut config = VacmConfig::new();
1617
1618        config.add_access(VacmAccessEntry {
1619            group_name: Bytes::from_static(b"test_group"),
1620            context_prefix: Bytes::new(),
1621            security_model: SecurityModel::Any,
1622            security_level: SecurityLevel::AuthPriv,
1623            context_match: ContextMatch::Exact,
1624            read_view: Bytes::from_static(b"authpriv_view"),
1625            write_view: Bytes::new(),
1626            notify_view: Bytes::new(),
1627        });
1628
1629        config.add_access(VacmAccessEntry {
1630            group_name: Bytes::from_static(b"test_group"),
1631            context_prefix: Bytes::new(),
1632            security_model: SecurityModel::Any,
1633            security_level: SecurityLevel::NoAuthNoPriv,
1634            context_match: ContextMatch::Exact,
1635            read_view: Bytes::from_static(b"noauth_view"),
1636            write_view: Bytes::new(),
1637            notify_view: Bytes::new(),
1638        });
1639
1640        let access = config
1641            .get_access(
1642                b"test_group",
1643                b"",
1644                SecurityModel::V2c,
1645                SecurityLevel::AuthPriv,
1646            )
1647            .expect("should find access entry");
1648        assert_eq!(
1649            access.read_view,
1650            Bytes::from_static(b"authpriv_view"),
1651            "higher security level should win regardless of insertion order"
1652        );
1653    }
1654
1655    // ViewCheckResult and check_subtree tests
1656    //
1657    // These tests validate the 3-state subtree check semantics from RFC 3415.
1658    // For GETBULK/GETNEXT operations, knowing whether a subtree has mixed
1659    // permissions enables optimizations:
1660    // - Included: Skip per-OID access checks for descendants
1661    // - Excluded: Early termination, no descendants accessible
1662    // - Ambiguous: Must check each OID individually
1663
1664    #[test]
1665    fn test_check_subtree_empty_view_is_excluded() {
1666        // Empty view contains nothing
1667        let view = View::new();
1668        assert_eq!(
1669            view.check_subtree(&oid!(1, 3, 6, 1)),
1670            ViewCheckResult::Excluded
1671        );
1672    }
1673
1674    #[test]
1675    fn test_check_subtree_oid_within_included_subtree() {
1676        // OID that falls within an included subtree is included
1677        let view = View::new().include(oid!(1, 3, 6, 1, 2, 1));
1678
1679        // OID within the subtree
1680        assert_eq!(
1681            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 1, 0)),
1682            ViewCheckResult::Included
1683        );
1684        // OID exactly at subtree root
1685        assert_eq!(
1686            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1)),
1687            ViewCheckResult::Included
1688        );
1689    }
1690
1691    #[test]
1692    fn test_check_subtree_oid_within_excluded_subtree() {
1693        // OID within an excluded subtree (after include) is excluded
1694        let view = View::new()
1695            .include(oid!(1, 3, 6, 1, 2, 1))
1696            .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7));
1697
1698        // OID within the excluded subtree
1699        assert_eq!(
1700            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 1, 7, 0)),
1701            ViewCheckResult::Excluded
1702        );
1703        // OID exactly at exclude root
1704        assert_eq!(
1705            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 1, 7)),
1706            ViewCheckResult::Excluded
1707        );
1708    }
1709
1710    #[test]
1711    fn test_check_subtree_oid_outside_all_subtrees() {
1712        // OID completely outside any defined subtree is excluded
1713        let view = View::new().include(oid!(1, 3, 6, 1, 2, 1));
1714
1715        // Different branch entirely
1716        assert_eq!(
1717            view.check_subtree(&oid!(1, 3, 6, 1, 4, 1)),
1718            ViewCheckResult::Excluded
1719        );
1720    }
1721
1722    #[test]
1723    fn test_check_subtree_parent_of_single_include_is_ambiguous() {
1724        // Parent OID of an included subtree is ambiguous:
1725        // some children (the include) are accessible, others are not
1726        let view = View::new().include(oid!(1, 3, 6, 1, 2, 1));
1727
1728        // Parent of the included subtree
1729        assert_eq!(
1730            view.check_subtree(&oid!(1, 3, 6, 1)),
1731            ViewCheckResult::Ambiguous
1732        );
1733        assert_eq!(
1734            view.check_subtree(&oid!(1, 3, 6)),
1735            ViewCheckResult::Ambiguous
1736        );
1737    }
1738
1739    #[test]
1740    fn test_check_subtree_parent_of_include_with_nested_exclude() {
1741        // View with include and nested exclude: parent is ambiguous
1742        let view = View::new()
1743            .include(oid!(1, 3, 6, 1, 2, 1))
1744            .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7));
1745
1746        // Parent of the include - ambiguous because it has included descendants
1747        assert_eq!(
1748            view.check_subtree(&oid!(1, 3, 6, 1)),
1749            ViewCheckResult::Ambiguous
1750        );
1751
1752        // The include root itself - ambiguous because it contains excluded subtree
1753        assert_eq!(
1754            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1)),
1755            ViewCheckResult::Ambiguous
1756        );
1757
1758        // Between include root and exclude - ambiguous
1759        assert_eq!(
1760            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 1)),
1761            ViewCheckResult::Ambiguous
1762        );
1763    }
1764
1765    #[test]
1766    fn test_check_subtree_fully_included_child() {
1767        // When querying a subtree that is fully within an include,
1768        // with no excludes affecting it, it should be included
1769        let view = View::new()
1770            .include(oid!(1, 3, 6, 1, 2, 1))
1771            .exclude(oid!(1, 3, 6, 1, 2, 1, 25)); // exclude host resources
1772
1773        // sysDescr subtree - fully included, no excludes affect it
1774        assert_eq!(
1775            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 1, 1)),
1776            ViewCheckResult::Included
1777        );
1778
1779        // But the system group itself is ambiguous because hrMIB is excluded
1780        // Wait, hrMIB is .25, not under .1 - so system group should be included
1781        assert_eq!(
1782            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 1)),
1783            ViewCheckResult::Included
1784        );
1785    }
1786
1787    #[test]
1788    fn test_check_subtree_multiple_includes() {
1789        // Multiple disjoint includes - parent is ambiguous
1790        let view = View::new()
1791            .include(oid!(1, 3, 6, 1, 2, 1, 1)) // system
1792            .include(oid!(1, 3, 6, 1, 2, 1, 2)); // interfaces
1793
1794        // Parent of both - ambiguous (some children included, others not)
1795        assert_eq!(
1796            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1)),
1797            ViewCheckResult::Ambiguous
1798        );
1799
1800        // Each individual include is fully included
1801        assert_eq!(
1802            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 1)),
1803            ViewCheckResult::Included
1804        );
1805        assert_eq!(
1806            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 2)),
1807            ViewCheckResult::Included
1808        );
1809
1810        // Sibling not in any include is excluded
1811        assert_eq!(
1812            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 3)),
1813            ViewCheckResult::Excluded
1814        );
1815    }
1816
1817    #[test]
1818    fn test_check_subtree_exclude_only_is_excluded() {
1819        // An exclude without a covering include excludes nothing
1820        // (exclude only has effect when there's a matching include)
1821        // Actually, per RFC 3415, an exclude without include means the OID
1822        // is simply not in the view at all (excluded)
1823        let view = View::new().exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7));
1824
1825        // Everything is excluded because there's no include
1826        assert_eq!(
1827            view.check_subtree(&oid!(1, 3, 6, 1)),
1828            ViewCheckResult::Excluded
1829        );
1830        assert_eq!(
1831            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1)),
1832            ViewCheckResult::Excluded
1833        );
1834    }
1835
1836    #[test]
1837    fn test_check_subtree_with_mask() {
1838        // Masked subtree - parent is ambiguous due to partial match
1839        let view = View::new().include_masked(
1840            oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2), // ifDescr
1841            vec![0xFF, 0xC0],                   // arcs 0-9 exact, 10+ wildcard
1842        );
1843
1844        // Within the masked include - included
1845        assert_eq!(
1846            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)),
1847            ViewCheckResult::Included
1848        );
1849
1850        // Parent of the masked include - ambiguous
1851        assert_eq!(
1852            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 2)),
1853            ViewCheckResult::Ambiguous
1854        );
1855
1856        // Sibling column (ifType) - excluded
1857        assert_eq!(
1858            view.check_subtree(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 3)),
1859            ViewCheckResult::Excluded
1860        );
1861    }
1862
1863    #[test]
1864    fn test_check_subtree_vs_contains_consistency() {
1865        // Verify that check_subtree is consistent with contains:
1866        // If check_subtree returns Included, contains should return true
1867        // If check_subtree returns Excluded, contains should return false
1868        let view = View::new()
1869            .include(oid!(1, 3, 6, 1, 2, 1))
1870            .exclude(oid!(1, 3, 6, 1, 2, 1, 25));
1871
1872        let test_cases = [
1873            oid!(1, 3, 6, 1, 2, 1, 1, 0),  // included
1874            oid!(1, 3, 6, 1, 2, 1, 25, 1), // excluded
1875            oid!(1, 3, 6, 1, 4, 1),        // not in view at all
1876        ];
1877
1878        for oid in &test_cases {
1879            let check_result = view.check_subtree(oid);
1880            let contains_result = view.contains(oid);
1881
1882            match check_result {
1883                ViewCheckResult::Included => {
1884                    assert!(
1885                        contains_result,
1886                        "check_subtree=Included but contains=false for {oid:?}"
1887                    );
1888                }
1889                ViewCheckResult::Excluded => {
1890                    assert!(
1891                        !contains_result,
1892                        "check_subtree=Excluded but contains=true for {oid:?}"
1893                    );
1894                }
1895                ViewCheckResult::Ambiguous => {
1896                    // Ambiguous can be either, depending on specific OID
1897                }
1898            }
1899        }
1900    }
1901}