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