Skip to main content

obby_proto/
casemap.rs

1//! Server casemapping.
2//!
3//! Two nicks or two channel names are the same identity when they fold to the same string under the
4//! server's `CASEMAPPING`. Under `rfc1459` that means `[]\~` fold together with `{}|^`, because the
5//! original protocol treated them as one alphabet. An ASCII `to_lowercase` gets this wrong on every
6//! server that does not advertise `ascii`, which is most of them.
7
8use alloc::string::String;
9use core::fmt;
10
11/// How a server folds case, from the `CASEMAPPING` ISUPPORT token.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
15pub enum Casemapping {
16    /// `A-Z` only.
17    Ascii,
18    /// `A-Z` plus `[]\~` folding onto `{}|^`. The default when a server advertises nothing.
19    #[default]
20    Rfc1459,
21    /// `rfc1459` without the `~` to `^` fold.
22    Rfc1459Strict,
23}
24
25impl Casemapping {
26    /// Read the value of a `CASEMAPPING` token. An unknown value falls back to `rfc1459`, which is
27    /// what a server that advertises nothing is assumed to use.
28    pub fn parse(token: &str) -> Self {
29        match token {
30            "ascii" => Self::Ascii,
31            "rfc1459-strict" => Self::Rfc1459Strict,
32            _ => Self::Rfc1459,
33        }
34    }
35
36    /// Fold one character.
37    pub fn fold_char(self, c: char) -> char {
38        match c {
39            'A'..='Z' => c.to_ascii_lowercase(),
40            '[' | ']' | '\\' if self != Self::Ascii => match c {
41                '[' => '{',
42                ']' => '}',
43                _ => '|',
44            },
45            '~' if self == Self::Rfc1459 => '^',
46            _ => c,
47        }
48    }
49
50    /// Fold a whole name into a key that can be compared and hashed.
51    pub fn fold(self, name: &str) -> CaseFolded {
52        CaseFolded(name.chars().map(|c| self.fold_char(c)).collect())
53    }
54
55    /// Whether two names are the same identity under this mapping, without allocating.
56    pub fn eq(self, a: &str, b: &str) -> bool {
57        let mut a = a.chars();
58        let mut b = b.chars();
59        loop {
60            match (a.next(), b.next()) {
61                (None, None) => return true,
62                (Some(x), Some(y)) if self.fold_char(x) == self.fold_char(y) => {}
63                _ => return false,
64            }
65        }
66    }
67}
68
69/// A nick or channel name folded under a server's casemapping.
70///
71/// Every map keyed by a nick or a channel is keyed by this type, so that a raw `String` can never be
72/// used as an identity by accident.
73///
74/// The mapping that produced it is not stored. Two `CaseFolded` values are only comparable when they
75/// came from the same connection, which is the only place they are ever used together.
76#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
79#[derive(Default)]
80pub struct CaseFolded(String);
81
82impl CaseFolded {
83    /// The folded form.
84    pub fn as_str(&self) -> &str {
85        &self.0
86    }
87
88    /// Take the folded string.
89    pub fn into_string(self) -> String {
90        self.0
91    }
92}
93
94impl AsRef<str> for CaseFolded {
95    fn as_ref(&self) -> &str {
96        &self.0
97    }
98}
99
100impl fmt::Display for CaseFolded {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(&self.0)
103    }
104}
105
106impl core::borrow::Borrow<str> for CaseFolded {
107    fn borrow(&self) -> &str {
108        &self.0
109    }
110}
111
112impl From<CaseFolded> for String {
113    fn from(folded: CaseFolded) -> Self {
114        folded.0
115    }
116}
117
118/// Fold with the default mapping, for the window before ISUPPORT arrives.
119impl From<&str> for CaseFolded {
120    fn from(name: &str) -> Self {
121        Casemapping::default().fold(name)
122    }
123}
124
125impl CaseFolded {
126    /// Wrap a string that is already folded, such as one read back from storage.
127    pub fn already_folded(value: impl Into<String>) -> Self {
128        Self(value.into())
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn ascii_folds_only_letters() {
138        let map = Casemapping::Ascii;
139        assert_eq!(map.fold("NiCk").as_str(), "nick");
140        assert_eq!(map.fold("[a]").as_str(), "[a]");
141    }
142
143    #[test]
144    fn rfc1459_folds_the_bracket_alphabet() {
145        let map = Casemapping::Rfc1459;
146        assert_eq!(map.fold(r"[Nick]\~").as_str(), "{nick}|^");
147    }
148
149    #[test]
150    fn strict_leaves_tilde_alone() {
151        assert_eq!(Casemapping::Rfc1459Strict.fold("~a").as_str(), "~a");
152        assert_eq!(Casemapping::Rfc1459.fold("~a").as_str(), "^a");
153    }
154
155    #[test]
156    fn eq_matches_fold() {
157        let map = Casemapping::Rfc1459;
158        assert!(map.eq("[nick]", "{NICK}"));
159        assert!(!map.eq("nick", "nick2"));
160        assert!(!Casemapping::Ascii.eq("[nick]", "{nick}"));
161    }
162
163    #[test]
164    fn parses_the_isupport_token() {
165        assert_eq!(Casemapping::parse("ascii"), Casemapping::Ascii);
166        assert_eq!(
167            Casemapping::parse("rfc1459-strict"),
168            Casemapping::Rfc1459Strict
169        );
170        assert_eq!(Casemapping::parse("rfc1459"), Casemapping::Rfc1459);
171        assert_eq!(Casemapping::parse("something-else"), Casemapping::Rfc1459);
172    }
173}