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