Skip to main content

mail4agent_messenger/
ids.rs

1//! Matrix identifier newtypes.
2//!
3//! [`UserId`], [`RoomId`] and [`EventId`] validate the shape the Matrix
4//! Client-Server API mandates for their respective sigils (`@local:server`,
5//! `!opaque:server`, `$opaque`) without parsing further into
6//! protocol-specific structure beyond that split. [`DeviceId`] is a plain
7//! opaque, server-assigned string with no sigil.
8//!
9//! This module never hardcodes a server name. Every id is either supplied
10//! whole by a caller (who read it off the wire) or built from a localpart
11//! plus a server name the *core* is
12//! configured with (`MessengerCore::new`, a later piece), never a constant
13//! baked in here.
14//!
15//! [`TxnId`] and [`RequestId`] are client-minted, not parsed off the wire:
16//! [`TxnId::new`]/[`RequestId::next`] mint values from an explicit,
17//! caller-supplied monotonic sequence number rather than a global counter,
18//! so this sans-I/O crate carries no hidden shared mutable state (no
19//! `static`/`OnceLock`) — the owner of a sequence (typically
20//! `MessengerCore`, one counter per device session) decides how the
21//! sequence is seeded (e.g. a random `u64` at process start) and threads it
22//! through explicitly.
23
24use crate::error::MessengerError;
25use serde::{Deserialize, Serialize};
26use std::fmt;
27
28/// Splits a `<sigil><localpart>:<server_name>` identifier into its
29/// localpart and server-name halves, used by [`UserId`] and [`RoomId`].
30///
31/// The split happens at the FIRST `:` after the sigil, not the last,
32/// because `server_name` may itself carry a `:port` suffix while
33/// `localpart` never contains `:` (matches the Matrix spec's own
34/// `user_id`/`room_id` grammar).
35fn split_sigil_id<'a>(
36    kind: &'static str,
37    value: &'a str,
38    sigil: char,
39) -> Result<(&'a str, &'a str), MessengerError> {
40    let invalid = |reason: &str| MessengerError::InvalidId {
41        kind,
42        value: value.to_string(),
43        reason: reason.to_string(),
44    };
45    let rest = value
46        .strip_prefix(sigil)
47        .ok_or_else(|| invalid(&format!("must start with '{sigil}'")))?;
48    let (localpart, server) = rest
49        .split_once(':')
50        .ok_or_else(|| invalid("missing ':' separating localpart and server name"))?;
51    if localpart.is_empty() {
52        return Err(invalid("localpart must not be empty"));
53    }
54    if server.is_empty() {
55        return Err(invalid("server name must not be empty"));
56    }
57    if localpart.chars().any(char::is_whitespace) || server.chars().any(char::is_whitespace) {
58        return Err(invalid("must not contain whitespace"));
59    }
60    Ok((localpart, server))
61}
62
63/// A Matrix user id: `@localpart:server_name` (client-server API's own
64/// `user_id` grammar).
65#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct UserId(String);
68
69impl UserId {
70    /// Validates `value` as `@localpart:server_name`.
71    pub fn parse(value: impl Into<String>) -> Result<Self, MessengerError> {
72        let value = value.into();
73        split_sigil_id("UserId", &value, '@')?;
74        Ok(Self(value))
75    }
76
77    /// The id's own string form, e.g. `"@alice:example.org"`.
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81}
82
83impl fmt::Display for UserId {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.write_str(&self.0)
86    }
87}
88
89/// A Matrix room id: `!opaque:server_name` (client-server API's own
90/// `room_id` grammar — opaque per spec, never derived from the room's own
91/// alias or name).
92#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
93#[serde(transparent)]
94pub struct RoomId(String);
95
96impl RoomId {
97    /// Validates `value` as `!opaque:server_name`.
98    pub fn parse(value: impl Into<String>) -> Result<Self, MessengerError> {
99        let value = value.into();
100        split_sigil_id("RoomId", &value, '!')?;
101        Ok(Self(value))
102    }
103
104    /// The id's own string form, e.g. `"!abc123:example.org"`.
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108}
109
110impl fmt::Display for RoomId {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(&self.0)
113    }
114}
115
116/// A Matrix event id: `$opaque` (room version 3+ shape — no server-name
117/// suffix; the opaque part is itself a content hash in most room
118/// versions, but this crate does not interpret it further).
119#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
120#[serde(transparent)]
121pub struct EventId(String);
122
123impl EventId {
124    /// Validates `value` as `$opaque`.
125    pub fn parse(value: impl Into<String>) -> Result<Self, MessengerError> {
126        let value = value.into();
127        let opaque = value.strip_prefix('$').ok_or_else(|| MessengerError::InvalidId {
128            kind: "EventId",
129            value: value.clone(),
130            reason: "must start with '$'".to_string(),
131        })?;
132        if opaque.is_empty() || opaque.chars().any(char::is_whitespace) {
133            return Err(MessengerError::InvalidId {
134                kind: "EventId",
135                value,
136                reason: "opaque part must be non-empty and contain no whitespace".to_string(),
137            });
138        }
139        Ok(Self(value))
140    }
141
142    /// The id's own string form, e.g. `"$abc123"`.
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146}
147
148impl fmt::Display for EventId {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(&self.0)
151    }
152}
153
154/// A device id: an opaque, server-assigned string with no sigil, one per
155/// authenticated session (plan §3.1's `MessengerCore::device_id`).
156#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
157#[serde(transparent)]
158pub struct DeviceId(String);
159
160impl DeviceId {
161    /// Validates `value` as a non-empty, whitespace-free opaque string.
162    pub fn parse(value: impl Into<String>) -> Result<Self, MessengerError> {
163        let value = value.into();
164        if value.is_empty() || value.chars().any(char::is_whitespace) {
165            return Err(MessengerError::InvalidId {
166                kind: "DeviceId",
167                value,
168                reason: "must be non-empty and contain no whitespace".to_string(),
169            });
170        }
171        Ok(Self(value))
172    }
173
174    /// The id's own string form.
175    pub fn as_str(&self) -> &str {
176        &self.0
177    }
178}
179
180impl fmt::Display for DeviceId {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        f.write_str(&self.0)
183    }
184}
185
186/// A client-chosen transaction id, scoping one request as idempotent from
187/// the server's point of view (client-server API's own `txnId` path
188/// parameter) and used locally to reconcile a local echo against its
189/// server-confirmed event (`unsigned.transaction_id`, a later piece).
190#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
191#[serde(transparent)]
192pub struct TxnId(String);
193
194impl TxnId {
195    /// Mints a transaction id from an explicit monotonic sequence value.
196    /// This crate keeps no global/static counter (see the module doc) —
197    /// the caller supplies a fresh, strictly increasing `seed` on every
198    /// call (e.g. a `u64` field on `MessengerCore`, itself seeded once from
199    /// any entropy source at construction).
200    pub fn new(seed: u64) -> Self {
201        Self(format!("m.txn.{seed:016x}"))
202    }
203
204    /// The id's own string form.
205    pub fn as_str(&self) -> &str {
206        &self.0
207    }
208
209    /// Recovers the monotonic seed this id was minted from via
210    /// [`TxnId::new`], or `None` if `self` was not built by this crate's
211    /// own minter (e.g. an opaque value read off the wire via
212    /// [`TxnId::from`]). Used by [`crate::core::MessengerCore::open`] to
213    /// restore its counter past every id already in flight — see that
214    /// module's doc for why a restart must never reissue an id.
215    pub fn as_seed(&self) -> Option<u64> {
216        self.0.strip_prefix("m.txn.").and_then(|hex| u64::from_str_radix(hex, 16).ok())
217    }
218}
219
220impl fmt::Display for TxnId {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        f.write_str(&self.0)
223    }
224}
225
226impl From<String> for TxnId {
227    /// Wraps a transaction id read back off the wire (e.g.
228    /// `unsigned.transaction_id` on a `/sync` timeline event) without
229    /// re-validating it — the server already accepted it once as the id
230    /// this client itself minted via [`TxnId::new`].
231    fn from(value: String) -> Self {
232        Self(value)
233    }
234}
235
236/// This crate's own internal request-correlation id — never sent over the
237/// wire, only used to key an [`crate::wire::HttpResponseDescriptor`] (or a
238/// crash-restart replay entry, a later piece) back to the
239/// [`crate::wire::OutgoingRequest`] that produced it.
240#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
241#[serde(transparent)]
242pub struct RequestId(String);
243
244impl RequestId {
245    /// Mints the next id from an explicit monotonic sequence value — see
246    /// [`TxnId::new`]'s doc for why this crate takes an explicit `seed`
247    /// instead of keeping a global counter.
248    pub fn next(seed: u64) -> Self {
249        Self(format!("req.{seed:016x}"))
250    }
251
252    /// The id's own string form.
253    pub fn as_str(&self) -> &str {
254        &self.0
255    }
256
257    /// Recovers the monotonic seed this id was minted from via
258    /// [`RequestId::next`], or `None` if `self` was not built by this
259    /// crate's own minter. See [`TxnId::as_seed`]'s doc — same purpose, the
260    /// other counter.
261    pub fn as_seed(&self) -> Option<u64> {
262        self.0.strip_prefix("req.").and_then(|hex| u64::from_str_radix(hex, 16).ok())
263    }
264}
265
266impl fmt::Display for RequestId {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        f.write_str(&self.0)
269    }
270}
271
272impl From<String> for RequestId {
273    /// Wraps a request id read back out of a persisted record's storage key
274    /// (`store`'s `pending/{request_id}` layout, a later piece) without
275    /// re-validating it -- the value being decoded is one this process
276    /// itself minted via [`RequestId::next`] on an earlier run.
277    fn from(value: String) -> Self {
278        Self(value)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn user_id_accepts_a_well_formed_sigil() {
288        let id = UserId::parse("@alice:example.org").expect("valid user id");
289        assert_eq!(id.as_str(), "@alice:example.org");
290        assert_eq!(id.to_string(), "@alice:example.org");
291    }
292
293    #[test]
294    fn user_id_accepts_a_server_name_with_a_port() {
295        let id = UserId::parse("@alice:example.org:8448").expect("port suffix is part of server_name");
296        assert_eq!(id.as_str(), "@alice:example.org:8448");
297    }
298
299    #[test]
300    fn user_id_rejects_a_missing_sigil() {
301        let err = UserId::parse("alice:example.org").unwrap_err();
302        assert!(matches!(err, MessengerError::InvalidId { kind: "UserId", .. }));
303    }
304
305    #[test]
306    fn user_id_rejects_a_missing_server_name() {
307        assert!(UserId::parse("@alice").is_err());
308        assert!(UserId::parse("@alice:").is_err());
309    }
310
311    #[test]
312    fn user_id_rejects_an_empty_localpart() {
313        assert!(UserId::parse("@:example.org").is_err());
314    }
315
316    #[test]
317    fn room_id_accepts_a_well_formed_sigil() {
318        let id = RoomId::parse("!abc123:example.org").expect("valid room id");
319        assert_eq!(id.as_str(), "!abc123:example.org");
320    }
321
322    #[test]
323    fn room_id_rejects_a_missing_sigil() {
324        assert!(RoomId::parse("abc123:example.org").is_err());
325    }
326
327    #[test]
328    fn event_id_accepts_a_well_formed_sigil() {
329        let id = EventId::parse("$abc123").expect("valid event id");
330        assert_eq!(id.as_str(), "$abc123");
331    }
332
333    #[test]
334    fn event_id_rejects_a_missing_sigil() {
335        assert!(EventId::parse("abc123").is_err());
336    }
337
338    #[test]
339    fn event_id_rejects_an_empty_opaque_part() {
340        assert!(EventId::parse("$").is_err());
341    }
342
343    #[test]
344    fn device_id_rejects_empty_and_whitespace() {
345        assert!(DeviceId::parse("").is_err());
346        assert!(DeviceId::parse("has space").is_err());
347        assert!(DeviceId::parse("ABCDEFGH").is_ok());
348    }
349
350    #[test]
351    fn txn_id_and_request_id_are_monotonic_and_distinct_namespaces() {
352        let a = TxnId::new(0);
353        let b = TxnId::new(1);
354        assert_ne!(a, b);
355        assert!(a.as_str() < b.as_str(), "fixed-width hex keeps lexicographic order monotonic");
356
357        let r0 = RequestId::next(0);
358        let r1 = RequestId::next(1);
359        assert_ne!(r0, r1);
360        assert_ne!(TxnId::new(0).as_str(), RequestId::next(0).as_str());
361    }
362
363    #[test]
364    fn request_id_from_string_round_trips_a_previously_minted_value() {
365        let minted = RequestId::next(7);
366        let recovered = RequestId::from(minted.as_str().to_string());
367        assert_eq!(minted, recovered);
368    }
369
370    #[test]
371    fn as_seed_recovers_the_minting_seed() {
372        assert_eq!(TxnId::new(42).as_seed(), Some(42));
373        assert_eq!(RequestId::next(42).as_seed(), Some(42));
374        assert_eq!(TxnId::from("opaque-server-value".to_string()).as_seed(), None);
375        assert_eq!(RequestId::from("opaque-value".to_string()).as_seed(), None);
376    }
377
378    #[test]
379    fn ids_round_trip_through_serde_as_a_bare_string() {
380        let id = RoomId::parse("!abc:example.org").expect("valid room id");
381        let json = serde_json::to_string(&id).expect("serialize");
382        assert_eq!(json, "\"!abc:example.org\"");
383        let back: RoomId = serde_json::from_str(&json).expect("deserialize");
384        assert_eq!(back, id);
385    }
386}