Skip to main content

async_snmp/agent/
vacm.rs

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