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