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