use alloc::collections::BTreeMap;
use alloc::vec::Vec;
type IdentityFingerprint = Vec<u8>;
pub type GuidPrefixBytes = [u8; 12];
#[derive(Debug, Default)]
pub struct IdentityBindingCache {
bindings: BTreeMap<GuidPrefixBytes, IdentityFingerprint>,
capacity: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindingDecision {
NewBinding,
Reaffirmed,
SquatterRejected {
previous: Vec<u8>,
},
CapacityExhausted,
}
impl IdentityBindingCache {
#[must_use]
pub fn new() -> Self {
Self {
bindings: BTreeMap::new(),
capacity: usize::MAX,
}
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
bindings: BTreeMap::new(),
capacity,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.bindings.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.bindings.is_empty()
}
pub fn observe(
&mut self,
guid_prefix: GuidPrefixBytes,
identity_token_bytes: &[u8],
) -> BindingDecision {
if let Some(existing) = self.bindings.get(&guid_prefix) {
return if existing.as_slice() == identity_token_bytes {
BindingDecision::Reaffirmed
} else {
BindingDecision::SquatterRejected {
previous: existing.clone(),
}
};
}
if self.bindings.len() >= self.capacity {
return BindingDecision::CapacityExhausted;
}
self.bindings
.insert(guid_prefix, identity_token_bytes.to_vec());
BindingDecision::NewBinding
}
pub fn evict(&mut self, guid_prefix: &GuidPrefixBytes) -> bool {
self.bindings.remove(guid_prefix).is_some()
}
#[must_use]
pub fn fingerprint_for(&self, guid_prefix: &GuidPrefixBytes) -> Option<&[u8]> {
self.bindings.get(guid_prefix).map(Vec::as_slice)
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn px(byte: u8) -> GuidPrefixBytes {
[byte; 12]
}
#[test]
fn first_observe_is_new_binding() {
let mut c = IdentityBindingCache::new();
let d = c.observe(px(0xAA), b"identity-token-A");
assert_eq!(d, BindingDecision::NewBinding);
assert_eq!(c.len(), 1);
}
#[test]
fn second_observe_with_same_token_is_reaffirmed() {
let mut c = IdentityBindingCache::new();
c.observe(px(0xAA), b"identity-token-A");
let d = c.observe(px(0xAA), b"identity-token-A");
assert_eq!(d, BindingDecision::Reaffirmed);
assert_eq!(c.len(), 1);
}
#[test]
fn squatter_with_diff_token_is_rejected() {
let mut c = IdentityBindingCache::new();
c.observe(px(0xAA), b"identity-token-A");
let d = c.observe(px(0xAA), b"identity-token-B");
match d {
BindingDecision::SquatterRejected { previous } => {
assert_eq!(previous, b"identity-token-A");
}
other => panic!("expected SquatterRejected, got {other:?}"),
}
assert_eq!(c.fingerprint_for(&px(0xAA)), Some(&b"identity-token-A"[..]));
}
#[test]
fn distinct_prefixes_are_independent() {
let mut c = IdentityBindingCache::new();
assert_eq!(
c.observe(px(0xAA), b"alice-token"),
BindingDecision::NewBinding
);
assert_eq!(
c.observe(px(0xBB), b"bob-token"),
BindingDecision::NewBinding
);
assert_eq!(c.len(), 2);
}
#[test]
fn evict_allows_rebinding_with_new_token() {
let mut c = IdentityBindingCache::new();
c.observe(px(0xAA), b"old-token");
assert!(c.evict(&px(0xAA)));
let d = c.observe(px(0xAA), b"new-token");
assert_eq!(d, BindingDecision::NewBinding);
}
#[test]
fn evict_unknown_prefix_returns_false() {
let mut c = IdentityBindingCache::new();
assert!(!c.evict(&px(0xCC)));
}
#[test]
fn capacity_cap_rejects_new_bindings() {
let mut c = IdentityBindingCache::with_capacity(2);
assert_eq!(c.observe(px(0x01), b"a"), BindingDecision::NewBinding);
assert_eq!(c.observe(px(0x02), b"b"), BindingDecision::NewBinding);
assert_eq!(
c.observe(px(0x03), b"c"),
BindingDecision::CapacityExhausted
);
assert_eq!(c.len(), 2);
}
#[test]
fn capacity_cap_still_allows_reaffirm() {
let mut c = IdentityBindingCache::with_capacity(1);
c.observe(px(0x01), b"a");
assert_eq!(c.observe(px(0x01), b"a"), BindingDecision::Reaffirmed);
}
#[test]
fn capacity_cap_still_detects_squatter() {
let mut c = IdentityBindingCache::with_capacity(1);
c.observe(px(0x01), b"a");
match c.observe(px(0x01), b"b") {
BindingDecision::SquatterRejected { .. } => {}
other => panic!("expected SquatterRejected, got {other:?}"),
}
}
#[test]
fn empty_token_is_distinct_from_some_token() {
let mut c = IdentityBindingCache::new();
c.observe(px(0x01), b"");
let d = c.observe(px(0x01), b"non-empty");
assert!(matches!(d, BindingDecision::SquatterRejected { .. }));
}
}