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