use super::caller::{Caller, Scope};
use crate::error::{ServiceError, ERROR_FORBIDDEN, ERROR_NOT_ALLOWED};
use crate::record::Allowlist;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Transport {
Local,
Remote,
Both,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MethodPolicy {
transport: Transport,
apps: BTreeSet<String>,
scopes: BTreeSet<Scope>,
}
impl MethodPolicy {
pub fn local<I, S>(apps: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
transport: Transport::Local,
apps: apps.into_iter().map(Into::into).collect(),
scopes: BTreeSet::new(),
}
}
pub fn remote<I, S>(scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Scope>,
{
Self {
transport: Transport::Remote,
apps: BTreeSet::new(),
scopes: scopes.into_iter().map(Into::into).collect(),
}
}
pub fn and_local<I, S>(mut self, apps: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.apps.extend(apps.into_iter().map(Into::into));
self.transport = match self.transport {
Transport::Remote => Transport::Both,
t => t,
};
self
}
pub fn and_remote<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Scope>,
{
self.scopes.extend(scopes.into_iter().map(Into::into));
self.transport = match self.transport {
Transport::Local => Transport::Both,
t => t,
};
self
}
pub fn transport(&self) -> Transport {
self.transport
}
fn permits_scopes(&self, held: &BTreeSet<Scope>) -> bool {
self.transport != Transport::Local && self.scopes.iter().any(|s| held.contains(s))
}
fn device_scopes_named(&self) -> bool {
!self.scopes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableError {
EmptyMethod,
BadMethod(String),
NoDeviceScopes(String),
}
impl std::fmt::Display for TableError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TableError::EmptyMethod => f.write_str("method id is empty"),
TableError::BadMethod(m) => write!(f, "method id {m:?} is not a plain method name"),
TableError::NoDeviceScopes(m) => write!(
f,
"method {m:?} is reachable by devices but names no scope (an empty scope set admits no device)"
),
}
}
}
impl std::error::Error for TableError {}
pub fn validate_method_id(id: &str) -> Result<(), TableError> {
if id.is_empty() {
return Err(TableError::EmptyMethod);
}
let bad = |c: char| {
c.is_whitespace()
|| c.is_control()
|| matches!(
c,
'*' | '?' | '[' | ']' | '{' | '}' | '/' | '%' | '#' | '\\'
)
};
if id.chars().any(bad) {
return Err(TableError::BadMethod(id.to_string()));
}
Ok(())
}
pub(crate) fn authorize_allowlist(
list: &Allowlist,
caller: &Caller,
method: &str,
) -> Result<(), ServiceError> {
let ok = match caller {
Caller::LocalApp { app, .. } => list.permits(app, method),
Caller::Device { .. } => method == "ping",
};
if ok {
Ok(())
} else {
Err(ServiceError::new(
ERROR_NOT_ALLOWED,
format!("{} may not call {method}", caller.label()),
))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MethodTable {
methods: BTreeMap<String, MethodPolicy>,
}
impl MethodTable {
pub fn new() -> Self {
Self::default()
}
pub fn try_method(
mut self,
id: impl Into<String>,
policy: MethodPolicy,
) -> Result<Self, TableError> {
let id = id.into();
validate_method_id(&id)?;
if policy.transport != Transport::Local && !policy.device_scopes_named() {
return Err(TableError::NoDeviceScopes(id));
}
self.methods.insert(id, policy);
Ok(self)
}
#[track_caller]
pub fn method(self, id: impl Into<String>, policy: MethodPolicy) -> Self {
match self.try_method(id, policy) {
Ok(t) => t,
Err(e) => panic!("MethodTable::method: {e}"),
}
}
pub fn get(&self, id: &str) -> Option<&MethodPolicy> {
self.methods.get(id)
}
pub fn ids(&self) -> impl Iterator<Item = &str> {
self.methods.keys().map(String::as_str)
}
pub fn allowlist(&self) -> Allowlist {
let mut a = Allowlist::new();
for (id, p) in &self.methods {
if p.transport == Transport::Remote {
continue;
}
for app in &p.apps {
a = a.allow(app, [id.clone()]);
}
}
a
}
pub fn authorize_device_call(&self, caller: &Caller, method: &str) -> Result<(), ServiceError> {
let both = self
.methods
.get(method)
.is_some_and(|p| p.transport == Transport::Both);
if method != "ping" && !both {
return Err(ServiceError::new(
ERROR_FORBIDDEN,
format!("{} may not call {method}", caller.label()),
));
}
self.authorize(caller, method)
}
pub fn authorize(&self, caller: &Caller, method: &str) -> Result<(), ServiceError> {
if method == "ping" {
return Ok(());
}
let policy = self.methods.get(method);
match caller {
Caller::LocalApp { .. } => authorize_allowlist(&self.allowlist(), caller, method),
Caller::Device { id, scopes } => {
if policy.is_some_and(|p| p.permits_scopes(scopes)) {
Ok(())
} else {
Err(ServiceError::new(
ERROR_FORBIDDEN,
format!("{id} may not call {method}"),
))
}
}
}
}
}