xmtp_id/associations/handle.rs
1//! Handles: short public names, such as `nick` or `nick#2341`, that an inbox
2//! claims in its identity log.
3//!
4//! This module holds only the grammar and the limits every validator applies.
5//! Policy that only the backend applies, such as the reserved names and the
6//! time between claims, lives with the backend.
7
8use regex::Regex;
9use std::{fmt, sync::LazyLock};
10use xmtp_common::ErrorCode;
11
12/// The most characters a handle's name part can hold.
13pub const MAX_HANDLE_NAME_LEN: usize = 27;
14
15/// The most `ClaimHandle` actions one association log can hold.
16pub const MAX_HANDLE_CLAIMS: u32 = 64;
17
18/// One more than the highest four-digit code.
19const CODE_LIMIT: u16 = 10_000;
20
21/// The handle grammar: a name part of 1 to [`MAX_HANDLE_NAME_LEN`] characters
22/// that starts with a lowercase ASCII letter, optionally followed by `#` and
23/// exactly four ASCII digits. `$` matches only at the end of the text, so a
24/// trailing newline does not match.
25static GRAMMAR: LazyLock<Regex> = LazyLock::new(|| {
26 Regex::new(r"^[a-z][a-z0-9_-]{0,26}(#[0-9]{4})?$").expect("static regex should always compile")
27});
28
29/// Invalid handle.
30///
31/// The string is not a handle under the handle grammar. Not retryable.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error, ErrorCode)]
33#[error("the string is not a handle under the handle grammar")]
34pub struct InvalidHandle;
35
36/// A string that the handle grammar matches in full. It is the one spelling
37/// of the handle, in lowercase.
38#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
39pub struct Handle(String);
40
41impl Handle {
42 /// Accepts `canonical` only when the grammar matches it in full. A claim's
43 /// handle is part of the signed text, so this never rewrites it: `Nick`
44 /// is rejected, not read as `nick`.
45 pub fn parse(canonical: &str) -> Result<Self, InvalidHandle> {
46 if GRAMMAR.is_match(canonical) {
47 Ok(Self(canonical.to_owned()))
48 } else {
49 Err(InvalidHandle)
50 }
51 }
52
53 /// Converts the ASCII uppercase letters of `input` to lowercase, then
54 /// parses the result. No other character changes, so input that is not
55 /// ASCII is still rejected. Use this for text a user typed, never for a
56 /// signed claim.
57 pub fn from_input(input: &str) -> Result<Self, InvalidHandle> {
58 Self::parse(&input.to_ascii_lowercase())
59 }
60
61 /// The handle with the name part `name` and the code `code`, written as
62 /// four digits with leading zeros. Fails when `code` has more than four
63 /// digits or `name` is not a name part.
64 pub fn with_code(name: &str, code: u16) -> Result<Self, InvalidHandle> {
65 if code >= CODE_LIMIT {
66 return Err(InvalidHandle);
67 }
68 Self::parse(&format!("{name}#{code:04}"))
69 }
70
71 /// The handle text.
72 pub fn as_str(&self) -> &str {
73 &self.0
74 }
75
76 /// The name part: the handle without `#` and its code.
77 pub fn name(&self) -> &str {
78 self.0.split_once('#').map_or(&self.0, |(name, _)| name)
79 }
80}
81
82impl fmt::Display for Handle {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 f.write_str(&self.0)
85 }
86}
87
88impl AsRef<str> for Handle {
89 fn as_ref(&self) -> &str {
90 &self.0
91 }
92}
93
94impl From<Handle> for String {
95 fn from(handle: Handle) -> Self {
96 handle.0
97 }
98}
99
100#[cfg(test)]
101mod tests;