use std::collections::HashMap;
use std::error::Error as StdError;
use std::fmt;
use bytes::Bytes;
use crate::message::SecurityLevel;
use crate::oid::Oid;
use crate::handler::SecurityModel;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum VacmSecurityModel {
#[default]
Any,
Exact(SecurityModel),
}
impl From<SecurityModel> for VacmSecurityModel {
fn from(model: SecurityModel) -> Self {
Self::Exact(model)
}
}
impl VacmSecurityModel {
fn matches(self, model: SecurityModel) -> bool {
match self {
Self::Any => true,
Self::Exact(exact) => exact == model,
}
}
fn is_exact(self, model: SecurityModel) -> bool {
self == Self::Exact(model)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum ContextMatch {
#[default]
Exact,
Prefix,
}
#[derive(Debug, Clone, Default)]
pub struct View {
subtrees: Vec<ViewSubtree>,
}
impl View {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn include(mut self, oid: Oid) -> Self {
self.subtrees.push(ViewSubtree {
oid,
mask: Vec::new(),
included: true,
});
self
}
#[must_use]
pub fn include_masked(mut self, oid: Oid, mask: Vec<u8>) -> Self {
self.subtrees.push(ViewSubtree {
oid,
mask,
included: true,
});
self
}
#[must_use]
pub fn exclude(mut self, oid: Oid) -> Self {
self.subtrees.push(ViewSubtree {
oid,
mask: Vec::new(),
included: false,
});
self
}
#[must_use]
pub fn exclude_masked(mut self, oid: Oid, mask: Vec<u8>) -> Self {
self.subtrees.push(ViewSubtree {
oid,
mask,
included: false,
});
self
}
#[must_use]
pub fn contains(&self, oid: &Oid) -> bool {
let mut best: Option<(&[u32], bool)> = None;
for subtree in &self.subtrees {
if subtree.matches(oid) {
let arcs = subtree.oid.arcs();
if view_match_wins(best, arcs, subtree.included) {
best = Some((arcs, subtree.included));
}
}
}
best.is_some_and(|(_, included)| included)
}
}
fn view_match_wins(best: Option<(&[u32], bool)>, arcs: &[u32], included: bool) -> bool {
match best {
None => true,
Some((best_arcs, best_included)) => {
arcs.len() > best_arcs.len()
|| (arcs.len() == best_arcs.len()
&& (arcs > best_arcs
|| (arcs == best_arcs && best_included && !included)))
}
}
}
#[derive(Debug, Clone)]
pub struct ViewSubtree {
pub oid: Oid,
pub mask: Vec<u8>,
pub included: bool,
}
impl ViewSubtree {
fn arc_is_exact(&self, i: usize) -> bool {
if i / 8 < self.mask.len() {
(self.mask[i / 8] >> (7 - (i % 8))) & 1 == 1
} else {
true }
}
fn prefix_matches(&self, arcs: &[u32]) -> bool {
let subtree_arcs = self.oid.arcs();
arcs.iter()
.enumerate()
.all(|(i, &arc)| !self.arc_is_exact(i) || subtree_arcs[i] == arc)
}
#[must_use]
pub fn matches(&self, oid: &Oid) -> bool {
let subtree_arcs = self.oid.arcs();
let oid_arcs = oid.arcs();
if oid_arcs.len() < subtree_arcs.len() {
return false;
}
self.prefix_matches(&oid_arcs[..subtree_arcs.len()])
}
}
#[derive(Debug, Clone)]
pub struct VacmAccessEntry {
pub group_name: Bytes,
pub context_prefix: Bytes,
pub security_model: VacmSecurityModel,
pub security_level: SecurityLevel,
pub(crate) context_match: ContextMatch,
pub read_view: Bytes,
pub write_view: Bytes,
pub notify_view: Bytes,
}
impl VacmAccessEntry {
#[must_use]
pub fn index(&self) -> VacmAccessIndex {
VacmAccessIndex {
group_name: self.group_name.clone(),
context_prefix: self.context_prefix.clone(),
security_model: self.security_model,
security_level: self.security_level,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct VacmAccessIndex {
pub group_name: Bytes,
pub context_prefix: Bytes,
pub security_model: VacmSecurityModel,
pub security_level: SecurityLevel,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuplicateVacmAccessEntry {
index: VacmAccessIndex,
}
impl DuplicateVacmAccessEntry {
#[must_use]
pub fn index(&self) -> &VacmAccessIndex {
&self.index
}
}
impl fmt::Display for DuplicateVacmAccessEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "duplicate VACM access entry at index {:?}", self.index)
}
}
impl StdError for DuplicateVacmAccessEntry {}
pub struct AccessEntryBuilder {
group_name: Bytes,
context_prefix: Bytes,
security_model: VacmSecurityModel,
security_level: SecurityLevel,
context_match: ContextMatch,
read_view: Bytes,
write_view: Bytes,
notify_view: Bytes,
}
impl AccessEntryBuilder {
pub fn new(
group_name: impl Into<Bytes>,
security_model: impl Into<VacmSecurityModel>,
security_level: SecurityLevel,
) -> Self {
Self {
group_name: group_name.into(),
context_prefix: Bytes::new(),
security_model: security_model.into(),
security_level,
context_match: ContextMatch::Exact,
read_view: Bytes::new(),
write_view: Bytes::new(),
notify_view: Bytes::new(),
}
}
#[must_use]
pub fn context_prefix(mut self, prefix: impl Into<Bytes>) -> Self {
self.context_prefix = prefix.into();
self
}
#[must_use]
pub fn context_match_prefix(mut self) -> Self {
self.context_match = ContextMatch::Prefix;
self
}
#[must_use]
pub fn read_view(mut self, view: impl Into<Bytes>) -> Self {
self.read_view = view.into();
self
}
#[must_use]
pub fn write_view(mut self, view: impl Into<Bytes>) -> Self {
self.write_view = view.into();
self
}
#[must_use]
pub fn notify_view(mut self, view: impl Into<Bytes>) -> Self {
self.notify_view = view.into();
self
}
pub fn build(self) -> VacmAccessEntry {
VacmAccessEntry {
group_name: self.group_name,
context_prefix: self.context_prefix,
security_model: self.security_model,
security_level: self.security_level,
context_match: self.context_match,
read_view: self.read_view,
write_view: self.write_view,
notify_view: self.notify_view,
}
}
}
#[derive(Clone, Default)]
pub struct VacmConfig {
security_to_group: HashMap<(VacmSecurityModel, Bytes), Bytes>,
access_entries: HashMap<VacmAccessIndex, VacmAccessEntry>,
views: HashMap<Bytes, View>,
}
impl fmt::Debug for VacmConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VacmConfig")
.field(
"security_to_group",
&RedactedSecurityMappings(&self.security_to_group),
)
.field("access_entries", &self.access_entries)
.field("views", &self.views)
.finish()
}
}
struct RedactedSecurityMappings<'a>(&'a HashMap<(VacmSecurityModel, Bytes), Bytes>);
impl fmt::Debug for RedactedSecurityMappings<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut list = f.debug_list();
for ((security_model, _security_name), group_name) in self.0 {
list.entry(&RedactedSecurityMapping {
security_model,
security_name: "[REDACTED]",
group_name,
});
}
list.finish()
}
}
struct RedactedSecurityMapping<'a> {
security_model: &'a VacmSecurityModel,
security_name: &'static str,
group_name: &'a Bytes,
}
impl fmt::Debug for RedactedSecurityMapping<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecurityMapping")
.field("security_model", self.security_model)
.field("security_name", &self.security_name)
.field("group_name", self.group_name)
.finish()
}
}
impl VacmConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_group(
&mut self,
security_name: impl Into<Bytes>,
security_model: impl Into<VacmSecurityModel>,
group_name: impl Into<Bytes>,
) {
self.security_to_group.insert(
(security_model.into(), security_name.into()),
group_name.into(),
);
}
pub fn add_access(&mut self, entry: VacmAccessEntry) -> Result<(), DuplicateVacmAccessEntry> {
let index = entry.index();
if self.access_entries.contains_key(&index) {
return Err(DuplicateVacmAccessEntry { index });
}
self.access_entries.insert(index, entry);
Ok(())
}
pub fn replace_access(&mut self, entry: VacmAccessEntry) -> Option<VacmAccessEntry> {
self.access_entries.insert(entry.index(), entry)
}
pub fn add_view(&mut self, name: impl Into<Bytes>, view: View) {
self.views.insert(name.into(), view);
}
#[must_use]
pub(crate) fn has_view(&self, name: &[u8]) -> bool {
!name.is_empty() && self.views.contains_key(name)
}
#[must_use]
pub fn get_group(&self, model: SecurityModel, name: &[u8]) -> Option<&Bytes> {
let mut any_match = None;
for ((entry_model, entry_name), group) in &self.security_to_group {
if entry_name.as_ref() == name {
if entry_model.is_exact(model) {
return Some(group);
} else if *entry_model == VacmSecurityModel::Any {
any_match = Some(group);
}
}
}
any_match
}
#[must_use]
pub fn get_access(
&self,
group: &[u8],
context: &[u8],
model: SecurityModel,
level: SecurityLevel,
) -> Option<&VacmAccessEntry> {
self.access_entries
.values()
.filter(|e| {
e.group_name.as_ref() == group
&& self.context_matches(&e.context_prefix, context, e.context_match)
&& e.security_model.matches(model)
&& level >= e.security_level
})
.max_by_key(|e| {
let model_score: u8 = u8::from(e.security_model.is_exact(model));
let match_score: u8 = u8::from(e.context_prefix.as_ref() == context);
let prefix_len = e.context_prefix.len();
let level_score = e.security_level as u8;
(model_score, match_score, prefix_len, level_score)
})
}
fn context_matches(&self, prefix: &[u8], context: &[u8], mode: ContextMatch) -> bool {
match mode {
ContextMatch::Exact => prefix == context,
ContextMatch::Prefix => context.starts_with(prefix),
}
}
#[must_use]
pub fn check_access(&self, view_name: Option<&Bytes>, oid: &Oid) -> bool {
let Some(view_name) = view_name else {
return false;
};
if view_name.is_empty() {
return false;
}
let Some(view) = self.views.get(view_name) else {
return false;
};
view.contains(oid)
}
}
pub struct VacmBuilder {
config: VacmConfig,
duplicate_access_entries: Vec<VacmAccessIndex>,
}
impl VacmBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: VacmConfig::new(),
duplicate_access_entries: Vec::new(),
}
}
#[must_use]
pub fn group(
mut self,
security_name: impl Into<Bytes>,
security_model: impl Into<VacmSecurityModel>,
group_name: impl Into<Bytes>,
) -> Self {
self.config
.add_group(security_name, security_model, group_name);
self
}
#[must_use]
pub fn access<F>(
mut self,
group_name: impl Into<Bytes>,
security_model: impl Into<VacmSecurityModel>,
security_level: SecurityLevel,
configure: F,
) -> Self
where
F: FnOnce(AccessEntryBuilder) -> AccessEntryBuilder,
{
let builder = AccessEntryBuilder::new(group_name, security_model, security_level);
let entry = configure(builder).build();
if let Err(error) = self.config.add_access(entry) {
self.duplicate_access_entries.push(error.index);
}
self
}
#[must_use]
pub fn replace_access<F>(
mut self,
group_name: impl Into<Bytes>,
security_model: impl Into<VacmSecurityModel>,
security_level: SecurityLevel,
configure: F,
) -> Self
where
F: FnOnce(AccessEntryBuilder) -> AccessEntryBuilder,
{
let entry = configure(AccessEntryBuilder::new(
group_name,
security_model,
security_level,
))
.build();
let index = entry.index();
self.duplicate_access_entries
.retain(|duplicate| duplicate != &index);
self.config.replace_access(entry);
self
}
#[must_use]
pub fn view<F>(mut self, name: impl Into<Bytes>, configure: F) -> Self
where
F: FnOnce(View) -> View,
{
let view = configure(View::new());
self.config.add_view(name, view);
self
}
pub fn build(self) -> Result<VacmConfig, DuplicateVacmAccessEntry> {
if let Some(index) = self.duplicate_access_entries.into_iter().next() {
return Err(DuplicateVacmAccessEntry { index });
}
Ok(self.config)
}
}
impl Default for VacmBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oid;
use proptest::prelude::*;
fn access_entry(
context_prefix: &'static [u8],
security_model: VacmSecurityModel,
security_level: SecurityLevel,
context_match: ContextMatch,
read_view: &'static [u8],
) -> VacmAccessEntry {
VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(context_prefix),
security_model,
security_level,
context_match,
read_view: Bytes::from_static(read_view),
write_view: Bytes::new(),
notify_view: Bytes::new(),
}
}
#[test]
fn test_view_contains_simple() {
let view = View::new().include(oid!(1, 3, 6, 1, 2, 1));
assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 2, 1, 1)));
assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1)));
assert!(!view.contains(&oid!(1, 3, 6, 1, 4, 1)));
assert!(!view.contains(&oid!(1, 3, 6, 1, 2)));
}
#[test]
fn test_view_exclude() {
let view = View::new()
.include(oid!(1, 3, 6, 1, 2, 1)) .exclude(oid!(1, 3, 6, 1, 2, 1, 1, 7));
assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 1, 0)));
assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7)));
assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 7, 0)));
}
#[test]
fn test_view_longest_match_wins() {
let view = View::new()
.include(oid!(1, 3, 6, 1))
.exclude(oid!(1, 3, 6, 1, 2))
.include(oid!(1, 3, 6, 1, 2, 1));
assert!(view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 3, 1, 0)));
assert!(view.contains(&oid!(1, 3, 6, 1, 4, 1, 0)));
}
#[test]
fn test_view_longest_match_exclude_wins() {
let view = View::new()
.include(oid!(1, 3, 6, 1))
.exclude(oid!(1, 3, 6, 1, 2, 1));
assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
assert!(view.contains(&oid!(1, 3, 6, 1, 4, 1, 0)));
}
#[test]
fn test_view_identical_oid_collision_resolves_to_exclusion() {
let view = View::new()
.include(oid!(1, 3, 6, 1, 2, 1))
.exclude(oid!(1, 3, 6, 1, 2, 1));
assert!(!view.contains(&oid!(1, 3, 6, 1, 2, 1, 1, 0)));
}
#[test]
fn test_view_equal_length_identical_oid_order_independent() {
let include_first = View::new()
.include(oid!(1, 3, 6, 1, 2, 1))
.exclude(oid!(1, 3, 6, 1, 2, 1));
let exclude_first = View::new()
.exclude(oid!(1, 3, 6, 1, 2, 1))
.include(oid!(1, 3, 6, 1, 2, 1));
let query = oid!(1, 3, 6, 1, 2, 1, 1, 0);
assert_eq!(
include_first.contains(&query),
exclude_first.contains(&query),
"identical-OID collision must be order-independent"
);
assert!(
!include_first.contains(&query),
"identical-OID collision resolves conservatively to excluded"
);
}
#[test]
fn test_view_equal_length_masked_lexicographically_greatest_subtree_wins() {
let query = oid!(1, 3, 6, 1, 2, 1, 5, 5);
let mask = vec![0xFE];
for excluded_high in [
View::new()
.include_masked(oid!(1, 3, 6, 1, 2, 1, 5, 1), mask.clone())
.exclude_masked(oid!(1, 3, 6, 1, 2, 1, 5, 9), mask.clone()),
View::new()
.exclude_masked(oid!(1, 3, 6, 1, 2, 1, 5, 9), mask.clone())
.include_masked(oid!(1, 3, 6, 1, 2, 1, 5, 1), mask.clone()),
] {
assert!(!excluded_high.contains(&query));
}
for included_high in [
View::new()
.exclude_masked(oid!(1, 3, 6, 1, 2, 1, 5, 1), mask.clone())
.include_masked(oid!(1, 3, 6, 1, 2, 1, 5, 9), mask.clone()),
View::new()
.include_masked(oid!(1, 3, 6, 1, 2, 1, 5, 9), mask.clone())
.exclude_masked(oid!(1, 3, 6, 1, 2, 1, 5, 1), mask.clone()),
] {
assert!(included_high.contains(&query));
}
}
#[test]
fn test_view_subtree_mask() {
let subtree = ViewSubtree {
oid: oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2), mask: vec![0xFF, 0xC0], included: true,
};
assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1)));
assert!(subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 999)));
assert!(!subtree.matches(&oid!(1, 3, 6, 1, 2, 1, 2, 2, 1, 3, 1)));
}
#[test]
fn test_vacm_group_lookup() {
let mut config = VacmConfig::new();
config.add_group("public", SecurityModel::V2c, "readonly_group");
config.add_group("admin", SecurityModel::Usm, "admin_group");
assert_eq!(
config.get_group(SecurityModel::V2c, b"public"),
Some(&Bytes::from_static(b"readonly_group"))
);
assert_eq!(
config.get_group(SecurityModel::Usm, b"admin"),
Some(&Bytes::from_static(b"admin_group"))
);
assert_eq!(config.get_group(SecurityModel::V1, b"public"), None);
}
#[test]
fn test_vacm_group_prefers_exact_model_and_falls_back_to_any() {
let mut config = VacmConfig::new();
config.add_group("shared", VacmSecurityModel::Any, "fallback_group");
config.add_group("shared", SecurityModel::V2c, "v2c_group");
assert_eq!(
config.get_group(SecurityModel::V2c, b"shared"),
Some(&Bytes::from_static(b"v2c_group"))
);
assert_eq!(
config.get_group(SecurityModel::V1, b"shared"),
Some(&Bytes::from_static(b"fallback_group"))
);
}
#[test]
fn test_vacm_access_lookup() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"readonly_group"),
context_prefix: Bytes::new(),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"full_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
let access = config.get_access(
b"readonly_group",
b"",
SecurityModel::V2c,
SecurityLevel::NoAuthNoPriv,
);
assert!(access.is_some());
assert_eq!(access.unwrap().read_view, Bytes::from_static(b"full_view"));
}
#[test]
fn test_vacm_access_security_level() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"admin_group"),
context_prefix: Bytes::new(),
security_model: SecurityModel::Usm.into(),
security_level: SecurityLevel::AuthPriv, context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"full_view"),
write_view: Bytes::from_static(b"full_view"),
notify_view: Bytes::new(),
})
.unwrap();
let access = config.get_access(
b"admin_group",
b"",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
);
assert!(access.is_none());
let access = config.get_access(
b"admin_group",
b"",
SecurityModel::Usm,
SecurityLevel::AuthPriv,
);
assert!(access.is_some());
}
#[test]
fn test_vacm_check_access() {
let mut config = VacmConfig::new();
config.add_view("full_view", View::new().include(oid!(1, 3, 6, 1)));
assert!(config.check_access(
Some(&Bytes::from_static(b"full_view")),
&oid!(1, 3, 6, 1, 2, 1, 1, 0),
));
assert!(!config.check_access(Some(&Bytes::new()), &oid!(1, 3, 6, 1, 2, 1, 1, 0),));
assert!(!config.check_access(None, &oid!(1, 3, 6, 1, 2, 1, 1, 0),));
assert!(!config.check_access(
Some(&Bytes::from_static(b"unknown_view")),
&oid!(1, 3, 6, 1, 2, 1, 1, 0),
));
}
#[test]
fn test_vacm_builder() {
let config = VacmBuilder::new()
.group("public", SecurityModel::V2c, "readonly_group")
.group("admin", SecurityModel::Usm, "admin_group")
.access(
"readonly_group",
VacmSecurityModel::Any,
SecurityLevel::NoAuthNoPriv,
|a| a.context_prefix("").read_view("full_view"),
)
.access(
"admin_group",
SecurityModel::Usm,
SecurityLevel::AuthPriv,
|a| a.read_view("full_view").write_view("full_view"),
)
.view("full_view", |v| v.include(oid!(1, 3, 6, 1)))
.build()
.unwrap();
assert!(config.get_group(SecurityModel::V2c, b"public").is_some());
assert!(config.get_group(SecurityModel::Usm, b"admin").is_some());
}
#[test]
fn duplicate_access_row_is_rejected_and_preserves_existing_entry() {
let mut config = VacmConfig::new();
let first = access_entry(
b"ctx",
SecurityModel::Usm.into(),
SecurityLevel::AuthNoPriv,
ContextMatch::Exact,
b"first",
);
let duplicate = access_entry(
b"ctx",
SecurityModel::Usm.into(),
SecurityLevel::AuthNoPriv,
ContextMatch::Prefix,
b"duplicate",
);
config.add_access(first).unwrap();
let error = config.add_access(duplicate).unwrap_err();
assert_eq!(error.index().group_name.as_ref(), b"test_group");
assert_eq!(error.index().context_prefix.as_ref(), b"ctx");
assert_eq!(error.index().security_model, SecurityModel::Usm.into());
assert_eq!(error.index().security_level, SecurityLevel::AuthNoPriv);
assert_eq!(
config
.get_access(
b"test_group",
b"ctx",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
)
.unwrap()
.read_view
.as_ref(),
b"first"
);
}
#[test]
fn explicit_access_replacement_is_deterministic() {
let mut config = VacmConfig::new();
let first = access_entry(
b"ctx",
SecurityModel::Usm.into(),
SecurityLevel::AuthNoPriv,
ContextMatch::Exact,
b"first",
);
let replacement = access_entry(
b"ctx",
SecurityModel::Usm.into(),
SecurityLevel::AuthNoPriv,
ContextMatch::Prefix,
b"replacement",
);
assert!(config.replace_access(first).is_none());
let replaced = config.replace_access(replacement).unwrap();
assert_eq!(replaced.read_view.as_ref(), b"first");
let selected = config
.get_access(
b"test_group",
b"ctx/child",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
)
.unwrap();
assert_eq!(selected.read_view.as_ref(), b"replacement");
assert_eq!(selected.context_match, ContextMatch::Prefix);
}
#[test]
fn builder_rejects_duplicates_unless_replacement_is_explicit() {
let duplicate = VacmBuilder::new()
.access(
"test_group",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
|entry| entry.context_prefix("ctx").read_view("first"),
)
.access(
"test_group",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
|entry| {
entry
.context_prefix("ctx")
.context_match_prefix()
.read_view("duplicate")
},
)
.build()
.unwrap_err();
assert_eq!(duplicate.index().context_prefix.as_ref(), b"ctx");
let config = VacmBuilder::new()
.access(
"test_group",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
|entry| entry.context_prefix("ctx").read_view("first"),
)
.replace_access(
"test_group",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
|entry| {
entry
.context_prefix("ctx")
.context_match_prefix()
.read_view("replacement")
},
)
.build()
.unwrap();
assert_eq!(
config
.get_access(
b"test_group",
b"ctx/child",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
)
.unwrap()
.read_view
.as_ref(),
b"replacement"
);
}
#[test]
fn absent_group_and_view_references_remain_valid_configuration() {
let config = VacmBuilder::new()
.access(
"unmapped_group",
SecurityModel::Usm,
SecurityLevel::NoAuthNoPriv,
|entry| entry.read_view("undefined_view"),
)
.build()
.unwrap();
let access = config
.get_access(
b"unmapped_group",
b"",
SecurityModel::Usm,
SecurityLevel::NoAuthNoPriv,
)
.unwrap();
assert!(!config.check_access(Some(&access.read_view), &oid!(1, 3, 6, 1)));
}
#[test]
fn authpriv_user_can_have_distinct_authnopriv_and_authpriv_access() {
let config = VacmBuilder::new()
.group("operator", SecurityModel::Usm, "operators")
.access(
"operators",
SecurityModel::Usm,
SecurityLevel::AuthNoPriv,
|access| access.read_view("read"),
)
.access(
"operators",
SecurityModel::Usm,
SecurityLevel::AuthPriv,
|access| access.read_view("read").write_view("write"),
)
.view("read", |view| view.include(oid!(1, 3, 6)))
.view("write", |view| view.include(oid!(1, 3, 6, 1, 4, 1)))
.build()
.unwrap();
let group = config.get_group(SecurityModel::Usm, b"operator").unwrap();
assert!(
config
.get_access(group, b"", SecurityModel::Usm, SecurityLevel::NoAuthNoPriv,)
.is_none()
);
let authenticated = config
.get_access(group, b"", SecurityModel::Usm, SecurityLevel::AuthNoPriv)
.unwrap();
assert_eq!(authenticated.read_view.as_ref(), b"read");
assert!(authenticated.write_view.is_empty());
let private = config
.get_access(group, b"", SecurityModel::Usm, SecurityLevel::AuthPriv)
.unwrap();
assert_eq!(private.read_view.as_ref(), b"read");
assert_eq!(private.write_view.as_ref(), b"write");
}
#[test]
fn test_vacm_access_prefers_specific_security_model_over_any() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::new(),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"any_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::new(),
security_model: SecurityModel::V2c.into(),
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"v2c_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
let access = config
.get_access(
b"test_group",
b"",
SecurityModel::V2c,
SecurityLevel::NoAuthNoPriv,
)
.expect("should find access entry");
assert_eq!(
access.read_view,
Bytes::from_static(b"v2c_view"),
"should prefer specific security model over Any"
);
let access = config
.get_access(
b"test_group",
b"",
SecurityModel::V1,
SecurityLevel::NoAuthNoPriv,
)
.expect("should fall back to wildcard access entry");
assert_eq!(access.read_view, Bytes::from_static(b"any_view"));
}
#[test]
fn test_vacm_access_prefers_identical_context_prefix() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"c"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::AuthPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"short_high_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"identical_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
let access = config
.get_access(
b"test_group",
b"ctx",
SecurityModel::V2c,
SecurityLevel::AuthPriv,
)
.expect("should find access entry");
assert_eq!(
access.read_view,
Bytes::from_static(b"identical_view"),
"an identical contextPrefix has RFC preference"
);
}
#[test]
fn test_vacm_access_prefers_longer_context_prefix() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"short_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx_longer"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"long_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
let access = config
.get_access(
b"test_group",
b"ctx_longer_suffix",
SecurityModel::V2c,
SecurityLevel::NoAuthNoPriv,
)
.expect("should find access entry");
assert_eq!(
access.read_view,
Bytes::from_static(b"long_view"),
"should prefer longer context prefix"
);
}
#[test]
fn test_vacm_access_prefers_higher_security_level() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::new(),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"noauth_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::new(),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::AuthNoPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"auth_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::new(),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::AuthPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"authpriv_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
for (level, expected_view) in [
(SecurityLevel::NoAuthNoPriv, b"noauth_view".as_slice()),
(SecurityLevel::AuthNoPriv, b"auth_view".as_slice()),
(SecurityLevel::AuthPriv, b"authpriv_view".as_slice()),
] {
let access = config
.get_access(b"test_group", b"", SecurityModel::V2c, level)
.expect("should find access entry");
assert_eq!(access.read_view.as_ref(), expected_view);
}
}
#[test]
fn test_vacm_access_preference_tier_ordering() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::AuthPriv, context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"any_prefix_short_high"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx"),
security_model: SecurityModel::V2c.into(),
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"v2c_prefix_short_low"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
let access = config
.get_access(
b"test_group",
b"ctx_test",
SecurityModel::V2c,
SecurityLevel::AuthPriv,
)
.expect("should find access entry");
assert_eq!(
access.read_view,
Bytes::from_static(b"v2c_prefix_short_low"),
"tier 1 (specific model) should take precedence over tier 4 (security level)"
);
}
#[test]
fn test_vacm_access_preference_prefix_length_over_security() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::AuthPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"short_high_sec"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx_test"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"long_low_sec"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
let access = config
.get_access(
b"test_group",
b"ctx_test_suffix",
SecurityModel::V2c,
SecurityLevel::AuthPriv,
)
.expect("should find access entry");
assert_eq!(
access.read_view,
Bytes::from_static(b"long_low_sec"),
"tier 3 (longer prefix) should take precedence over tier 4 (security level)"
);
}
#[test]
fn test_vacm_access_all_tiers_combined() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"a"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"entry1"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"a"),
security_model: SecurityModel::V2c.into(),
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"entry2"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
let access = config
.get_access(
b"test_group",
b"a",
SecurityModel::V2c,
SecurityLevel::NoAuthNoPriv,
)
.expect("should find access entry");
assert_eq!(
access.read_view,
Bytes::from_static(b"entry2"),
"specific model + exact match should win"
);
}
#[test]
fn test_vacm_access_context_modes_and_selection_are_insertion_independent() {
let exact = VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"exact_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
};
let prefix = VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::from_static(b"ctx/"),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Prefix,
read_view: Bytes::from_static(b"prefix_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
};
for rows in [
[exact.clone(), prefix.clone()],
[prefix.clone(), exact.clone()],
] {
let mut config = VacmConfig::new();
for row in rows {
config.add_access(row).unwrap();
}
assert_eq!(
config
.get_access(
b"test_group",
b"ctx",
SecurityModel::V2c,
SecurityLevel::NoAuthNoPriv,
)
.unwrap()
.read_view
.as_ref(),
b"exact_view"
);
assert_eq!(
config
.get_access(
b"test_group",
b"ctx/child",
SecurityModel::V2c,
SecurityLevel::NoAuthNoPriv,
)
.unwrap()
.read_view
.as_ref(),
b"prefix_view"
);
}
}
#[test]
fn test_vacm_access_higher_security_wins_regardless_of_insertion_order() {
let mut config = VacmConfig::new();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::new(),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::AuthPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"authpriv_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
config
.add_access(VacmAccessEntry {
group_name: Bytes::from_static(b"test_group"),
context_prefix: Bytes::new(),
security_model: VacmSecurityModel::Any,
security_level: SecurityLevel::NoAuthNoPriv,
context_match: ContextMatch::Exact,
read_view: Bytes::from_static(b"noauth_view"),
write_view: Bytes::new(),
notify_view: Bytes::new(),
})
.unwrap();
let access = config
.get_access(
b"test_group",
b"",
SecurityModel::V2c,
SecurityLevel::AuthPriv,
)
.expect("should find access entry");
assert_eq!(
access.read_view,
Bytes::from_static(b"authpriv_view"),
"higher security level should win regardless of insertion order"
);
}
proptest! {
#[test]
fn access_selection_is_independent_of_insertion_order(order_keys in any::<[u32; 4]>()) {
let rows = [
access_entry(
b"c",
VacmSecurityModel::Any,
SecurityLevel::NoAuthNoPriv,
ContextMatch::Prefix,
b"any_short",
),
access_entry(
b"ctx",
VacmSecurityModel::Any,
SecurityLevel::NoAuthNoPriv,
ContextMatch::Prefix,
b"any_identical",
),
access_entry(
b"c",
SecurityModel::V2c.into(),
SecurityLevel::AuthPriv,
ContextMatch::Prefix,
b"exact_model_short_high",
),
access_entry(
b"ctx",
SecurityModel::V2c.into(),
SecurityLevel::AuthNoPriv,
ContextMatch::Prefix,
b"winner",
),
];
let mut order = [0, 1, 2, 3];
order.sort_by_key(|&index| (order_keys[index], index));
let mut config = VacmConfig::new();
for index in order {
config.add_access(rows[index].clone()).unwrap();
}
prop_assert_eq!(
config
.get_access(
b"test_group",
b"ctx",
SecurityModel::V2c,
SecurityLevel::AuthPriv,
)
.unwrap()
.read_view
.as_ref(),
b"winner"
);
}
#[test]
fn equal_length_masked_view_uses_lexicographically_greatest_subtree(
first_arc in 0u32..=255,
second_arc in 0u32..=255,
query_arc in any::<u32>(),
first_included in any::<bool>(),
second_included in any::<bool>(),
reverse in any::<bool>(),
) {
prop_assume!(first_arc != second_arc);
let first = Oid::new([1, 3, 6, 1, 2, 1, 5, first_arc]);
let second = Oid::new([1, 3, 6, 1, 2, 1, 5, second_arc]);
let query = Oid::new([1, 3, 6, 1, 2, 1, 5, query_arc]);
let add = |view: View, oid: Oid, included: bool| {
if included {
view.include_masked(oid, vec![0xfe])
} else {
view.exclude_masked(oid, vec![0xfe])
}
};
let view = if reverse {
add(
add(View::new(), second, second_included),
first,
first_included,
)
} else {
add(
add(View::new(), first, first_included),
second,
second_included,
)
};
let expected = if first_arc > second_arc {
first_included
} else {
second_included
};
prop_assert_eq!(view.contains(&query), expected);
}
}
}