use std::cmp::Ordering;
use parking_lot::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
Explorer = 0,
Validator = 1,
PairedFullnode = 2,
Admin = 3,
}
impl PartialOrd for Role {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Role {
fn cmp(&self, other: &Self) -> Ordering {
(*self as u8).cmp(&(*other as u8))
}
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Admin => "admin",
Role::PairedFullnode => "paired_fullnode",
Role::Validator => "validator",
Role::Explorer => "explorer",
}
}
}
#[derive(Debug, Clone)]
pub enum CertMatcher {
ExactCn(String),
CnGlob(String),
SanDnsGlob(String),
PublicKeyHashHex(String),
}
impl CertMatcher {
pub fn matches(&self, cert: &PeerCertInfo) -> bool {
match self {
CertMatcher::ExactCn(cn) => cert.cn.as_deref() == Some(cn.as_str()),
CertMatcher::CnGlob(pat) => cert
.cn
.as_deref()
.map(|cn| glob_match(pat, cn))
.unwrap_or(false),
CertMatcher::SanDnsGlob(pat) => cert.san_dns.iter().any(|san| glob_match(pat, san)),
CertMatcher::PublicKeyHashHex(hex) => cert
.spki_sha256_hex
.as_deref()
.map(|h| h.eq_ignore_ascii_case(hex))
.unwrap_or(false),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PeerCertInfo {
pub cn: Option<String>,
pub san_dns: Vec<String>,
pub spki_sha256_hex: Option<String>,
}
#[derive(Debug)]
pub struct RoleMap {
entries: RwLock<Vec<RoleMapEntry>>,
default: Role,
}
#[derive(Debug, Clone)]
pub struct RoleMapEntry {
pub matcher: CertMatcher,
pub role: Role,
}
impl RoleMap {
pub fn new(default: Role) -> Self {
Self {
entries: RwLock::new(Vec::new()),
default,
}
}
pub fn push(&self, entry: RoleMapEntry) {
self.entries.write().push(entry);
}
pub fn reload(&self, entries: Vec<RoleMapEntry>) {
*self.entries.write() = entries;
}
pub fn resolve(&self, cert: &PeerCertInfo) -> Role {
let g = self.entries.read();
for e in g.iter() {
if e.matcher.matches(cert) {
return e.role;
}
}
self.default
}
pub fn len(&self) -> usize {
self.entries.read().len()
}
pub fn is_empty(&self) -> bool {
self.entries.read().is_empty()
}
}
pub(crate) fn glob_match(pattern: &str, s: &str) -> bool {
let pb = pattern.as_bytes();
let sb = s.as_bytes();
let (p_len, s_len) = (pb.len(), sb.len());
let mut table = vec![vec![false; s_len + 1]; p_len + 1];
table[0][0] = true;
for (i, &pat_byte) in pb.iter().enumerate() {
if pat_byte == b'*' {
table[i + 1][0] = table[i][0];
}
}
for (i, &pat_byte) in pb.iter().enumerate() {
for (j, &s_byte) in sb.iter().enumerate() {
if pat_byte == b'*' {
table[i + 1][j + 1] = table[i][j + 1] || table[i + 1][j];
} else if pat_byte == s_byte {
table[i + 1][j + 1] = table[i][j];
}
}
}
table[p_len][s_len]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_ordering() {
assert!(Role::Admin > Role::PairedFullnode);
assert!(Role::PairedFullnode > Role::Validator);
assert!(Role::Validator > Role::Explorer);
assert!(Role::Admin > Role::Explorer);
assert_eq!(Role::Admin, Role::Admin);
}
#[test]
fn empty_map_uses_default() {
let rm = RoleMap::new(Role::Explorer);
let resolved = rm.resolve(&PeerCertInfo::default());
assert_eq!(resolved, Role::Explorer);
}
#[test]
fn exact_cn_matches() {
let m = CertMatcher::ExactCn("validator-0".to_string());
let hit = PeerCertInfo {
cn: Some("validator-0".to_string()),
..Default::default()
};
let miss_case = PeerCertInfo {
cn: Some("VALIDATOR-0".to_string()),
..Default::default()
};
let miss_other = PeerCertInfo {
cn: Some("validator-1".to_string()),
..Default::default()
};
assert!(m.matches(&hit));
assert!(!m.matches(&miss_case));
assert!(!m.matches(&miss_other));
}
#[test]
fn glob_cn_matches() {
let m = CertMatcher::CnGlob("validator-*".to_string());
let hits = ["validator-0", "validator-42", "validator-"];
for h in hits {
let info = PeerCertInfo {
cn: Some(h.to_string()),
..Default::default()
};
assert!(m.matches(&info), "{h}");
}
let misses = ["valid-0", "VALIDATOR-0"];
for miss in misses {
let info = PeerCertInfo {
cn: Some(miss.to_string()),
..Default::default()
};
assert!(!m.matches(&info), "{miss}");
}
}
#[test]
fn glob_positions() {
assert!(glob_match("*", "anything"));
assert!(glob_match("abc*", "abcdef"));
assert!(glob_match("*def", "abcdef"));
assert!(glob_match("a*f", "abcdef"));
assert!(glob_match("a*c*f", "abcdef"));
assert!(!glob_match("abc", "abcdef"));
assert!(!glob_match("abc*", "xyabcdef"));
}
#[test]
fn first_match_wins() {
let rm = RoleMap::new(Role::Explorer);
rm.push(RoleMapEntry {
matcher: CertMatcher::ExactCn("foo".to_string()),
role: Role::Admin,
});
rm.push(RoleMapEntry {
matcher: CertMatcher::CnGlob("*".to_string()),
role: Role::Validator,
});
let admin_cert = PeerCertInfo {
cn: Some("foo".to_string()),
..Default::default()
};
assert_eq!(rm.resolve(&admin_cert), Role::Admin);
let other_cert = PeerCertInfo {
cn: Some("bar".to_string()),
..Default::default()
};
assert_eq!(rm.resolve(&other_cert), Role::Validator);
}
#[test]
fn reload_replaces_rules() {
let rm = RoleMap::new(Role::Explorer);
rm.push(RoleMapEntry {
matcher: CertMatcher::ExactCn("foo".to_string()),
role: Role::Admin,
});
rm.reload(vec![RoleMapEntry {
matcher: CertMatcher::ExactCn("bar".to_string()),
role: Role::Validator,
}]);
let foo = PeerCertInfo {
cn: Some("foo".to_string()),
..Default::default()
};
let bar = PeerCertInfo {
cn: Some("bar".to_string()),
..Default::default()
};
assert_eq!(rm.resolve(&foo), Role::Explorer); assert_eq!(rm.resolve(&bar), Role::Validator);
}
}