Skip to main content

cortexkit_bus_naming/
token.rs

1use std::error::Error;
2use std::fmt;
3
4/// The identity field being interpolated into a NATS name.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum TokenKind {
7    Account,
8    AgentId,
9    SessionId,
10    RoomId,
11    ModuleId,
12    RosterHostId,
13    CredentialPublic,
14    RootProvider,
15    /// The `{event}` token of a module event subject.
16    EventName,
17    /// The `v{version}` token of a module event subject.
18    EventVersion,
19}
20
21impl TokenKind {
22    pub const fn label(self) -> &'static str {
23        match self {
24            Self::Account => "acct",
25            Self::AgentId => "agent_id",
26            Self::SessionId => "session_id",
27            Self::RoomId => "room_id",
28            Self::ModuleId => "module_id",
29            Self::RosterHostId => "roster_host_id",
30            Self::CredentialPublic => "credential_public",
31            Self::RootProvider => "root_provider",
32            Self::EventName => "event",
33            Self::EventVersion => "event_version",
34        }
35    }
36}
37
38/// A token that cannot be interpolated without changing or broadening identity.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct NamingError {
41    kind: TokenKind,
42    token: String,
43    reason: &'static str,
44}
45
46impl NamingError {
47    pub fn kind(&self) -> TokenKind {
48        self.kind
49    }
50
51    pub fn token(&self) -> &str {
52        &self.token
53    }
54
55    pub fn reason(&self) -> &'static str {
56        self.reason
57    }
58
59    fn new(kind: TokenKind, token: &str, reason: &'static str) -> Self {
60        Self {
61            kind,
62            token: token.to_owned(),
63            reason,
64        }
65    }
66}
67
68impl fmt::Display for NamingError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(
71            f,
72            "invalid {} token {:?}: {}",
73            self.kind.label(),
74            self.token,
75            self.reason
76        )
77    }
78}
79
80impl Error for NamingError {}
81
82/// Validates the account-only lexicon `[a-z0-9][a-z0-9_]{0,62}`.
83pub fn validate_account_token(token: &str) -> Result<(), NamingError> {
84    validate_no_hyphen_token(
85        TokenKind::Account,
86        token,
87        "expected [a-z0-9][a-z0-9_]{0,62}; hyphens and normalization are forbidden",
88    )
89}
90
91/// Validates an event name, `[a-z0-9][a-z0-9_]{0,62}`.
92///
93/// NATS splits subjects on dots, so an event name must be exactly one subject
94/// token: a dot would add a level to `ck.{acct}.event.{module_id}.{event}.v{n}`
95/// and shift the version out of place. The hyphen is refused as well, so event
96/// names stay in the narrower lexicon the account token already uses.
97fn validate_event_name(token: &str) -> Result<(), NamingError> {
98    validate_no_hyphen_token(
99        TokenKind::EventName,
100        token,
101        "expected [a-z0-9][a-z0-9_]{0,62}; an event name is one subject token, so dots, hyphens, wildcards and whitespace are forbidden",
102    )
103}
104
105/// Parses the version token of an event subject, `v{n}` with `n >= 1`.
106///
107/// The number is plain decimal without leading zeros, so each version has
108/// exactly one spelling (`v1`, never `v01`) and two subjects for one version
109/// cannot exist.
110pub fn validate_event_version(token: &str) -> Result<u32, NamingError> {
111    let refuse = || {
112        NamingError::new(
113            TokenKind::EventVersion,
114            token,
115            "expected v{n} with n a decimal integer of at least 1 and no leading zero",
116        )
117    };
118    let digits = token.strip_prefix('v').ok_or_else(refuse)?;
119    if digits.is_empty()
120        || digits.starts_with('0')
121        || !digits.bytes().all(|byte| byte.is_ascii_digit())
122    {
123        return Err(refuse());
124    }
125    digits.parse::<u32>().map_err(|_| refuse())
126}
127
128/// The one-token lexicon without hyphens shared by accounts and event names.
129fn validate_no_hyphen_token(
130    kind: TokenKind,
131    token: &str,
132    reason: &'static str,
133) -> Result<(), NamingError> {
134    validate_len_and_first(kind, token)?;
135    if token
136        .bytes()
137        .skip(1)
138        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
139    {
140        Ok(())
141    } else {
142        Err(NamingError::new(kind, token, reason))
143    }
144}
145
146/// Validates the shared identity lexicon `[a-z0-9][a-z0-9_-]{0,62}`.
147///
148/// `CredentialPublic` is an inbox-prefix component rather than a registry token;
149/// it accepts ASCII letters because real NATS public nkeys are uppercase.
150/// `Account` and `EventName` use the narrower lexicon without the hyphen, and
151/// `EventVersion` accepts only `v{n}` (see [`validate_event_version`]).
152pub fn validate_token(kind: TokenKind, token: &str) -> Result<(), NamingError> {
153    match kind {
154        TokenKind::Account => return validate_account_token(token),
155        TokenKind::CredentialPublic => return validate_credential_public(token),
156        TokenKind::EventName => return validate_event_name(token),
157        TokenKind::EventVersion => return validate_event_version(token).map(|_| ()),
158        _ => {}
159    }
160
161    validate_len_and_first(kind, token)?;
162    if token.bytes().skip(1).all(|byte| {
163        byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-'
164    }) {
165        Ok(())
166    } else {
167        Err(NamingError::new(
168            kind,
169            token,
170            "expected [a-z0-9][a-z0-9_-]{0,62}; dots, wildcards, whitespace, and normalization are forbidden",
171        ))
172    }
173}
174
175fn validate_len_and_first(kind: TokenKind, token: &str) -> Result<(), NamingError> {
176    if token.is_empty() || token.len() > 63 {
177        return Err(NamingError::new(
178            kind,
179            token,
180            "token must contain between 1 and 63 ASCII bytes",
181        ));
182    }
183    let first = token.as_bytes()[0];
184    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
185        return Err(NamingError::new(
186            kind,
187            token,
188            "token must start with a lowercase ASCII letter or digit",
189        ));
190    }
191    Ok(())
192}
193
194fn validate_credential_public(token: &str) -> Result<(), NamingError> {
195    if token.is_empty() || token.len() > 63 {
196        return Err(NamingError::new(
197            TokenKind::CredentialPublic,
198            token,
199            "inbox component must contain between 1 and 63 ASCII bytes",
200        ));
201    }
202    if token
203        .bytes()
204        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
205    {
206        Ok(())
207    } else {
208        Err(NamingError::new(
209            TokenKind::CredentialPublic,
210            token,
211            "inbox component may contain only ASCII letters, digits, underscore, or hyphen",
212        ))
213    }
214}