Skip to main content

arminius_lib_message_bus/
subject.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Utilities for building NATS subjects out of arbitrary tokens.
6//!
7//! NATS reserves three characters with special meaning inside a subject:
8//! `.` separates tokens, `*` matches a single token, and `>` matches one or
9//! more trailing tokens. A token that contains any of these (for example a
10//! Matrix room ID) would otherwise be split or interpreted as a wildcard, so
11//! [`EscapingSubject`] escapes them (along with the escape character `\`
12//! itself) when serializing and restores them when parsing.
13
14use std::str::FromStr;
15
16use serde::{Deserialize, Serialize};
17
18/// Errors that occur when unescaping a NATS token
19#[derive(Debug, thiserror::Error, Serialize, Deserialize, PartialEq)]
20pub enum TokenEscapeError {
21	/// A backslash was followed by a character that does not form a known
22	/// escape sequence.
23	#[error("Unknown escape code in token {0}")]
24	UnknownEscapeCode(char),
25	/// The token ended with a trailing backslash, so there is no character
26	/// left to unescape.
27	#[error("Last character of escaped token is backslash")]
28	LastCharIsBackslash,
29}
30
31/// Struct that allows building a NATS subject out of tokens, escaping any
32/// reserved characters that would otherwise split up or wildcard the tokens.
33#[derive(Debug)]
34pub struct EscapingSubject(pub Vec<String>);
35
36impl EscapingSubject {
37	/// Constructor
38	#[must_use]
39	pub fn new(tokens: Vec<String>) -> Self {
40		EscapingSubject(tokens)
41	}
42
43	/// Serialize the tokens into a NATS subject string, escaping reserved
44	/// characters.
45	#[must_use]
46	pub fn to_subject_string(&self) -> String {
47		self.0.iter().map(|token| escape_token(token)).collect::<Vec<_>>().join(".")
48	}
49}
50
51impl FromStr for EscapingSubject {
52	type Err = TokenEscapeError;
53
54	fn from_str(subject: &str) -> Result<Self, Self::Err> {
55		Ok(Self(subject.split('.').map(unescape_token).collect::<Result<Vec<_>, _>>()?))
56	}
57}
58
59/// The character that introduces an escape sequence.
60const ESCAPE_CHAR: char = '\\';
61
62/// The single source of truth for NATS reserved characters and their escape
63/// codes, as `(literal, escape-code)` pairs. Escaping looks a character up by
64/// its literal; unescaping looks it up by its code.
65///
66/// The [`ESCAPE_CHAR`] itself must be escaped so escapes are
67/// unambiguous.
68const ESCAPES: &[(char, char)] = &[
69	('.', 'd'),         // token separator
70	('*', 'a'),         // single-token wildcard
71	('>', 'g'),         // multi-token wildcard
72	(ESCAPE_CHAR, 'b'), // the escape character itself
73];
74
75/// Escape every NATS-reserved character in a token (see [`ESCAPES`]) so it
76/// survives being placed in a subject.
77pub fn escape_token(token: &str) -> String {
78	let mut str = String::with_capacity(token.len() * 2);
79	for char in token.chars() {
80		match ESCAPES.iter().find(|(literal, _)| *literal == char) {
81			Some((_, code)) => {
82				str.push(ESCAPE_CHAR);
83				str.push(*code);
84			}
85			None => str.push(char),
86		}
87	}
88	str
89}
90
91/// Unescape a string that has been escaped with [escape_token]
92pub fn unescape_token(escaped_token: &str) -> Result<String, TokenEscapeError> {
93	let mut token = String::with_capacity(escaped_token.len());
94	let mut chars = escaped_token.chars();
95	while let Some(char) = chars.next() {
96		if char == ESCAPE_CHAR {
97			let code = chars.next().ok_or(TokenEscapeError::LastCharIsBackslash)?;
98			let (literal, _) = ESCAPES
99				.iter()
100				.find(|(_, escape_code)| *escape_code == code)
101				.ok_or(TokenEscapeError::UnknownEscapeCode(code))?;
102			token.push(*literal);
103		} else {
104			token.push(char);
105		}
106	}
107	Ok(token)
108}
109
110#[cfg(test)]
111mod tests;