Skip to main content

melin_app/
auth.rs

1//! Connection-level role model for application access control, plus the
2//! `authorized_keys` file loader that maps Ed25519 public keys to roles.
3//!
4//! Both live in `melin-app` (next to the [`Application` trait](crate::Application))
5//! because the role taxonomy ("who can do what to my app") and the
6//! deployment-time mapping of operator-managed keys to roles are
7//! application-shaped concerns, not wire-format concerns. The
8//! wire-shaped helper for the challenge-response signing payload
9//! lives in `melin-protocol::auth`.
10//!
11//! The runtime owns the two roles it acts on, `operator` and
12//! `replication`. Every other role is the application's: it declares them
13//! as a type implementing [`Role`](crate::auth::Role), and its decoder
14//! receives a [`ClientRole`](crate::auth::ClientRole) of that type with
15//! every request.
16
17use std::any::TypeId;
18use std::collections::HashMap;
19use std::fmt;
20use std::io;
21use std::path::Path;
22
23use base64::Engine;
24use base64::engine::general_purpose::STANDARD as BASE64;
25
26/// The token of the runtime's operator role in the `authorized_keys` file.
27const OPERATOR_TOKEN: &str = "operator";
28/// The token of the runtime's replication role in the `authorized_keys`
29/// file.
30const REPLICATION_TOKEN: &str = "replication";
31
32/// The roles an application declares for its clients, each named by a
33/// token in the `authorized_keys` file.
34///
35/// Usually a fieldless enum:
36///
37/// ```
38/// use melin_app::auth::Role;
39///
40/// #[derive(Clone, Copy, Debug, PartialEq, Eq)]
41/// enum PaymentsRole {
42///     Payer,
43///     Auditor,
44/// }
45///
46/// impl Role for PaymentsRole {
47///     const ROLES: &'static [(&'static str, Self)] =
48///         &[("payer", PaymentsRole::Payer), ("auditor", PaymentsRole::Auditor)];
49/// }
50///
51/// melin_app::auth::validate_roles::<PaymentsRole>().expect("a valid role table");
52/// ```
53///
54/// The table is checked by [`validate_roles`] whenever a keys file is
55/// parsed, so a node refuses a bad table before it serves. The order of
56/// the table is free: inside the runtime a role travels as its index in
57/// the table, but that index is never persisted or sent, so adding or
58/// reordering roles between two builds changes nothing a node reads back.
59pub trait Role: Copy + Eq + fmt::Debug + Send + Sync + 'static {
60    /// Every role, each with the token that names it in `authorized_keys`.
61    /// One entry per role, and one role per token.
62    const ROLES: &'static [(&'static str, Self)];
63}
64
65/// The role type of an application with no client roles of its own: its
66/// keys file lists `operator` and `replication` keys only. Also what a
67/// keys table for replication keys alone is parsed with.
68///
69/// Not a default access model: an application on `NoRoles` admits
70/// operator keys and nothing else on its client listener.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum NoRoles {}
73
74impl Role for NoRoles {
75    const ROLES: &'static [(&'static str, Self)] = &[];
76}
77
78/// Check an application's role table: every token well-formed and its
79/// own, no token or role listed twice, and no more roles than a
80/// [`RoleId`] indexes.
81///
82/// Every parse of a keys file calls this first. An application pins its
83/// own table with a one-line test calling it.
84///
85/// A token is lowercase ASCII letters, digits, `-` and `_`, starting with
86/// a letter; matching a token in the file is exact. The tokens `operator`
87/// and `replication` are the runtime's.
88pub fn validate_roles<R: Role>() -> Result<(), String> {
89    let roles = R::ROLES;
90    if roles.len() > RoleId::CAPACITY {
91        return Err(format!(
92            "role table: {} roles, more than the {} a role index holds",
93            roles.len(),
94            RoleId::CAPACITY
95        ));
96    }
97    // Pairwise comparison, quadratic in the table's length: the table holds
98    // at most `RoleId::CAPACITY` entries and is checked once per keys-file
99    // parse, and `Role` asks for `Eq` only, not `Hash` or `Ord`.
100    for (i, &(token, role)) in roles.iter().enumerate() {
101        if !is_valid_token(token) {
102            return Err(format!(
103                "role table: '{token}' is not a valid role token (lowercase ASCII \
104                 letters, digits, '-' and '_', starting with a letter)"
105            ));
106        }
107        if token == OPERATOR_TOKEN || token == REPLICATION_TOKEN {
108            return Err(format!(
109                "role table: '{token}' is the runtime's own role token"
110            ));
111        }
112        for &(earlier_token, earlier_role) in &roles[..i] {
113            if earlier_token == token {
114                return Err(format!("role table: token '{token}' is listed twice"));
115            }
116            if earlier_role == role {
117                return Err(format!(
118                    "role table: {role:?} is listed twice, as '{earlier_token}' and '{token}'"
119                ));
120            }
121        }
122    }
123    Ok(())
124}
125
126/// Whether `token` fits `[a-z][a-z0-9_-]*`.
127fn is_valid_token(token: &str) -> bool {
128    let mut bytes = token.bytes();
129    matches!(bytes.next(), Some(b'a'..=b'z'))
130        && bytes.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_'))
131}
132
133/// An application role as the runtime carries it: its index in the
134/// application's [`Role::ROLES`] table.
135///
136/// The runtime never names the application's role type. It stores this
137/// per connection, and the decoder seam turns it back into the typed role
138/// before the application sees it. Only parsing a keys file makes one.
139/// Never persisted or sent.
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub struct RoleId(
142    /// `u8`: a connection's role sits beside other per-connection state on
143    /// the reader and in the DPDK connection table, and no access model
144    /// needs more than 256 roles.
145    u8,
146);
147
148impl RoleId {
149    /// How many roles a table may hold: one per `u8` value.
150    const CAPACITY: usize = u8::MAX as usize + 1;
151
152    /// The role's index in its table.
153    pub(crate) fn index(self) -> usize {
154        usize::from(self.0)
155    }
156}
157
158/// Who a connection on the client listener is: the runtime's operator, or
159/// one of the application's own roles. The application's decoder receives
160/// one with every request and decides what it may do.
161///
162/// There is no replication variant: the client listener refuses a
163/// replication key during the handshake, so no decoder ever sees one.
164///
165/// Exhaustive on purpose, not `#[non_exhaustive]`: that would force every
166/// decoder to write a wildcard arm, and a wildcard in an access check is a
167/// default grant that a role added later would inherit silently. A new
168/// runtime role is a compile error in every decoder instead.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub enum ClientRole<R> {
171    /// The node's administrator. Also opens the admin endpoint.
172    Operator,
173    /// One of the application's own roles.
174    App(R),
175}
176
177impl<R> ClientRole<R> {
178    /// Whether this is the runtime's operator role.
179    pub fn is_operator(&self) -> bool {
180        matches!(self, ClientRole::Operator)
181    }
182}
183
184/// What the `authorized_keys` file grants a key: streaming between nodes,
185/// or a connection to the client listener under a [`ClientRole`].
186///
187/// Every handshake decides on this — the client listener, the admin
188/// endpoint, the replication and control-plane handshakes, and any
189/// listener of the application's own that admits the same keys — and none
190/// of them needs the application's role type: an application role is its
191/// [`RoleId`] here. [`AuthorizedKeys::token`] names one for a log line.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum KeyRole {
194    /// Journal streaming between primary and replica, and the
195    /// control-plane mesh. Refused on the client listener.
196    Replication,
197    /// A client connection with this role.
198    Client(ClientRole<RoleId>),
199}
200
201impl KeyRole {
202    /// Whether this key authorizes replication connections (journal
203    /// streaming between primary and replica, and the control-plane mesh).
204    pub fn is_replication(self) -> bool {
205        matches!(self, KeyRole::Replication)
206    }
207
208    /// The role a connection with this key has on the client listener, or
209    /// `None` if the key may not connect there at all. A replication key
210    /// authorizes streaming between nodes and nothing else, so the runtime
211    /// refuses it during the handshake: no request of its reaches the
212    /// application's decoder, and no application has to remember to refuse
213    /// it. A listener of the application's own that admits the same keys
214    /// should call the server runtime's `client_auth::verify_client`, which
215    /// applies this rule and also checks the key's signature, rather than
216    /// rebuild the check from this.
217    pub fn client(self) -> Option<ClientRole<RoleId>> {
218        match self {
219            KeyRole::Replication => None,
220            KeyRole::Client(role) => Some(role),
221        }
222    }
223}
224
225/// Maps Ed25519 public keys to the role each is listed under.
226///
227/// HashMap for O(1) lookup by public key bytes. Loaded once at server
228/// startup and shared (immutably) across threads via `Arc`.
229///
230/// One type whatever the application's roles: parsing is generic over the
231/// [`Role`] type, the table is not, so the runtime components that only
232/// act on `operator` and `replication` never name the application's type.
233#[derive(Debug)]
234pub struct AuthorizedKeys {
235    /// Public key bytes (32 bytes) → the role the file lists it under.
236    keys: HashMap<[u8; 32], KeyRole>,
237    /// The application's role tokens, indexed by [`RoleId`], so logs and
238    /// errors name a role as the operator wrote it. A boxed slice: fixed
239    /// once parsed.
240    app_tokens: Box<[&'static str]>,
241    /// The [`Role`] type the file was parsed for. Checked where the table
242    /// is paired with a decoder, so a table parsed for another role type
243    /// can never turn an index into the wrong role.
244    role_type: TypeId,
245}
246
247impl AuthorizedKeys {
248    /// Load authorized keys from a file, for the application roles `R`.
249    ///
250    /// File format (one entry per line):
251    /// ```text
252    /// # <role> <base64-encoded-public-key> <optional-comment>
253    /// operator AAAA...base64... ops-team
254    /// writer BBBB...base64... desk-1
255    /// reader DDDD...base64... monitoring
256    /// ```
257    ///
258    /// A role is `operator`, `replication`, or a token of `R`. Lines
259    /// starting with `#` and empty lines are ignored. A key may be listed
260    /// once: a second line for the same key is refused rather than
261    /// resolved, since which role an operator meant is not the loader's to
262    /// guess.
263    pub fn load<R: Role>(path: &Path) -> io::Result<Self> {
264        let content = std::fs::read_to_string(path)?;
265        Self::parse::<R>(&content).map_err(|e| io::Error::other(format!("{path:?}: {e}")))
266    }
267
268    /// Parse authorized keys from a string, for the application roles `R`.
269    /// Separated from `load` for testing. Validates `R`'s table first (see
270    /// [`validate_roles`]), even for an empty file.
271    pub fn parse<R: Role>(content: &str) -> Result<Self, String> {
272        validate_roles::<R>()?;
273        let mut keys = HashMap::new();
274
275        for (line_num, line) in content.lines().enumerate() {
276            let line = line.trim();
277            if line.is_empty() || line.starts_with('#') {
278                continue;
279            }
280
281            let mut parts = line.split_whitespace();
282            let role_token = parts
283                .next()
284                .ok_or_else(|| format!("line {}: missing role", line_num + 1))?;
285            let key_b64 = parts
286                .next()
287                .ok_or_else(|| format!("line {}: missing public key", line_num + 1))?;
288
289            let role = role_for_token::<R>(role_token).ok_or_else(|| {
290                let expected: Vec<&str> = [OPERATOR_TOKEN, REPLICATION_TOKEN]
291                    .into_iter()
292                    .chain(R::ROLES.iter().map(|&(token, _)| token))
293                    .collect();
294                format!(
295                    "line {}: unknown role '{role_token}' (expected {})",
296                    line_num + 1,
297                    expected.join(", ")
298                )
299            })?;
300
301            let key_bytes = BASE64
302                .decode(key_b64)
303                .map_err(|e| format!("line {}: invalid base64: {e}", line_num + 1))?;
304
305            if key_bytes.len() != 32 {
306                return Err(format!(
307                    "line {}: public key must be 32 bytes, got {}",
308                    line_num + 1,
309                    key_bytes.len()
310                ));
311            }
312
313            let mut key = [0u8; 32];
314            key.copy_from_slice(&key_bytes);
315            if keys.insert(key, role).is_some() {
316                return Err(format!(
317                    "line {}: public key already listed on an earlier line",
318                    line_num + 1
319                ));
320            }
321        }
322
323        Ok(Self {
324            keys,
325            app_tokens: R::ROLES.iter().map(|&(token, _)| token).collect(),
326            role_type: TypeId::of::<R>(),
327        })
328    }
329
330    /// Look up the role a public key is listed under. Returns `None` if
331    /// the key is not authorized.
332    pub fn lookup(&self, public_key: &[u8; 32]) -> Option<KeyRole> {
333        self.keys.get(public_key).copied()
334    }
335
336    /// The token naming `role` in the keys file, for a log line or an
337    /// error. `role` is expected to come from this table; an application
338    /// role this table has no token for is named `?`.
339    pub fn token(&self, role: KeyRole) -> &'static str {
340        match role {
341            KeyRole::Replication => REPLICATION_TOKEN,
342            KeyRole::Client(ClientRole::Operator) => OPERATOR_TOKEN,
343            KeyRole::Client(ClientRole::App(id)) => {
344                self.app_tokens.get(id.index()).copied().unwrap_or("?")
345            }
346        }
347    }
348
349    /// Whether this table was parsed for the application roles `R`.
350    pub(crate) fn is_for<R: Role>(&self) -> bool {
351        self.role_type == TypeId::of::<R>()
352    }
353
354    /// Number of authorized keys.
355    pub fn len(&self) -> usize {
356        self.keys.len()
357    }
358
359    /// Whether the keys file is empty (no authorized keys).
360    pub fn is_empty(&self) -> bool {
361        self.keys.is_empty()
362    }
363}
364
365/// The role `token` names for an application with roles `R`: the
366/// runtime's two first, then `R`'s table. `validate_roles` keeps the two
367/// sets apart.
368fn role_for_token<R: Role>(token: &str) -> Option<KeyRole> {
369    match token {
370        OPERATOR_TOKEN => Some(KeyRole::Client(ClientRole::Operator)),
371        REPLICATION_TOKEN => Some(KeyRole::Replication),
372        _ => R::ROLES
373            .iter()
374            .position(|&(app_token, _)| app_token == token)
375            .map(|index| {
376                // `validate_roles` caps the table at `RoleId::CAPACITY`
377                // entries, so every index fits a `u8`.
378                let id = u8::try_from(index).expect("role table validated to fit a u8 index");
379                KeyRole::Client(ClientRole::App(RoleId(id)))
380            }),
381    }
382}
383
384#[cfg(test)]
385pub(crate) mod tests {
386    use super::*;
387
388    /// The key used by the single-line fixtures below: 32 zero bytes.
389    const ZERO_KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
390
391    /// A role type shaped like a real application's, for the tests here
392    /// and in `decoder`.
393    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
394    pub(crate) enum DeskRole {
395        Trader,
396        Custodian,
397        ReadOnly,
398    }
399
400    impl Role for DeskRole {
401        const ROLES: &'static [(&'static str, Self)] = &[
402            ("trader", DeskRole::Trader),
403            ("custodian", DeskRole::Custodian),
404            ("readonly", DeskRole::ReadOnly),
405        ];
406    }
407
408    /// A one-line keys table listing `ZERO_KEY` under `token`.
409    fn listing<R: Role>(token: &str) -> Result<AuthorizedKeys, String> {
410        AuthorizedKeys::parse::<R>(&format!("{token} {ZERO_KEY} test\n"))
411    }
412
413    /// Declares a role type named `$name` whose table is `$table`, for the
414    /// validation tests: each needs a type of its own, since the table is
415    /// an associated constant.
416    macro_rules! role_table {
417        ($name:ident, $table:expr) => {
418            // Not every table lists both variants.
419            #[allow(dead_code)]
420            #[derive(Clone, Copy, Debug, PartialEq, Eq)]
421            enum $name {
422                A,
423                B,
424            }
425            impl Role for $name {
426                const ROLES: &'static [(&'static str, Self)] = $table;
427            }
428        };
429    }
430
431    #[test]
432    fn a_valid_role_table_is_accepted() {
433        validate_roles::<DeskRole>().unwrap();
434        validate_roles::<NoRoles>().unwrap();
435        role_table!(Mixed, &[("desk-2", Mixed::A), ("read_only9", Mixed::B)]);
436        validate_roles::<Mixed>().unwrap();
437    }
438
439    #[test]
440    fn a_runtime_token_in_the_role_table_is_refused() {
441        role_table!(ClaimsOperator, &[("operator", ClaimsOperator::A)]);
442        role_table!(ClaimsReplication, &[("replication", ClaimsReplication::A)]);
443        for err in [
444            validate_roles::<ClaimsOperator>().unwrap_err(),
445            validate_roles::<ClaimsReplication>().unwrap_err(),
446        ] {
447            assert!(err.contains("is the runtime's own role token"), "{err}");
448        }
449    }
450
451    #[test]
452    fn a_token_listed_twice_is_refused() {
453        role_table!(Twice, &[("desk", Twice::A), ("desk", Twice::B)]);
454        let err = validate_roles::<Twice>().unwrap_err();
455        assert_eq!(err, "role table: token 'desk' is listed twice");
456    }
457
458    /// One token per role: an alias is refused, and can be allowed later
459    /// without breaking anything.
460    #[test]
461    fn a_role_listed_twice_is_refused() {
462        role_table!(Alias, &[("desk", Alias::A), ("trading-desk", Alias::A)]);
463        let err = validate_roles::<Alias>().unwrap_err();
464        assert_eq!(
465            err,
466            "role table: A is listed twice, as 'desk' and 'trading-desk'"
467        );
468    }
469
470    #[test]
471    fn a_token_outside_the_charset_is_refused() {
472        role_table!(Capital, &[("Trader", Capital::A)]);
473        role_table!(Empty, &[("", Empty::A)]);
474        role_table!(Space, &[("read only", Space::A)]);
475        role_table!(Comment, &[("#desk", Comment::A)]);
476        role_table!(LeadingDigit, &[("9desk", LeadingDigit::A)]);
477        role_table!(Colon, &[("desk:1", Colon::A)]);
478        for err in [
479            validate_roles::<Capital>().unwrap_err(),
480            validate_roles::<Empty>().unwrap_err(),
481            validate_roles::<Space>().unwrap_err(),
482            validate_roles::<Comment>().unwrap_err(),
483            validate_roles::<LeadingDigit>().unwrap_err(),
484            validate_roles::<Colon>().unwrap_err(),
485        ] {
486            assert!(err.contains("is not a valid role token"), "{err}");
487        }
488    }
489
490    /// A table longer than a `RoleId` indexes is refused. Built from a
491    /// role type whose values are the index itself, since a fieldless enum
492    /// of that size is not worth writing out.
493    #[test]
494    fn a_table_longer_than_a_role_id_indexes_is_refused() {
495        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
496        struct Numbered(u16);
497
498        // The tokens need not differ: the length is checked first.
499        const TABLE: [(&str, Numbered); RoleId::CAPACITY + 1] = {
500            let mut table = [("desk", Numbered(0)); RoleId::CAPACITY + 1];
501            let mut i = 0;
502            while i < table.len() {
503                table[i].1 = Numbered(i as u16);
504                i += 1;
505            }
506            table
507        };
508        impl Role for Numbered {
509            const ROLES: &'static [(&'static str, Self)] = &TABLE;
510        }
511
512        let err = validate_roles::<Numbered>().unwrap_err();
513        assert_eq!(
514            err,
515            "role table: 257 roles, more than the 256 a role index holds"
516        );
517    }
518
519    /// A keys file is refused on a bad role table even when it lists no
520    /// key at all.
521    #[test]
522    fn parsing_validates_the_role_table_even_for_an_empty_file() {
523        role_table!(ClaimsOperator, &[("operator", ClaimsOperator::A)]);
524        let err = AuthorizedKeys::parse::<ClaimsOperator>("").unwrap_err();
525        assert!(err.contains("is the runtime's own role token"), "{err}");
526    }
527
528    #[test]
529    fn client_role_is_operator() {
530        assert!(ClientRole::<DeskRole>::Operator.is_operator());
531        assert!(!ClientRole::App(DeskRole::Trader).is_operator());
532    }
533
534    #[test]
535    fn only_replication_has_no_client_role() {
536        assert_eq!(KeyRole::Replication.client(), None);
537        assert!(KeyRole::Replication.is_replication());
538        let operator = KeyRole::Client(ClientRole::Operator);
539        assert_eq!(operator.client(), Some(ClientRole::Operator));
540        assert!(!operator.is_replication());
541    }
542
543    /// The runtime's tokens parse to the runtime's roles whatever the
544    /// application declares.
545    #[test]
546    fn runtime_tokens_parse_to_the_runtime_roles() {
547        for keys in [
548            [
549                listing::<DeskRole>("operator"),
550                listing::<DeskRole>("replication"),
551            ],
552            [
553                listing::<NoRoles>("operator"),
554                listing::<NoRoles>("replication"),
555            ],
556        ] {
557            let [operator, replication] = keys.map(Result::unwrap);
558            assert_eq!(
559                operator.lookup(&[0u8; 32]),
560                Some(KeyRole::Client(ClientRole::Operator))
561            );
562            assert_eq!(replication.lookup(&[0u8; 32]), Some(KeyRole::Replication));
563        }
564    }
565
566    /// Every application role parses from its token to an index that
567    /// names the same token back.
568    #[test]
569    fn every_app_role_round_trips_through_its_token() {
570        for (index, &(token, _)) in DeskRole::ROLES.iter().enumerate() {
571            let keys = listing::<DeskRole>(token).unwrap();
572            let role = keys.lookup(&[0u8; 32]).unwrap();
573            let Some(ClientRole::App(id)) = role.client() else {
574                panic!("{token} must parse to an application role, got {role:?}");
575            };
576            assert_eq!(id.index(), index);
577            assert_eq!(keys.token(role), token);
578        }
579    }
580
581    #[test]
582    fn the_runtime_roles_are_named_by_their_tokens() {
583        let keys = AuthorizedKeys::parse::<NoRoles>("").unwrap();
584        assert_eq!(keys.token(KeyRole::Replication), "replication");
585        assert_eq!(
586            keys.token(KeyRole::Client(ClientRole::Operator)),
587            "operator"
588        );
589    }
590
591    /// An application role an application without roles cannot parse.
592    #[test]
593    fn no_roles_admits_only_the_runtime_tokens() {
594        let err = listing::<NoRoles>("trader").unwrap_err();
595        assert_eq!(
596            err,
597            "line 1: unknown role 'trader' (expected operator, replication)"
598        );
599    }
600
601    /// Matching is exact: the file's tokens are lowercase.
602    #[test]
603    fn a_token_in_another_case_is_unknown() {
604        let err = listing::<DeskRole>("Trader").unwrap_err();
605        assert!(err.contains("unknown role 'Trader'"), "{err}");
606    }
607
608    #[test]
609    fn a_table_knows_the_role_type_it_was_parsed_for() {
610        let keys = AuthorizedKeys::parse::<DeskRole>("").unwrap();
611        assert!(keys.is_for::<DeskRole>());
612        assert!(!keys.is_for::<NoRoles>());
613    }
614
615    // --- AuthorizedKeys ---
616
617    #[test]
618    fn parse_valid_keys_file() {
619        let content = "\
620# Auth keys file
621operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ops-team
622trader AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= market-maker-1
623readonly AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI= monitoring
624";
625        let keys = AuthorizedKeys::parse::<DeskRole>(content).unwrap();
626        assert_eq!(keys.len(), 3);
627
628        let admin_key = BASE64
629            .decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
630            .unwrap();
631        let mut k = [0u8; 32];
632        k.copy_from_slice(&admin_key);
633        assert_eq!(keys.lookup(&k), Some(KeyRole::Client(ClientRole::Operator)));
634    }
635
636    #[test]
637    fn parse_skips_comments_and_blanks() {
638        let content = "\
639# comment
640   # indented comment
641
642operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test
643";
644        let keys = AuthorizedKeys::parse::<NoRoles>(content).unwrap();
645        assert_eq!(keys.len(), 1);
646    }
647
648    /// The error names every role the file may use, the runtime's own
649    /// first, so the operator can see what was meant.
650    #[test]
651    fn parse_rejects_unknown_role_naming_every_valid_one() {
652        let err = listing::<DeskRole>("superuser").unwrap_err();
653        assert_eq!(
654            err,
655            "line 1: unknown role 'superuser' \
656             (expected operator, replication, trader, custodian, readonly)"
657        );
658    }
659
660    #[test]
661    fn parse_rejects_wrong_key_length() {
662        let content = "operator AQID test\n"; // 3 bytes, not 32
663        let result = AuthorizedKeys::parse::<NoRoles>(content);
664        assert!(result.is_err());
665        assert!(result.unwrap_err().contains("32 bytes"));
666    }
667
668    #[test]
669    fn lookup_missing_key_returns_none() {
670        let keys = AuthorizedKeys::parse::<NoRoles>("").unwrap();
671        assert!(keys.lookup(&[0u8; 32]).is_none());
672    }
673
674    /// Two roles for one key is an operator mistake with no safe reading
675    /// (the loader used to keep the last line silently). Refused, and a
676    /// repeat of the same role with it: a key has one line.
677    #[test]
678    fn duplicate_key_is_refused() {
679        for content in [
680            "\
681operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= first
682readonly AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= second
683",
684            "\
685trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= first
686trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= again
687",
688        ] {
689            let err = AuthorizedKeys::parse::<DeskRole>(content).unwrap_err();
690            assert!(
691                err.starts_with("line 2:") && err.contains("already listed"),
692                "unexpected error: {err}"
693            );
694        }
695    }
696
697    #[test]
698    fn empty_file_produces_empty_keys() {
699        let keys = AuthorizedKeys::parse::<NoRoles>("").unwrap();
700        assert!(keys.is_empty());
701        assert_eq!(keys.len(), 0);
702        assert!(keys.lookup(&[0u8; 32]).is_none());
703    }
704
705    #[test]
706    fn parse_rejects_invalid_base64() {
707        let content = "operator not-valid-base64!!! test\n";
708        let result = AuthorizedKeys::parse::<NoRoles>(content);
709        assert!(result.is_err());
710        assert!(result.unwrap_err().contains("invalid base64"));
711    }
712
713    #[test]
714    fn parse_rejects_missing_key_field() {
715        let content = "admin\n";
716        let result = AuthorizedKeys::parse::<NoRoles>(content);
717        assert!(result.is_err());
718        assert!(result.unwrap_err().contains("missing public key"));
719    }
720
721    #[test]
722    fn comments_only_file_produces_empty_keys() {
723        let content = "\
724# only comments
725# nothing else
726  # indented
727";
728        let keys = AuthorizedKeys::parse::<NoRoles>(content).unwrap();
729        assert!(keys.is_empty());
730    }
731
732    #[test]
733    fn load_nonexistent_file_is_error() {
734        let result =
735            AuthorizedKeys::load::<NoRoles>(std::path::Path::new("/nonexistent/path/keys.txt"));
736        assert!(result.is_err());
737    }
738
739    #[test]
740    fn load_from_file() {
741        let dir = tempfile::tempdir().unwrap();
742        let path = dir.path().join("keys.txt");
743        std::fs::write(
744            &path,
745            "trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test\n",
746        )
747        .unwrap();
748        let keys = AuthorizedKeys::load::<DeskRole>(&path).unwrap();
749        assert_eq!(keys.len(), 1);
750    }
751}