Skip to main content

obby_proto/
tags.rs

1//! Message tags and their escaping.
2
3use alloc::borrow::ToOwned;
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7use core::fmt::Write as _;
8
9/// One message tag. A tag with an empty value is the same as a tag with no value, so both parse to
10/// `value: None`.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
14#[cfg_attr(feature = "ts", ts(rename = "MessageTag"))]
15pub struct Tag {
16    /// The tag name, including a leading `+` on a client-only tag and any vendor prefix.
17    pub key: String,
18    /// The unescaped value.
19    pub value: Option<String>,
20}
21
22impl Tag {
23    /// A tag that carries a value.
24    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
25        Self {
26            key: key.into(),
27            value: Some(value.into()),
28        }
29    }
30
31    /// A tag that is only present, with no value.
32    pub fn flag(key: impl Into<String>) -> Self {
33        Self {
34            key: key.into(),
35            value: None,
36        }
37    }
38
39    /// True when this is a client-only tag, which servers relay without interpreting.
40    pub fn is_client_only(&self) -> bool {
41        self.key.starts_with('+')
42    }
43}
44
45/// The tag section of a message, in the order it arrived.
46///
47/// Order is kept rather than folded into a map because a round trip has to reproduce the line, and
48/// because a server may legally send the same key twice.
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
52#[cfg_attr(feature = "ts", ts(rename = "MessageTags"))]
53pub struct Tags(Vec<Tag>);
54
55impl Tags {
56    /// Parse the raw tag section, without its leading `@`.
57    pub fn parse(raw: &str) -> Self {
58        Self(
59            raw.split(';')
60                .filter(|part| !part.is_empty())
61                .map(|part| match part.split_once('=') {
62                    None | Some((_, "")) => Tag::flag(key_of(part)),
63                    Some((key, value)) => Tag::new(key, unescape(value)),
64                })
65                .collect(),
66        )
67    }
68
69    /// The unescaped value of the last tag with this key, if it has one.
70    ///
71    /// A key may legally appear twice, and the specification says to disregard all but the final
72    /// occurrence. The duplicates are still kept so a line renders back exactly as it arrived.
73    pub fn get(&self, key: &str) -> Option<&str> {
74        self.0
75            .iter()
76            .rev()
77            .find(|tag| tag.key == key)?
78            .value
79            .as_deref()
80    }
81
82    /// True when a tag with this key is present, with or without a value.
83    pub fn contains(&self, key: &str) -> bool {
84        self.0.iter().any(|tag| tag.key == key)
85    }
86
87    /// Add a tag, replacing any existing tag with the same key.
88    pub fn set(&mut self, tag: Tag) {
89        match self.0.iter_mut().find(|existing| existing.key == tag.key) {
90            Some(existing) => *existing = tag,
91            None => self.0.push(tag),
92        }
93    }
94
95    /// Remove every tag with this key.
96    pub fn remove(&mut self, key: &str) {
97        self.0.retain(|tag| tag.key != key);
98    }
99
100    /// True when there are no tags, in which case the line carries no `@` section.
101    pub fn is_empty(&self) -> bool {
102        self.0.is_empty()
103    }
104
105    /// How many tags are present.
106    pub fn len(&self) -> usize {
107        self.0.len()
108    }
109
110    /// Iterate the tags in wire order.
111    pub fn iter(&self) -> core::slice::Iter<'_, Tag> {
112        self.0.iter()
113    }
114}
115
116impl FromIterator<Tag> for Tags {
117    fn from_iter<T: IntoIterator<Item = Tag>>(iter: T) -> Self {
118        Self(iter.into_iter().collect())
119    }
120}
121
122impl<'a> IntoIterator for &'a Tags {
123    type Item = &'a Tag;
124    type IntoIter = core::slice::Iter<'a, Tag>;
125
126    fn into_iter(self) -> Self::IntoIter {
127        self.iter()
128    }
129}
130
131impl fmt::Display for Tags {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        for (i, tag) in self.0.iter().enumerate() {
134            if i > 0 {
135                f.write_str(";")?;
136            }
137            f.write_str(&tag.key)?;
138            if let Some(value) = &tag.value {
139                write!(f, "={}", Escaped(value))?;
140            }
141        }
142        Ok(())
143    }
144}
145
146fn key_of(part: &str) -> &str {
147    part.split_once('=').map_or(part, |(key, _)| key)
148}
149
150/// Undo the escaping a tag value carries on the wire.
151///
152/// A backslash before any character with no escape of its own drops the backslash and keeps the
153/// character, and a lone trailing backslash is dropped, both per the message-tags specification.
154pub(crate) fn unescape(value: &str) -> String {
155    if !value.contains('\\') {
156        return value.to_owned();
157    }
158    let mut out = String::with_capacity(value.len());
159    let mut chars = value.chars();
160    while let Some(c) = chars.next() {
161        if c != '\\' {
162            out.push(c);
163            continue;
164        }
165        match chars.next() {
166            Some(':') => out.push(';'),
167            Some('s') => out.push(' '),
168            Some('\\') => out.push('\\'),
169            Some('r') => out.push('\r'),
170            Some('n') => out.push('\n'),
171            Some(other) => out.push(other),
172            None => {}
173        }
174    }
175    out
176}
177
178/// Wraps a tag value so that `Display` writes it escaped.
179pub(crate) struct Escaped<'a>(pub &'a str);
180
181impl fmt::Display for Escaped<'_> {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        for c in self.0.chars() {
184            match c {
185                ';' => f.write_str("\\:")?,
186                ' ' => f.write_str("\\s")?,
187                '\\' => f.write_str("\\\\")?,
188                '\r' => f.write_str("\\r")?,
189                '\n' => f.write_str("\\n")?,
190                other => f.write_char(other)?,
191            }
192        }
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use alloc::format;
201
202    #[test]
203    fn unescapes_the_specified_sequences() {
204        assert_eq!(unescape(r"a\:b"), "a;b");
205        assert_eq!(unescape(r"a\sb"), "a b");
206        assert_eq!(unescape(r"a\\b"), r"a\b");
207        assert_eq!(unescape(r"a\rb"), "a\rb");
208        assert_eq!(unescape(r"a\nb"), "a\nb");
209    }
210
211    #[test]
212    fn drops_the_backslash_of_an_undefined_escape() {
213        assert_eq!(unescape(r"a\qb"), "aqb");
214    }
215
216    #[test]
217    fn drops_a_lone_trailing_backslash() {
218        assert_eq!(unescape(r"ab\"), "ab");
219    }
220
221    #[test]
222    fn escapes_round_trip() {
223        let raw = "semi;space colon:back\\slash\r\n";
224        let escaped = format!("{}", Escaped(raw));
225        assert_eq!(unescape(&escaped), raw);
226    }
227
228    #[test]
229    fn an_empty_value_is_the_same_as_no_value() {
230        let tags = Tags::parse("a=;b");
231        assert_eq!(tags.get("a"), None);
232        assert!(tags.contains("a"));
233        assert!(tags.contains("b"));
234    }
235
236    #[test]
237    fn keeps_wire_order_and_duplicate_keys() {
238        let tags = Tags::parse("z=1;a=2;z=3");
239        assert_eq!(tags.len(), 3);
240        assert_eq!(
241            tags.get("z"),
242            Some("3"),
243            "the specification says to disregard all but the final occurrence"
244        );
245        assert_eq!(
246            format!("{tags}"),
247            "z=1;a=2;z=3",
248            "but the line still renders as it arrived"
249        );
250    }
251
252    #[test]
253    fn recognises_a_client_only_tag() {
254        assert!(Tag::flag("+obby.world/e2ee").is_client_only());
255        assert!(!Tag::flag("time").is_client_only());
256    }
257}