Skip to main content

dig_rpc/
method.rs

1//! Per-method metadata — used by the middleware to gate access and
2//! attribute rate limits.
3//!
4//! Servers register each method they dispatch with a [`MethodMeta`]
5//! describing:
6//!
7//! - `name` — wire name (e.g., `"get_blockchain_state"`).
8//! - `class` — read / write / admin; drives audit logging.
9//! - `min_role` — the minimum [`Role`](crate::role::Role) required to call.
10//! - `rate_bucket` — which token bucket accounts for this call.
11//! - `public_exposed` — whether the method is served on the public port.
12
13use std::collections::HashMap;
14
15use parking_lot::RwLock;
16
17use crate::role::Role;
18
19/// Broad method class, used by the audit log and the public-port filter.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum MethodClass {
22    /// Read-only lookup.
23    Read,
24    /// State-changing call.
25    Write,
26    /// Operator-only admin (stop_node, ban_peer, etc.).
27    Admin,
28}
29
30/// Named rate-limit bucket.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum RateBucket {
33    /// Cheap reads (get_blockchain_state, healthz).
34    ReadLight,
35    /// Expensive reads (get_block, get_coin_records_by_hint).
36    ReadHeavy,
37    /// Cheap writes (submit_partial_checkpoint_signature).
38    WriteLight,
39    /// Expensive writes (push_tx).
40    WriteHeavy,
41    /// Admin-only methods (stop_node, ban_peer).
42    AdminOnly,
43}
44
45/// Per-method metadata.
46#[derive(Debug, Clone)]
47pub struct MethodMeta {
48    /// JSON-RPC method name (snake_case).
49    pub name: &'static str,
50    /// Classification.
51    pub class: MethodClass,
52    /// Minimum role.
53    pub min_role: Role,
54    /// Rate bucket.
55    pub rate_bucket: RateBucket,
56    /// Whether the method is served on the public (non-admin) port.
57    pub public_exposed: bool,
58}
59
60impl MethodMeta {
61    /// Convenience builder for a read-only method.
62    pub const fn read(name: &'static str, min_role: Role, bucket: RateBucket) -> Self {
63        Self {
64            name,
65            class: MethodClass::Read,
66            min_role,
67            rate_bucket: bucket,
68            public_exposed: matches!(min_role, Role::Explorer),
69        }
70    }
71
72    /// Convenience builder for a write method. Never public-exposed.
73    pub const fn write(name: &'static str, min_role: Role, bucket: RateBucket) -> Self {
74        Self {
75            name,
76            class: MethodClass::Write,
77            min_role,
78            rate_bucket: bucket,
79            public_exposed: false,
80        }
81    }
82
83    /// Convenience builder for an admin method.
84    pub const fn admin(name: &'static str) -> Self {
85        Self {
86            name,
87            class: MethodClass::Admin,
88            min_role: Role::Admin,
89            rate_bucket: RateBucket::AdminOnly,
90            public_exposed: false,
91        }
92    }
93}
94
95/// Registry of method metadata.
96///
97/// Servers consult the registry on every request to decide role / rate /
98/// allow-list enforcement. Clone is cheap (`Arc` internally).
99#[derive(Debug, Default)]
100pub struct MethodRegistry {
101    inner: RwLock<HashMap<&'static str, MethodMeta>>,
102}
103
104impl MethodRegistry {
105    /// Build an empty registry.
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    /// Register a method. Overwrites any existing entry with the same name.
111    pub fn register(&self, meta: MethodMeta) {
112        self.inner.write().insert(meta.name, meta);
113    }
114
115    /// Look up metadata for a method. `None` if not registered (server
116    /// should respond with `MethodNotFound`).
117    pub fn get(&self, name: &str) -> Option<MethodMeta> {
118        self.inner.read().get(name).cloned()
119    }
120
121    /// Register multiple methods at once.
122    pub fn register_all(&self, metas: impl IntoIterator<Item = MethodMeta>) {
123        let mut g = self.inner.write();
124        for m in metas {
125            g.insert(m.name, m);
126        }
127    }
128
129    /// Number of registered methods.
130    pub fn len(&self) -> usize {
131        self.inner.read().len()
132    }
133
134    /// Whether the registry is empty.
135    pub fn is_empty(&self) -> bool {
136        self.inner.read().is_empty()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    /// **Proves:** the `read` / `write` / `admin` builders produce meta
145    /// with the classifications baked in correctly — write/admin are
146    /// never public-exposed even if a bug in the enum ordering would allow
147    /// it.
148    ///
149    /// **Why it matters:** `public_exposed` is the last line of defence
150    /// against accidentally serving `stop_node` on the internet. Any
151    /// regression in the builders would silently open attack surface.
152    ///
153    /// **Catches:** a copy-paste regression between the `read` / `write` /
154    /// `admin` builders that swaps `public_exposed` values.
155    #[test]
156    fn builders_set_public_exposed_correctly() {
157        let r = MethodMeta::read("healthz", Role::Explorer, RateBucket::ReadLight);
158        assert!(r.public_exposed);
159        assert_eq!(r.class, MethodClass::Read);
160
161        let r_admin = MethodMeta::read("get_slashing_db", Role::Admin, RateBucket::ReadLight);
162        assert!(!r_admin.public_exposed); // requires Admin -> NOT public
163
164        let w = MethodMeta::write("push_tx", Role::Explorer, RateBucket::WriteHeavy);
165        assert!(!w.public_exposed); // writes are never public
166        assert_eq!(w.class, MethodClass::Write);
167
168        let a = MethodMeta::admin("stop_node");
169        assert!(!a.public_exposed);
170        assert_eq!(a.min_role, Role::Admin);
171        assert_eq!(a.rate_bucket, RateBucket::AdminOnly);
172    }
173
174    /// **Proves:** `MethodRegistry::get` returns metadata after registration
175    /// and `None` otherwise.
176    ///
177    /// **Why it matters:** `None` → server responds `MethodNotFound`. If
178    /// `get` hallucinated metadata for unregistered methods, every method
179    /// call on an empty server would return `Forbidden`-style errors
180    /// instead of the correct `MethodNotFound`.
181    ///
182    /// **Catches:** a regression where `get` falls back to a permissive
183    /// default (Some(MethodMeta::admin("..."))) instead of None.
184    #[test]
185    fn registry_register_and_lookup() {
186        let r = MethodRegistry::new();
187        assert!(r.is_empty());
188        assert!(r.get("healthz").is_none());
189
190        r.register(MethodMeta::read(
191            "healthz",
192            Role::Explorer,
193            RateBucket::ReadLight,
194        ));
195        assert_eq!(r.len(), 1);
196        let meta = r.get("healthz").unwrap();
197        assert_eq!(meta.name, "healthz");
198        assert_eq!(meta.class, MethodClass::Read);
199    }
200
201    /// **Proves:** re-registering the same method name overwrites the
202    /// previous entry.
203    ///
204    /// **Why it matters:** A live-reload of the method catalogue (e.g.,
205    /// feature-flagging a method off) needs to replace the entry rather
206    /// than leave stale metadata behind.
207    ///
208    /// **Catches:** an insert-only regression that accumulates duplicate
209    /// entries.
210    #[test]
211    fn register_overwrites() {
212        let r = MethodRegistry::new();
213        r.register(MethodMeta::read("m", Role::Explorer, RateBucket::ReadLight));
214        r.register(MethodMeta::admin("m"));
215        assert_eq!(r.len(), 1);
216        assert_eq!(r.get("m").unwrap().class, MethodClass::Admin);
217    }
218}