Skip to main content

rmux_proto/
identity.rs

1//! Canonical identity newtypes shared across the RMUX workspace.
2//!
3//! `rmux-proto` is the single public home for the identity vocabulary
4//! (`SessionName`, `SessionId`, `WindowId`, `PaneId`). Other crates,
5//! including `rmux-core`, `rmux-server`, and `rmux-sdk`, must re-export
6//! these types rather than declaring their own. Allocation, lookup, and
7//! resolution remain in `rmux-core::session`; the types defined here
8//! describe identity values, not the policy that issues them.
9
10use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Deserializer, Serialize};
14
15use crate::RmuxError;
16
17/// A validated RMUX session name.
18///
19/// Empty strings are rejected. `:` and `.` characters are rewritten to `_`
20/// to keep names safe for use inside exact target syntax (`session`,
21/// `session:window`, `session:window.pane`). Non-printable characters are
22/// rendered using tmux's `vis`-style escape sequences so display output is
23/// always single-line and non-controlling.
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
25#[serde(transparent)]
26pub struct SessionName(String);
27
28impl SessionName {
29    /// Validates and stores a session name using tmux-compatible rewriting.
30    pub fn new(value: impl Into<String>) -> Result<Self, RmuxError> {
31        let value = value.into();
32
33        if value.is_empty() {
34            return Err(RmuxError::EmptySessionName);
35        }
36
37        Ok(Self(sanitize_session_name(&value)))
38    }
39
40    /// Returns the sanitized validated session name.
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        &self.0
44    }
45
46    /// Consumes the wrapper and returns the sanitized string.
47    #[must_use]
48    pub fn into_inner(self) -> String {
49        self.0
50    }
51}
52
53fn sanitize_session_name(input: &str) -> String {
54    let mut sanitized = String::with_capacity(input.len());
55    for character in input.chars() {
56        let rewritten = match character {
57            ':' | '.' => '_',
58            other => other,
59        };
60        push_session_name_character(rewritten, &mut sanitized);
61    }
62    sanitized
63}
64
65fn push_session_name_character(character: char, output: &mut String) {
66    match character {
67        '\0' => output.push_str("\\000"),
68        '\x07' => output.push_str("\\a"),
69        '\x08' => output.push_str("\\b"),
70        '\t' => output.push_str("\\t"),
71        '\n' => output.push_str("\\n"),
72        '\x0b' => output.push_str("\\v"),
73        '\x0c' => output.push_str("\\f"),
74        '\r' => output.push_str("\\r"),
75        control if control.is_control() => {
76            let value = control as u32;
77            output.push('\\');
78            output.push(char::from(b'0' + ((value >> 6) & 0x7) as u8));
79            output.push(char::from(b'0' + ((value >> 3) & 0x7) as u8));
80            output.push(char::from(b'0' + (value & 0x7) as u8));
81        }
82        // Line/paragraph separators, bidi overrides and zero-width marks are NOT
83        // Unicode Cc controls, so `is_control()` misses them, yet they still break
84        // the single-line, non-controlling display invariant (and bidi overrides
85        // can spoof the rendered name). Escape each UTF-8 byte octally — the same
86        // `\NNN` form as the control arm and tmux's byte-oriented vis. The leading
87        // backslash is never itself escaped, so the output re-sanitizes to itself
88        // and stays idempotent through serde's re-sanitizing Deserialize.
89        format_char if is_display_unsafe_format_char(format_char) => {
90            let mut buffer = [0_u8; 4];
91            for byte in format_char.encode_utf8(&mut buffer).bytes() {
92                output.push('\\');
93                output.push(char::from(b'0' + ((byte >> 6) & 0x7)));
94                output.push(char::from(b'0' + ((byte >> 3) & 0x7)));
95                output.push(char::from(b'0' + (byte & 0x7)));
96            }
97        }
98        _ => {
99            output.push(character);
100        }
101    }
102}
103
104/// Non-`Cc` code points that still violate the single-line, non-controlling
105/// session-name invariant: line/paragraph separators, bidi embeddings, overrides
106/// and isolates, and zero-width / invisible formatting marks.
107fn is_display_unsafe_format_char(character: char) -> bool {
108    matches!(
109        character as u32,
110        0x00AD               // SOFT HYPHEN
111            | 0x061C         // ARABIC LETTER MARK
112            | 0x200B..=0x200F // ZWSP, ZWNJ, ZWJ, LRM, RLM
113            | 0x2028         // LINE SEPARATOR
114            | 0x2029         // PARAGRAPH SEPARATOR
115            | 0x202A..=0x202E // LRE, RLE, PDF, LRO, RLO (bidi embeddings/overrides)
116            | 0x2066..=0x2069 // LRI, RLI, FSI, PDI (bidi isolates)
117            | 0xFEFF         // ZERO WIDTH NO-BREAK SPACE / BOM
118    )
119}
120
121impl AsRef<str> for SessionName {
122    fn as_ref(&self) -> &str {
123        self.as_str()
124    }
125}
126
127impl fmt::Display for SessionName {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        formatter.write_str(self.as_str())
130    }
131}
132
133impl FromStr for SessionName {
134    type Err = RmuxError;
135
136    fn from_str(value: &str) -> Result<Self, Self::Err> {
137        Self::new(value)
138    }
139}
140
141impl TryFrom<&str> for SessionName {
142    type Error = RmuxError;
143
144    fn try_from(value: &str) -> Result<Self, Self::Error> {
145        Self::new(value)
146    }
147}
148
149impl TryFrom<String> for SessionName {
150    type Error = RmuxError;
151
152    fn try_from(value: String) -> Result<Self, Self::Error> {
153        Self::new(value)
154    }
155}
156
157impl<'de> Deserialize<'de> for SessionName {
158    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
159    where
160        D: Deserializer<'de>,
161    {
162        let value = String::deserialize(deserializer)?;
163        Self::new(value).map_err(serde::de::Error::custom)
164    }
165}
166
167/// Stable per-server session identity (`$N`).
168///
169/// `SessionId` is the numeric identity rendered as `$N` by tmux-compatible
170/// formats. Allocation lives in `rmux-core::session::SessionStore`; the
171/// type defined here is the storable, transferable identity value.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
173#[serde(transparent)]
174pub struct SessionId(u32);
175
176impl SessionId {
177    /// Wraps a raw stable session identity.
178    #[must_use]
179    pub const fn new(value: u32) -> Self {
180        Self(value)
181    }
182
183    /// Returns the raw stable session identity.
184    #[must_use]
185    pub const fn as_u32(self) -> u32 {
186        self.0
187    }
188}
189
190impl fmt::Display for SessionId {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        write!(formatter, "${}", self.0)
193    }
194}
195
196impl From<SessionId> for u32 {
197    fn from(value: SessionId) -> Self {
198        value.0
199    }
200}
201
202impl From<u32> for SessionId {
203    fn from(value: u32) -> Self {
204        Self(value)
205    }
206}
207
208/// Stable per-server window identity (`@N`).
209///
210/// `WindowId` is the numeric identity rendered as `@N` by tmux-compatible
211/// formats. Allocation lives in `rmux-core::session`; the type defined
212/// here is the storable, transferable identity value.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
214#[serde(transparent)]
215pub struct WindowId(u32);
216
217impl WindowId {
218    /// Wraps a raw stable window identity.
219    #[must_use]
220    pub const fn new(value: u32) -> Self {
221        Self(value)
222    }
223
224    /// Returns the raw stable window identity.
225    #[must_use]
226    pub const fn as_u32(self) -> u32 {
227        self.0
228    }
229}
230
231impl fmt::Display for WindowId {
232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(formatter, "@{}", self.0)
234    }
235}
236
237impl From<WindowId> for u32 {
238    fn from(value: WindowId) -> Self {
239        value.0
240    }
241}
242
243impl From<u32> for WindowId {
244    fn from(value: u32) -> Self {
245        Self(value)
246    }
247}
248
249/// Stable per-server pane identity (`%N`).
250///
251/// `PaneId` is the numeric identity rendered as `%N` by tmux-compatible
252/// formats. Allocation lives in `rmux-core::session::SessionStore`; the
253/// type defined here is the storable, transferable identity value.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
255#[serde(transparent)]
256pub struct PaneId(u32);
257
258impl PaneId {
259    /// Wraps a raw stable pane identity.
260    #[must_use]
261    pub const fn new(value: u32) -> Self {
262        Self(value)
263    }
264
265    /// Returns the raw stable pane identity.
266    #[must_use]
267    pub const fn as_u32(self) -> u32 {
268        self.0
269    }
270}
271
272impl fmt::Display for PaneId {
273    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
274        write!(formatter, "%{}", self.0)
275    }
276}
277
278impl From<PaneId> for u32 {
279    fn from(value: PaneId) -> Self {
280        value.0
281    }
282}
283
284impl From<u32> for PaneId {
285    fn from(value: u32) -> Self {
286        Self(value)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::{is_display_unsafe_format_char, PaneId, SessionId, SessionName, WindowId};
293    use crate::RmuxError;
294
295    #[test]
296    fn session_name_rejects_empty_values() {
297        assert_eq!(SessionName::new(""), Err(RmuxError::EmptySessionName));
298    }
299
300    #[test]
301    fn session_name_rewrites_colon_and_dot() {
302        assert_eq!(
303            SessionName::new("alpha:beta.gamma")
304                .expect("rewritten")
305                .as_str(),
306            "alpha_beta_gamma"
307        );
308    }
309
310    #[test]
311    fn session_name_round_trips_through_serde() {
312        let payload = bincode::serialize("alpha.beta").expect("string encodes");
313        assert_eq!(
314            bincode::deserialize::<SessionName>(&payload).expect("rewritten on the wire"),
315            SessionName::new("alpha_beta").expect("valid")
316        );
317    }
318
319    #[test]
320    fn session_name_serde_rejects_empty_payloads_truthfully() {
321        let payload = bincode::serialize("").expect("empty string encodes");
322        assert!(
323            bincode::deserialize::<SessionName>(&payload).is_err(),
324            "empty session names must fail deserialization rather than silently \
325             producing an empty inner value"
326        );
327    }
328
329    #[test]
330    fn session_name_serialize_round_trips_after_rewriting() {
331        let original = SessionName::new("alpha.beta").expect("rewrites dots");
332        let bytes = bincode::serialize(&original).expect("session name encodes");
333        let restored: SessionName =
334            bincode::deserialize(&bytes).expect("session name decodes idempotently");
335        assert_eq!(restored, original);
336        assert_eq!(restored.as_str(), "alpha_beta");
337    }
338
339    #[test]
340    fn session_name_from_str_and_try_from_match_constructor() {
341        let from_str: SessionName = "alpha:beta".parse().expect("FromStr rewrites");
342        let try_from_ref: SessionName =
343            SessionName::try_from("alpha:beta").expect("TryFrom<&str> rewrites");
344        let try_from_owned: SessionName =
345            SessionName::try_from(String::from("alpha:beta")).expect("TryFrom<String> rewrites");
346        assert_eq!(from_str, try_from_ref);
347        assert_eq!(from_str, try_from_owned);
348        assert_eq!(from_str.as_str(), "alpha_beta");
349    }
350
351    #[test]
352    fn session_name_into_inner_returns_sanitized_string() {
353        let owned = SessionName::new("alpha:beta")
354            .expect("rewrites colons")
355            .into_inner();
356        assert_eq!(owned, "alpha_beta");
357    }
358
359    #[test]
360    fn session_name_escapes_line_and_paragraph_separators() {
361        // U+2028/U+2029 are not Cc controls but break the single-line invariant.
362        assert_eq!(
363            SessionName::new("a\u{2028}b\u{2029}c")
364                .expect("escaped")
365                .as_str(),
366            "a\\342\\200\\250b\\342\\200\\251c"
367        );
368    }
369
370    #[test]
371    fn session_name_escapes_bidi_overrides_and_zero_width_marks() {
372        // A right-to-left override could otherwise spoof the rendered name; a
373        // zero-width space could hide content. Both must be escaped.
374        let rendered = SessionName::new("a\u{202e}b\u{200b}c").expect("escaped");
375        assert_eq!(rendered.as_str(), "a\\342\\200\\256b\\342\\200\\213c");
376        assert!(!rendered.as_str().chars().any(is_display_unsafe_format_char));
377    }
378
379    #[test]
380    fn session_name_sanitization_is_idempotent_through_serde() {
381        // Deserialize re-runs sanitize_session_name, so an escaped name must
382        // survive a second pass unchanged (no doubled backslashes).
383        let original =
384            SessionName::new("tab\there\u{2028}line\u{202e}rtl\x01ctl").expect("escaped");
385        let bytes = bincode::serialize(&original).expect("encodes");
386        let restored: SessionName = bincode::deserialize(&bytes).expect("decodes");
387        assert_eq!(restored, original, "re-sanitizing must be a no-op");
388        // And a direct second sanitize pass is also a no-op.
389        assert_eq!(
390            SessionName::new(original.as_str())
391                .expect("re-sanitizes")
392                .as_str(),
393            original.as_str()
394        );
395    }
396
397    #[test]
398    fn session_id_displays_with_dollar_prefix() {
399        assert_eq!(SessionId::new(7).to_string(), "$7");
400        assert_eq!(SessionId::new(7).as_u32(), 7);
401    }
402
403    #[test]
404    fn window_id_displays_with_at_prefix() {
405        assert_eq!(WindowId::new(9).to_string(), "@9");
406        assert_eq!(WindowId::new(9).as_u32(), 9);
407    }
408
409    #[test]
410    fn window_id_zero_and_max_render_as_at_prefixed_decimal() {
411        assert_eq!(WindowId::new(0).to_string(), "@0");
412        assert_eq!(
413            WindowId::new(u32::MAX).to_string(),
414            format!("@{}", u32::MAX)
415        );
416    }
417
418    #[test]
419    fn pane_id_displays_with_percent_prefix() {
420        assert_eq!(PaneId::new(3).to_string(), "%3");
421        assert_eq!(PaneId::new(3).as_u32(), 3);
422    }
423
424    #[test]
425    fn pane_id_zero_and_max_render_as_percent_prefixed_decimal() {
426        assert_eq!(PaneId::new(0).to_string(), "%0");
427        assert_eq!(PaneId::new(u32::MAX).to_string(), format!("%{}", u32::MAX));
428    }
429
430    #[test]
431    fn session_id_zero_and_max_render_as_dollar_prefixed_decimal() {
432        assert_eq!(SessionId::new(0).to_string(), "$0");
433        assert_eq!(
434            SessionId::new(u32::MAX).to_string(),
435            format!("${}", u32::MAX)
436        );
437    }
438
439    #[test]
440    fn identity_newtypes_round_trip_through_u32_conversions() {
441        for value in [0_u32, 1, 17, u32::MAX] {
442            assert_eq!(u32::from(SessionId::from(value)), value);
443            assert_eq!(u32::from(WindowId::from(value)), value);
444            assert_eq!(u32::from(PaneId::from(value)), value);
445            assert_eq!(SessionId::from(value).as_u32(), value);
446            assert_eq!(WindowId::from(value).as_u32(), value);
447            assert_eq!(PaneId::from(value).as_u32(), value);
448        }
449    }
450
451    #[test]
452    fn identity_newtypes_are_serde_transparent() {
453        assert_eq!(
454            bincode::serialize(&PaneId::new(11)).expect("encodes"),
455            bincode::serialize(&11_u32).expect("encodes")
456        );
457        assert_eq!(
458            bincode::serialize(&WindowId::new(11)).expect("encodes"),
459            bincode::serialize(&11_u32).expect("encodes")
460        );
461        assert_eq!(
462            bincode::serialize(&SessionId::new(11)).expect("encodes"),
463            bincode::serialize(&11_u32).expect("encodes")
464        );
465    }
466
467    #[test]
468    fn identity_id_newtypes_decode_back_through_serde() {
469        for value in [0_u32, 7, 257, u32::MAX] {
470            let session_bytes =
471                bincode::serialize(&SessionId::new(value)).expect("session id encodes");
472            let window_bytes =
473                bincode::serialize(&WindowId::new(value)).expect("window id encodes");
474            let pane_bytes = bincode::serialize(&PaneId::new(value)).expect("pane id encodes");
475
476            assert_eq!(
477                bincode::deserialize::<SessionId>(&session_bytes).expect("session id decodes"),
478                SessionId::new(value),
479            );
480            assert_eq!(
481                bincode::deserialize::<WindowId>(&window_bytes).expect("window id decodes"),
482                WindowId::new(value),
483            );
484            assert_eq!(
485                bincode::deserialize::<PaneId>(&pane_bytes).expect("pane id decodes"),
486                PaneId::new(value),
487            );
488        }
489    }
490
491    #[test]
492    fn identity_id_newtypes_total_order_matches_inner_u32() {
493        let mut ids = [PaneId::new(3), PaneId::new(0), PaneId::new(1)];
494        ids.sort();
495        assert_eq!(ids, [PaneId::new(0), PaneId::new(1), PaneId::new(3)]);
496    }
497
498    #[test]
499    fn session_name_already_sanitized_round_trips_through_serde() {
500        let original = SessionName::new("alpha-beta_gamma").expect("printable name");
501        let bytes = bincode::serialize(&original).expect("session name encodes");
502        let restored: SessionName =
503            bincode::deserialize(&bytes).expect("session name decodes idempotently");
504        assert_eq!(restored, original);
505    }
506}