1use std::collections::HashMap;
13use std::io;
14use std::path::Path;
15
16use base64::Engine;
17use base64::engine::general_purpose::STANDARD as BASE64;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Permission {
35 Operator,
39 Trader,
42 Custodian,
45 ReadOnly,
47 Replication,
51}
52
53impl Permission {
54 pub fn can_trade(self) -> bool {
57 matches!(self, Permission::Trader)
58 }
59
60 pub fn is_operator(self) -> bool {
64 matches!(self, Permission::Operator)
65 }
66
67 pub fn can_manage_funds(self) -> bool {
70 matches!(self, Permission::Custodian)
71 }
72
73 pub fn is_replication(self) -> bool {
76 matches!(self, Permission::Replication)
77 }
78}
79
80#[derive(Debug)]
85pub struct AuthorizedKeys {
86 keys: HashMap<[u8; 32], Permission>,
88}
89
90impl AuthorizedKeys {
91 pub fn load(path: &Path) -> io::Result<Self> {
103 let content = std::fs::read_to_string(path)?;
104 Self::parse(&content).map_err(|e| io::Error::other(format!("{path:?}: {e}")))
105 }
106
107 pub fn parse(content: &str) -> Result<Self, String> {
109 let mut keys = HashMap::new();
110
111 for (line_num, line) in content.lines().enumerate() {
112 let line = line.trim();
113 if line.is_empty() || line.starts_with('#') {
114 continue;
115 }
116
117 let mut parts = line.split_whitespace();
118 let perm_str = parts
119 .next()
120 .ok_or_else(|| format!("line {}: missing permission", line_num + 1))?;
121 let key_b64 = parts
122 .next()
123 .ok_or_else(|| format!("line {}: missing public key", line_num + 1))?;
124
125 let permission = match perm_str {
126 "operator" => Permission::Operator,
127 "trader" => Permission::Trader,
128 "custodian" => Permission::Custodian,
129 "readonly" => Permission::ReadOnly,
130 "replication" => Permission::Replication,
131 other => {
132 return Err(format!(
133 "line {}: unknown permission '{}' (expected operator/trader/custodian/readonly/replication)",
134 line_num + 1,
135 other
136 ));
137 }
138 };
139
140 let key_bytes = BASE64
141 .decode(key_b64)
142 .map_err(|e| format!("line {}: invalid base64: {e}", line_num + 1))?;
143
144 if key_bytes.len() != 32 {
145 return Err(format!(
146 "line {}: public key must be 32 bytes, got {}",
147 line_num + 1,
148 key_bytes.len()
149 ));
150 }
151
152 let mut key = [0u8; 32];
153 key.copy_from_slice(&key_bytes);
154 keys.insert(key, permission);
155 }
156
157 Ok(Self { keys })
158 }
159
160 pub fn lookup(&self, public_key: &[u8; 32]) -> Option<Permission> {
163 self.keys.get(public_key).copied()
164 }
165
166 pub fn len(&self) -> usize {
168 self.keys.len()
169 }
170
171 pub fn is_empty(&self) -> bool {
173 self.keys.is_empty()
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn permission_can_trade() {
183 assert!(!Permission::Operator.can_trade());
184 assert!(Permission::Trader.can_trade());
185 assert!(!Permission::Custodian.can_trade());
186 assert!(!Permission::ReadOnly.can_trade());
187 assert!(!Permission::Replication.can_trade());
188 }
189
190 #[test]
191 fn permission_is_operator() {
192 assert!(Permission::Operator.is_operator());
193 assert!(!Permission::Trader.is_operator());
194 assert!(!Permission::Custodian.is_operator());
195 assert!(!Permission::ReadOnly.is_operator());
196 assert!(!Permission::Replication.is_operator());
197 }
198
199 #[test]
200 fn permission_can_manage_funds() {
201 assert!(!Permission::Operator.can_manage_funds());
202 assert!(!Permission::Trader.can_manage_funds());
203 assert!(Permission::Custodian.can_manage_funds());
204 assert!(!Permission::ReadOnly.can_manage_funds());
205 assert!(!Permission::Replication.can_manage_funds());
206 }
207
208 #[test]
209 fn permission_is_replication() {
210 assert!(!Permission::Operator.is_replication());
211 assert!(!Permission::Trader.is_replication());
212 assert!(!Permission::Custodian.is_replication());
213 assert!(!Permission::ReadOnly.is_replication());
214 assert!(Permission::Replication.is_replication());
215 }
216
217 #[test]
220 fn parse_valid_keys_file() {
221 let content = "\
222# Auth keys file
223operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ops-team
224trader AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= market-maker-1
225readonly AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI= monitoring
226";
227 let keys = AuthorizedKeys::parse(content).unwrap();
228 assert_eq!(keys.len(), 3);
229
230 let admin_key = BASE64
231 .decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
232 .unwrap();
233 let mut k = [0u8; 32];
234 k.copy_from_slice(&admin_key);
235 assert_eq!(keys.lookup(&k), Some(Permission::Operator));
236 }
237
238 #[test]
239 fn parse_skips_comments_and_blanks() {
240 let content = "\
241# comment
242 # indented comment
243
244operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test
245";
246 let keys = AuthorizedKeys::parse(content).unwrap();
247 assert_eq!(keys.len(), 1);
248 }
249
250 #[test]
251 fn parse_rejects_unknown_permission() {
252 let content = "superuser AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test\n";
253 let result = AuthorizedKeys::parse(content);
254 assert!(result.is_err());
255 assert!(result.unwrap_err().contains("unknown permission"));
256 }
257
258 #[test]
259 fn parse_rejects_wrong_key_length() {
260 let content = "operator AQID test\n"; let result = AuthorizedKeys::parse(content);
262 assert!(result.is_err());
263 assert!(result.unwrap_err().contains("32 bytes"));
264 }
265
266 #[test]
267 fn lookup_missing_key_returns_none() {
268 let keys = AuthorizedKeys::parse("").unwrap();
269 assert!(keys.lookup(&[0u8; 32]).is_none());
270 }
271
272 #[test]
273 fn replication_key_parsed_from_file() {
274 let content = "replication AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= replica-1\n";
275 let keys = AuthorizedKeys::parse(content).unwrap();
276 let pub_key = [0u8; 32];
277 assert_eq!(keys.lookup(&pub_key), Some(Permission::Replication));
278 }
279
280 #[test]
281 fn custodian_key_parsed_from_file() {
282 let content = "custodian AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= treasury\n";
283 let keys = AuthorizedKeys::parse(content).unwrap();
284 let pub_key = [0u8; 32];
285 assert_eq!(keys.lookup(&pub_key), Some(Permission::Custodian));
286 }
287
288 #[test]
289 fn duplicate_key_last_permission_wins() {
290 let content = "\
291operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= first
292readonly AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= second
293";
294 let keys = AuthorizedKeys::parse(content).unwrap();
295 assert_eq!(keys.len(), 1);
297 let key = BASE64
298 .decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
299 .unwrap();
300 let mut k = [0u8; 32];
301 k.copy_from_slice(&key);
302 assert_eq!(keys.lookup(&k), Some(Permission::ReadOnly));
303 }
304
305 #[test]
306 fn empty_file_produces_empty_keys() {
307 let keys = AuthorizedKeys::parse("").unwrap();
308 assert!(keys.is_empty());
309 assert_eq!(keys.len(), 0);
310 assert!(keys.lookup(&[0u8; 32]).is_none());
311 }
312
313 #[test]
314 fn parse_rejects_invalid_base64() {
315 let content = "operator not-valid-base64!!! test\n";
316 let result = AuthorizedKeys::parse(content);
317 assert!(result.is_err());
318 assert!(result.unwrap_err().contains("invalid base64"));
319 }
320
321 #[test]
322 fn parse_rejects_missing_key_field() {
323 let content = "admin\n";
324 let result = AuthorizedKeys::parse(content);
325 assert!(result.is_err());
326 assert!(result.unwrap_err().contains("missing public key"));
327 }
328
329 #[test]
330 fn comments_only_file_produces_empty_keys() {
331 let content = "\
332# only comments
333# nothing else
334 # indented
335";
336 let keys = AuthorizedKeys::parse(content).unwrap();
337 assert!(keys.is_empty());
338 }
339
340 #[test]
341 fn load_nonexistent_file_is_error() {
342 let result = AuthorizedKeys::load(std::path::Path::new("/nonexistent/path/keys.txt"));
343 assert!(result.is_err());
344 }
345
346 #[test]
347 fn load_from_file() {
348 let dir = tempfile::tempdir().unwrap();
349 let path = dir.path().join("keys.txt");
350 std::fs::write(
351 &path,
352 "trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test\n",
353 )
354 .unwrap();
355 let keys = AuthorizedKeys::load(&path).unwrap();
356 assert_eq!(keys.len(), 1);
357 }
358}