1use std::fmt;
21use std::path::Path;
22use std::sync::Arc;
23
24use anyhow::{Context, Result};
25use iroh::EndpointId;
26use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
27
28const GRANTS: TableDefinition<'_, &[u8], ()> = TableDefinition::new("grants");
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum Privilege {
38 BlobList,
41}
42
43impl Privilege {
44 pub fn as_str(self) -> &'static str {
46 match self {
47 Privilege::BlobList => "blob-list",
48 }
49 }
50}
51
52impl fmt::Display for Privilege {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 f.write_str(self.as_str())
55 }
56}
57
58impl TryFrom<&str> for Privilege {
59 type Error = anyhow::Error;
60
61 fn try_from(s: &str) -> Result<Self> {
67 match s {
68 "blob-list" => Ok(Privilege::BlobList),
69 _ => anyhow::bail!("unknown privilege: {s:?}"),
70 }
71 }
72}
73
74#[derive(Clone)]
78pub struct GrantStore {
79 db: Arc<Database>,
80}
81
82impl GrantStore {
83 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
92 let db = Database::create(path).context("opening grants database")?;
93 let write = db
94 .begin_write()
95 .context("starting grants init transaction")?;
96 write.open_table(GRANTS).context("creating grants table")?;
97 write.commit().context("committing grants init")?;
98 Ok(Self { db: Arc::new(db) })
99 }
100
101 pub fn grant(&self, privilege: Privilege, peer: EndpointId) -> Result<()> {
109 let key = grant_key(privilege, &peer);
110 let write = self.db.begin_write().context("beginning grant write")?;
111 {
112 let mut table = write.open_table(GRANTS).context("opening grants table")?;
113 table
114 .insert(key.as_slice(), ())
115 .context("inserting grant")?;
116 }
117 write.commit().context("committing grant")?;
118 Ok(())
119 }
120
121 pub fn revoke(&self, privilege: Privilege, peer: EndpointId) -> Result<()> {
129 let key = grant_key(privilege, &peer);
130 let write = self.db.begin_write().context("beginning revoke write")?;
131 {
132 let mut table = write.open_table(GRANTS).context("opening grants table")?;
133 table.remove(key.as_slice()).context("removing grant")?;
134 }
135 write.commit().context("committing revoke")?;
136 Ok(())
137 }
138
139 pub fn has_grant(&self, privilege: Privilege, peer: &EndpointId) -> Result<bool> {
145 let key = grant_key(privilege, peer);
146 let read = self.db.begin_read().context("beginning grant read")?;
147 let table = read.open_table(GRANTS).context("opening grants table")?;
148 Ok(table
149 .get(key.as_slice())
150 .context("querying grant")?
151 .is_some())
152 }
153
154 pub fn list(&self) -> Result<Vec<(Privilege, EndpointId)>> {
160 let read = self.db.begin_read().context("beginning grants list read")?;
161 let table = read.open_table(GRANTS).context("opening grants table")?;
162 let mut result = Vec::new();
163 for item in table.iter().context("iterating grants")? {
164 let (k, _) = item.context("reading grant entry")?;
165 let (privilege, peer) = decode_grant_key(k.value())?;
166 result.push((privilege, peer));
167 }
168 Ok(result)
169 }
170}
171
172fn grant_key(privilege: Privilege, peer: &EndpointId) -> Vec<u8> {
174 let priv_bytes = privilege.as_str().as_bytes();
175 let mut key = Vec::with_capacity(priv_bytes.len() + 1 + 32);
176 key.extend_from_slice(priv_bytes);
177 key.push(b'\0');
178 key.extend_from_slice(peer.as_bytes());
179 key
180}
181
182fn decode_grant_key(key: &[u8]) -> Result<(Privilege, EndpointId)> {
184 let sep = key
185 .iter()
186 .position(|&b| b == b'\0')
187 .context("grant key missing NUL separator")?;
188 let priv_str = std::str::from_utf8(&key[..sep]).context("grant key privilege not UTF-8")?;
189 let privilege = Privilege::try_from(priv_str)?;
190 let peer_bytes: [u8; 32] = key[sep + 1..]
191 .try_into()
192 .context("grant key peer id not 32 bytes")?;
193 let peer = EndpointId::from_bytes(&peer_bytes).context("grant key invalid peer id")?;
194 Ok((privilege, peer))
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use tempfile::tempdir;
201
202 fn open_store() -> (GrantStore, tempfile::TempDir) {
203 let dir = tempdir().unwrap();
204 let store = GrantStore::open(dir.path().join("grants.redb")).unwrap();
205 (store, dir)
206 }
207
208 fn peer() -> EndpointId {
209 iroh::SecretKey::generate().public()
210 }
211
212 #[test]
213 fn grant_and_has_grant_returns_true() {
214 let (store, _dir) = open_store();
215 let peer = peer();
216 store.grant(Privilege::BlobList, peer).unwrap();
217 assert!(store.has_grant(Privilege::BlobList, &peer).unwrap());
218 }
219
220 #[test]
221 fn has_grant_returns_false_for_unknown_peer() {
222 let (store, _dir) = open_store();
223 assert!(!store.has_grant(Privilege::BlobList, &peer()).unwrap());
224 }
225
226 #[test]
227 fn revoke_removes_grant() {
228 let (store, _dir) = open_store();
229 let peer = peer();
230 store.grant(Privilege::BlobList, peer).unwrap();
231 store.revoke(Privilege::BlobList, peer).unwrap();
232 assert!(!store.has_grant(Privilege::BlobList, &peer).unwrap());
233 }
234
235 #[test]
236 fn revoke_non_existent_grant_is_idempotent() {
237 let (store, _dir) = open_store();
238 store.revoke(Privilege::BlobList, peer()).unwrap();
239 }
240
241 #[test]
242 fn grant_is_idempotent() {
243 let (store, _dir) = open_store();
244 let peer = peer();
245 store.grant(Privilege::BlobList, peer).unwrap();
246 store.grant(Privilege::BlobList, peer).unwrap();
247 assert!(store.has_grant(Privilege::BlobList, &peer).unwrap());
248 }
249
250 #[test]
251 fn list_returns_all_current_grants() {
252 let (store, _dir) = open_store();
253 let p1 = peer();
254 let p2 = peer();
255 store.grant(Privilege::BlobList, p1).unwrap();
256 store.grant(Privilege::BlobList, p2).unwrap();
257 let grants = store.list().unwrap();
258 assert_eq!(grants.len(), 2);
259 assert!(grants
260 .iter()
261 .any(|(priv_, id)| *priv_ == Privilege::BlobList && *id == p1));
262 assert!(grants
263 .iter()
264 .any(|(priv_, id)| *priv_ == Privilege::BlobList && *id == p2));
265 }
266
267 #[test]
268 fn list_on_empty_store_returns_empty_vec() {
269 let (store, _dir) = open_store();
270 assert!(store.list().unwrap().is_empty());
271 }
272
273 #[test]
274 fn privilege_display_matches_wire_string() {
275 assert_eq!(Privilege::BlobList.to_string(), "blob-list");
276 }
277
278 #[test]
279 fn privilege_round_trips_through_string() {
280 let p = Privilege::BlobList;
281 assert_eq!(Privilege::try_from(p.as_str()).unwrap(), p);
282 }
283
284 #[test]
285 fn unknown_privilege_string_errors() {
286 assert!(Privilege::try_from("admin").is_err());
287 }
288
289 #[test]
290 fn grants_persist_across_close_and_reopen() {
291 let dir = tempdir().unwrap();
292 let peer = peer();
293 {
294 let store = GrantStore::open(dir.path().join("grants.redb")).unwrap();
295 store.grant(Privilege::BlobList, peer).unwrap();
296 }
297 let store = GrantStore::open(dir.path().join("grants.redb")).unwrap();
298 assert!(store.has_grant(Privilege::BlobList, &peer).unwrap());
299 }
300}