Skip to main content

efema_proto/
name.rs

1//! The name a stream is addressed by.
2
3use std::fmt;
4use std::str::FromStr;
5
6use minicbor::decode::{Decoder, Error as DecodeError};
7use minicbor::encode::{Encoder, Error as EncodeError, Write};
8use minicbor::{Decode, Encode};
9
10/// The longest name a stream may have, in bytes.
11pub const MAX_NAME_LEN: usize = 64;
12
13/// The name of a stream: what goes into the URL, and what a person reads in
14/// the relay's listing.
15///
16/// One to [`MAX_NAME_LEN`] characters, lowercase ASCII letters, digits, `-`,
17/// `_` and `.`, starting with a letter or a digit. Narrow on purpose: a name
18/// travels in a URL path, in a log line and in a file listing, and each of
19/// those has its own idea of what is special. Widening the set later breaks
20/// nobody; narrowing it would.
21///
22/// The relay sees names - they are not sealed. A name that says more than it
23/// should (`bank-passwords`) says it to whoever runs the relay.
24///
25/// ```
26/// use efema_proto::StreamName;
27///
28/// assert!("notes".parse::<StreamName>().is_ok());
29/// assert!("vault.2026-10".parse::<StreamName>().is_ok());
30/// assert!("Notes".parse::<StreamName>().is_err());
31/// assert!("-notes".parse::<StreamName>().is_err());
32/// assert!("a/b".parse::<StreamName>().is_err());
33/// ```
34#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub struct StreamName(String);
36
37/// Why a text is not a stream name.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub enum InvalidName {
40    /// The name is empty.
41    Empty,
42    /// The name is longer than [`MAX_NAME_LEN`] bytes.
43    TooLong(usize),
44    /// The name starts with something other than a letter or a digit.
45    BadStart(char),
46    /// The name contains a character outside the allowed set.
47    BadCharacter(char),
48}
49
50impl fmt::Display for InvalidName {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Empty => f.write_str("a stream name cannot be empty"),
54            Self::TooLong(len) => {
55                write!(f, "a stream name is at most {MAX_NAME_LEN} characters, this one is {len}")
56            }
57            Self::BadStart(c) => write!(f, "a stream name starts with a letter or a digit, not {c:?}"),
58            Self::BadCharacter(c) => write!(
59                f,
60                "a stream name is made of lowercase letters, digits, '-', '_' and '.', and {c:?} is none of them"
61            ),
62        }
63    }
64}
65
66impl std::error::Error for InvalidName {}
67
68impl StreamName {
69    /// Checks `name` and wraps it.
70    ///
71    /// # Errors
72    ///
73    /// The first reason `name` is not a valid stream name.
74    pub fn new(name: impl Into<String>) -> Result<Self, InvalidName> {
75        let name = name.into();
76        validate(&name)?;
77        Ok(Self(name))
78    }
79
80    /// The name as text.
81    pub fn as_str(&self) -> &str {
82        &self.0
83    }
84}
85
86fn validate(name: &str) -> Result<(), InvalidName> {
87    let mut chars = name.chars();
88    let first = chars.next().ok_or(InvalidName::Empty)?;
89    if name.len() > MAX_NAME_LEN {
90        return Err(InvalidName::TooLong(name.len()));
91    }
92    if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
93        return Err(InvalidName::BadStart(first));
94    }
95    match chars.find(|&c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))) {
96        Some(c) => Err(InvalidName::BadCharacter(c)),
97        None => Ok(()),
98    }
99}
100
101impl FromStr for StreamName {
102    type Err = InvalidName;
103
104    fn from_str(s: &str) -> Result<Self, Self::Err> {
105        Self::new(s)
106    }
107}
108
109impl fmt::Display for StreamName {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        f.write_str(&self.0)
112    }
113}
114
115impl AsRef<str> for StreamName {
116    fn as_ref(&self) -> &str {
117        &self.0
118    }
119}
120
121impl<C> Encode<C> for StreamName {
122    fn encode<W: Write>(&self, e: &mut Encoder<W>, _: &mut C) -> Result<(), EncodeError<W::Error>> {
123        e.str(&self.0)?.ok()
124    }
125}
126
127// Decoding checks the name as well: a name that would be refused in a URL is
128// refused in a body too, so no code path holds one it could not have parsed.
129impl<'b, C> Decode<'b, C> for StreamName {
130    fn decode(d: &mut Decoder<'b>, _: &mut C) -> Result<Self, DecodeError> {
131        let position = d.position();
132        let text = d.str()?;
133        Self::new(text).map_err(|e| DecodeError::message(e.to_string()).at(position))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn accepts_the_documented_alphabet() {
143        for name in ["a", "0", "notes", "vault-2026", "a_b", "x.y.z", "9lives", &"a".repeat(MAX_NAME_LEN)] {
144            assert!(StreamName::new(name).is_ok(), "{name:?} should be a valid name");
145        }
146    }
147
148    #[test]
149    fn refuses_with_the_first_reason() {
150        assert_eq!(StreamName::new(""), Err(InvalidName::Empty));
151        assert_eq!(StreamName::new("a".repeat(MAX_NAME_LEN + 1)), Err(InvalidName::TooLong(MAX_NAME_LEN + 1)));
152        assert_eq!(StreamName::new("-a"), Err(InvalidName::BadStart('-')));
153        assert_eq!(StreamName::new(".a"), Err(InvalidName::BadStart('.')));
154        assert_eq!(StreamName::new("Notes"), Err(InvalidName::BadStart('N')));
155        assert_eq!(StreamName::new("no tes"), Err(InvalidName::BadCharacter(' ')));
156        assert_eq!(StreamName::new("a/b"), Err(InvalidName::BadCharacter('/')));
157        assert_eq!(StreamName::new("a:b"), Err(InvalidName::BadCharacter(':')));
158        assert_eq!(StreamName::new("заметки"), Err(InvalidName::BadStart('з')));
159        assert_eq!(StreamName::new("aé"), Err(InvalidName::BadCharacter('é')));
160    }
161
162    #[test]
163    fn decoding_refuses_what_parsing_refuses() {
164        let encoded = minicbor::to_vec("Bad Name").unwrap();
165        assert!(minicbor::decode::<StreamName>(&encoded).is_err());
166        let encoded = minicbor::to_vec("good-name").unwrap();
167        assert_eq!(minicbor::decode::<StreamName>(&encoded).unwrap().as_str(), "good-name");
168    }
169}