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