rightkit_daemon/contract/
method.rs1use super::caller::{Caller, Scope};
2use crate::error::{ServiceError, ERROR_FORBIDDEN, ERROR_NOT_ALLOWED};
3use crate::record::Allowlist;
4use std::collections::{BTreeMap, BTreeSet};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
8pub enum Transport {
9 Local,
11 Remote,
13 Both,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct MethodPolicy {
20 transport: Transport,
21 apps: BTreeSet<String>,
23 scopes: BTreeSet<Scope>,
27}
28
29impl MethodPolicy {
30 pub fn local<I, S>(apps: I) -> Self
32 where
33 I: IntoIterator<Item = S>,
34 S: Into<String>,
35 {
36 Self {
37 transport: Transport::Local,
38 apps: apps.into_iter().map(Into::into).collect(),
39 scopes: BTreeSet::new(),
40 }
41 }
42
43 pub fn remote<I, S>(scopes: I) -> Self
45 where
46 I: IntoIterator<Item = S>,
47 S: Into<Scope>,
48 {
49 Self {
50 transport: Transport::Remote,
51 apps: BTreeSet::new(),
52 scopes: scopes.into_iter().map(Into::into).collect(),
53 }
54 }
55
56 pub fn and_local<I, S>(mut self, apps: I) -> Self
58 where
59 I: IntoIterator<Item = S>,
60 S: Into<String>,
61 {
62 self.apps.extend(apps.into_iter().map(Into::into));
63 self.transport = match self.transport {
64 Transport::Remote => Transport::Both,
65 t => t,
66 };
67 self
68 }
69
70 pub fn and_remote<I, S>(mut self, scopes: I) -> Self
72 where
73 I: IntoIterator<Item = S>,
74 S: Into<Scope>,
75 {
76 self.scopes.extend(scopes.into_iter().map(Into::into));
77 self.transport = match self.transport {
78 Transport::Local => Transport::Both,
79 t => t,
80 };
81 self
82 }
83
84 pub fn transport(&self) -> Transport {
85 self.transport
86 }
87
88 fn permits_scopes(&self, held: &BTreeSet<Scope>) -> bool {
89 self.transport != Transport::Local && self.scopes.iter().any(|s| held.contains(s))
91 }
92
93 fn device_scopes_named(&self) -> bool {
95 !self.scopes.is_empty()
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum TableError {
102 EmptyMethod,
104 BadMethod(String),
108 NoDeviceScopes(String),
110}
111
112impl std::fmt::Display for TableError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 TableError::EmptyMethod => f.write_str("method id is empty"),
116 TableError::BadMethod(m) => write!(f, "method id {m:?} is not a plain method name"),
117 TableError::NoDeviceScopes(m) => write!(
118 f,
119 "method {m:?} is reachable by devices but names no scope (an empty scope set admits no device)"
120 ),
121 }
122 }
123}
124
125impl std::error::Error for TableError {}
126
127pub fn validate_method_id(id: &str) -> Result<(), TableError> {
130 if id.is_empty() {
131 return Err(TableError::EmptyMethod);
132 }
133 let bad = |c: char| {
134 c.is_whitespace()
135 || c.is_control()
136 || matches!(
137 c,
138 '*' | '?' | '[' | ']' | '{' | '}' | '/' | '%' | '#' | '\\'
139 )
140 };
141 if id.chars().any(bad) {
142 return Err(TableError::BadMethod(id.to_string()));
143 }
144 Ok(())
145}
146
147pub(crate) fn authorize_allowlist(
150 list: &Allowlist,
151 caller: &Caller,
152 method: &str,
153) -> Result<(), ServiceError> {
154 let ok = match caller {
155 Caller::LocalApp { app, .. } => list.permits(app, method),
156 Caller::Device { .. } => method == "ping",
157 };
158 if ok {
159 Ok(())
160 } else {
161 Err(ServiceError::new(
162 ERROR_NOT_ALLOWED,
163 format!("{} may not call {method}", caller.label()),
164 ))
165 }
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct MethodTable {
172 methods: BTreeMap<String, MethodPolicy>,
173}
174
175impl MethodTable {
176 pub fn new() -> Self {
177 Self::default()
178 }
179
180 pub fn try_method(
183 mut self,
184 id: impl Into<String>,
185 policy: MethodPolicy,
186 ) -> Result<Self, TableError> {
187 let id = id.into();
188 validate_method_id(&id)?;
189 if policy.transport != Transport::Local && !policy.device_scopes_named() {
190 return Err(TableError::NoDeviceScopes(id));
191 }
192 self.methods.insert(id, policy);
193 Ok(self)
194 }
195
196 #[track_caller]
199 pub fn method(self, id: impl Into<String>, policy: MethodPolicy) -> Self {
200 match self.try_method(id, policy) {
201 Ok(t) => t,
202 Err(e) => panic!("MethodTable::method: {e}"),
203 }
204 }
205
206 pub fn get(&self, id: &str) -> Option<&MethodPolicy> {
207 self.methods.get(id)
208 }
209
210 pub fn ids(&self) -> impl Iterator<Item = &str> {
211 self.methods.keys().map(String::as_str)
212 }
213
214 pub fn allowlist(&self) -> Allowlist {
218 let mut a = Allowlist::new();
219 for (id, p) in &self.methods {
220 if p.transport == Transport::Remote {
221 continue;
222 }
223 for app in &p.apps {
224 a = a.allow(app, [id.clone()]);
225 }
226 }
227 a
228 }
229
230 pub fn authorize_device_call(&self, caller: &Caller, method: &str) -> Result<(), ServiceError> {
234 let both = self
235 .methods
236 .get(method)
237 .is_some_and(|p| p.transport == Transport::Both);
238 if method != "ping" && !both {
239 return Err(ServiceError::new(
240 ERROR_FORBIDDEN,
241 format!("{} may not call {method}", caller.label()),
242 ));
243 }
244 self.authorize(caller, method)
245 }
246
247 pub fn authorize(&self, caller: &Caller, method: &str) -> Result<(), ServiceError> {
250 if method == "ping" {
251 return Ok(());
252 }
253 let policy = self.methods.get(method);
254 match caller {
255 Caller::LocalApp { .. } => authorize_allowlist(&self.allowlist(), caller, method),
256 Caller::Device { id, scopes } => {
257 if policy.is_some_and(|p| p.permits_scopes(scopes)) {
258 Ok(())
259 } else {
260 Err(ServiceError::new(
261 ERROR_FORBIDDEN,
262 format!("{id} may not call {method}"),
263 ))
264 }
265 }
266 }
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::peer::PeerIdentity;
274
275 fn table() -> MethodTable {
276 MethodTable::new()
277 .method("echo", MethodPolicy::local(["*"]))
278 .method("both", MethodPolicy::local(["a"]).and_remote(["s1"]))
279 .method("up", MethodPolicy::remote(["s2"]))
280 }
281
282 #[test]
283 fn allowlist_is_derived_and_skips_remote_only() {
284 let a = table().allowlist();
285 assert!(a.permits("any", "echo"));
286 assert!(a.permits("a", "both"));
287 assert!(!a.permits("b", "both"));
288 assert!(!a.permits("a", "up"));
289 assert!(a.permits("b", "ping"));
290 }
291
292 #[test]
293 fn an_empty_scope_set_admits_no_device() {
294 let p = MethodPolicy::remote(Vec::<String>::new());
295 let held: BTreeSet<Scope> = ["s1".to_string(), "any".to_string()].into();
296 assert!(!p.permits_scopes(&held));
297 assert!(!p.permits_scopes(&BTreeSet::new()));
298 let q = MethodPolicy::local(["a"]).and_remote(Vec::<String>::new());
299 assert!(!q.permits_scopes(&held));
300 }
301
302 #[test]
303 fn device_call_needs_both_transports() {
304 let t = table();
305 let dev = Caller::device("dev_1", ["s1", "s2"]);
306 assert!(t.authorize_device_call(&dev, "both").is_ok());
307 for m in ["up", "echo", "missing"] {
308 assert_eq!(
309 t.authorize_device_call(&dev, m).unwrap_err().code,
310 ERROR_FORBIDDEN,
311 "{m}"
312 );
313 }
314 let no_scope = Caller::device("dev_2", ["other"]);
315 assert_eq!(
316 t.authorize_device_call(&no_scope, "both").unwrap_err().code,
317 ERROR_FORBIDDEN
318 );
319 }
320
321 #[test]
322 fn authorize_by_caller() {
323 let t = table();
324 let app = |n: &str| Caller::local_app(n, PeerIdentity::default());
325 assert!(t.authorize(&app("a"), "both").is_ok());
326 assert_eq!(
327 t.authorize(&app("b"), "both").unwrap_err().code,
328 ERROR_NOT_ALLOWED
329 );
330 assert_eq!(
331 t.authorize(&app("a"), "up").unwrap_err().code,
332 ERROR_NOT_ALLOWED
333 );
334 let dev = Caller::device("dev_1", ["s1"]);
335 assert!(t.authorize(&dev, "both").is_ok());
336 assert_eq!(t.authorize(&dev, "up").unwrap_err().code, ERROR_FORBIDDEN);
337 assert_eq!(t.authorize(&dev, "echo").unwrap_err().code, ERROR_FORBIDDEN);
338 assert_eq!(
339 t.authorize(&dev, "missing").unwrap_err().code,
340 ERROR_FORBIDDEN
341 );
342 assert!(t.authorize(&dev, "ping").is_ok());
343 }
344}