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