Skip to main content

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;