use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NodeId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PeerId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum OpKind {
Read,
Write,
TriggerEffect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RemoteOp {
pub kind: OpKind,
pub node: NodeId,
}
impl RemoteOp {
pub fn read(node: NodeId) -> Self {
Self {
kind: OpKind::Read,
node,
}
}
pub fn write(node: NodeId) -> Self {
Self {
kind: OpKind::Write,
node,
}
}
pub fn trigger_effect(node: NodeId) -> Self {
Self {
kind: OpKind::TriggerEffect,
node,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PermissionDenied {
pub peer: PeerId,
pub op: RemoteOp,
}
impl fmt::Display for PermissionDenied {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"peer {} denied {:?} on node {}",
self.peer.0, self.op.kind, self.op.node.0
)
}
}
impl std::error::Error for PermissionDenied {}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct PeerAllowlist {
readable: HashSet<NodeId>,
writable: HashSet<NodeId>,
triggerable: HashSet<NodeId>,
}
impl PeerAllowlist {
fn set_mut(&mut self, kind: OpKind) -> &mut HashSet<NodeId> {
match kind {
OpKind::Read => &mut self.readable,
OpKind::Write => &mut self.writable,
OpKind::TriggerEffect => &mut self.triggerable,
}
}
fn set_ref(&self, kind: OpKind) -> &HashSet<NodeId> {
match kind {
OpKind::Read => &self.readable,
OpKind::Write => &self.writable,
OpKind::TriggerEffect => &self.triggerable,
}
}
fn is_empty(&self) -> bool {
self.readable.is_empty() && self.writable.is_empty() && self.triggerable.is_empty()
}
}
#[derive(Debug, Clone, Default)]
pub struct PeerPermissions {
peers: HashMap<PeerId, PeerAllowlist>,
}
impl PeerPermissions {
pub fn new() -> Self {
Self::default()
}
pub fn allow(&mut self, peer: PeerId, op: RemoteOp) -> bool {
self.peers
.entry(peer)
.or_default()
.set_mut(op.kind)
.insert(op.node)
}
pub fn allow_many<I>(&mut self, peer: PeerId, kind: OpKind, nodes: I)
where
I: IntoIterator<Item = NodeId>,
{
self.peers
.entry(peer)
.or_default()
.set_mut(kind)
.extend(nodes);
}
pub fn revoke(&mut self, peer: PeerId, op: RemoteOp) -> bool {
let Some(allow) = self.peers.get_mut(&peer) else {
return false;
};
let removed = allow.set_mut(op.kind).remove(&op.node);
if allow.is_empty() {
self.peers.remove(&peer);
}
removed
}
pub fn revoke_peer(&mut self, peer: PeerId) -> bool {
self.peers.remove(&peer).is_some()
}
pub fn is_allowed(&self, peer: PeerId, op: RemoteOp) -> bool {
self.peers
.get(&peer)
.is_some_and(|allow| allow.set_ref(op.kind).contains(&op.node))
}
pub fn check(&self, peer: PeerId, op: RemoteOp) -> Result<(), PermissionDenied> {
if self.is_allowed(peer, op) {
Ok(())
} else {
Err(PermissionDenied { peer, op })
}
}
pub fn filter_readable<I>(&self, peer: PeerId, nodes: I) -> Vec<NodeId>
where
I: IntoIterator<Item = NodeId>,
{
match self.peers.get(&peer) {
Some(allow) => nodes
.into_iter()
.filter(|node| allow.readable.contains(node))
.collect(),
None => Vec::new(),
}
}
pub fn peer_count(&self) -> usize {
self.peers.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
const PEER_A: PeerId = PeerId(1);
const PEER_B: PeerId = PeerId(2);
#[test]
fn default_denies_everything() {
let perms = PeerPermissions::new();
assert!(!perms.is_allowed(PEER_A, RemoteOp::read(NodeId(7))));
assert!(!perms.is_allowed(PEER_A, RemoteOp::write(NodeId(7))));
assert!(!perms.is_allowed(PEER_A, RemoteOp::trigger_effect(NodeId(7))));
assert_eq!(perms.peer_count(), 0);
}
#[test]
fn allow_grants_only_that_op() {
let mut perms = PeerPermissions::new();
assert!(perms.allow(PEER_A, RemoteOp::read(NodeId(7))));
assert!(!perms.allow(PEER_A, RemoteOp::read(NodeId(7))));
assert!(perms.is_allowed(PEER_A, RemoteOp::read(NodeId(7))));
assert!(!perms.is_allowed(PEER_A, RemoteOp::write(NodeId(7))));
assert!(!perms.is_allowed(PEER_A, RemoteOp::trigger_effect(NodeId(7))));
assert!(!perms.is_allowed(PEER_A, RemoteOp::read(NodeId(8))));
}
#[test]
fn op_kinds_are_independent() {
let mut perms = PeerPermissions::new();
perms.allow(PEER_A, RemoteOp::write(NodeId(3)));
perms.allow(PEER_A, RemoteOp::trigger_effect(NodeId(3)));
assert!(!perms.is_allowed(PEER_A, RemoteOp::read(NodeId(3))));
assert!(perms.is_allowed(PEER_A, RemoteOp::write(NodeId(3))));
assert!(perms.is_allowed(PEER_A, RemoteOp::trigger_effect(NodeId(3))));
}
#[test]
fn peers_are_isolated() {
let mut perms = PeerPermissions::new();
perms.allow(PEER_A, RemoteOp::read(NodeId(1)));
assert!(!perms.is_allowed(PEER_B, RemoteOp::read(NodeId(1))));
}
#[test]
fn revoke_removes_one_op_and_prunes_empty_peer() {
let mut perms = PeerPermissions::new();
perms.allow(PEER_A, RemoteOp::read(NodeId(1)));
perms.allow(PEER_A, RemoteOp::read(NodeId(2)));
assert!(perms.revoke(PEER_A, RemoteOp::read(NodeId(1))));
assert!(!perms.is_allowed(PEER_A, RemoteOp::read(NodeId(1))));
assert!(perms.is_allowed(PEER_A, RemoteOp::read(NodeId(2))));
assert_eq!(perms.peer_count(), 1);
assert!(!perms.revoke(PEER_A, RemoteOp::read(NodeId(99))));
assert!(perms.revoke(PEER_A, RemoteOp::read(NodeId(2))));
assert_eq!(perms.peer_count(), 0);
}
#[test]
fn revoke_peer_clears_all_ops() {
let mut perms = PeerPermissions::new();
perms.allow(PEER_A, RemoteOp::read(NodeId(1)));
perms.allow(PEER_A, RemoteOp::write(NodeId(1)));
assert!(perms.revoke_peer(PEER_A));
assert_eq!(perms.peer_count(), 0);
assert!(!perms.is_allowed(PEER_A, RemoteOp::read(NodeId(1))));
assert!(!perms.revoke_peer(PEER_B));
}
#[test]
fn allow_many_seeds_a_readable_subgraph() {
let mut perms = PeerPermissions::new();
perms.allow_many(PEER_A, OpKind::Read, [NodeId(1), NodeId(2), NodeId(3)]);
assert!(perms.is_allowed(PEER_A, RemoteOp::read(NodeId(1))));
assert!(perms.is_allowed(PEER_A, RemoteOp::read(NodeId(3))));
assert!(!perms.is_allowed(PEER_A, RemoteOp::write(NodeId(1))));
}
#[test]
fn check_is_fail_closed_with_context() {
let mut perms = PeerPermissions::new();
perms.allow(PEER_A, RemoteOp::read(NodeId(5)));
assert!(perms.check(PEER_A, RemoteOp::read(NodeId(5))).is_ok());
let denied = perms.check(PEER_A, RemoteOp::write(NodeId(5))).unwrap_err();
assert_eq!(denied.peer, PEER_A);
assert_eq!(denied.op, RemoteOp::write(NodeId(5)));
}
#[test]
fn filter_readable_omits_non_allowlisted_nodes_in_order() {
let mut perms = PeerPermissions::new();
perms.allow_many(PEER_A, OpKind::Read, [NodeId(1), NodeId(3)]);
let visible = perms.filter_readable(PEER_A, [NodeId(1), NodeId(2), NodeId(3), NodeId(4)]);
assert_eq!(visible, vec![NodeId(1), NodeId(3)]);
let none = perms.filter_readable(PEER_B, [NodeId(1), NodeId(3)]);
assert!(none.is_empty());
}
#[test]
fn filter_readable_ignores_write_and_effect_grants() {
let mut perms = PeerPermissions::new();
perms.allow(PEER_A, RemoteOp::write(NodeId(1)));
perms.allow(PEER_A, RemoteOp::trigger_effect(NodeId(2)));
let visible = perms.filter_readable(PEER_A, [NodeId(1), NodeId(2)]);
assert!(visible.is_empty());
}
#[cfg(feature = "serde")]
#[test]
fn remote_op_round_trips_through_serde() {
for op in [
RemoteOp::read(NodeId(42)),
RemoteOp::write(NodeId(7)),
RemoteOp::trigger_effect(NodeId(99)),
] {
let json = serde_json::to_string(&op).unwrap();
let back: RemoteOp = serde_json::from_str(&json).unwrap();
assert_eq!(op, back);
}
}
}