Skip to main content

muxr_core/
session.rs

1use std::env;
2use std::fmt;
3use std::io;
4use std::path::Path;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use rootcause::report;
9use serde::Deserialize;
10use serde::Deserializer;
11use serde::Serialize;
12
13pub const DEFAULT_SESSION_NAME: &str = "default";
14pub const EXTERNAL_LAYOUT_ARG: &str = "--layout";
15/// Timestamp format used in muxr server log filenames.
16///
17/// The server owns timestamp generation; clients should not pass this through the private runner argv.
18pub const SERVER_LOG_TIMESTAMP_FORMAT: &str = "%Y%m%d%H%M%S";
19const STATE_HOME_PARTS: &[&str] = &[".local", "state", "muxr"];
20
21const LOGS_DIR_NAME: &str = "logs";
22const SOCKET_HOME_PARTS: &[&str] = &["s"];
23const SOCKET_HASH_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
24const SOCKET_HASH_PRIME: u64 = 0x0000_0100_0000_01b3;
25const SOCKET_PATH_MAX_BYTES: usize = 103;
26const SERVER_LOG_TIMESTAMP_LEN: usize = 14;
27
28/// Validated timestamp component for a muxr server log filename.
29///
30/// This is intentionally a filename-only type, not a protocol/versioning field.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct ServerLogTimestamp(String);
33
34impl AsRef<str> for ServerLogTimestamp {
35    fn as_ref(&self) -> &str {
36        &self.0
37    }
38}
39
40impl fmt::Display for ServerLogTimestamp {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(self.as_ref())
43    }
44}
45
46impl FromStr for ServerLogTimestamp {
47    type Err = rootcause::Report;
48
49    fn from_str(raw: &str) -> Result<Self, Self::Err> {
50        if raw.len() != SERVER_LOG_TIMESTAMP_LEN {
51            return Err(report!("invalid muxr server log timestamp {raw:?}").attach("reason=expected YYYYMMDDHHMMSS"));
52        }
53        let bytes = raw.as_bytes();
54        if !bytes.iter().all(u8::is_ascii_digit) {
55            return Err(report!("invalid muxr server log timestamp {raw:?}").attach("reason=expected YYYYMMDDHHMMSS"));
56        }
57        Ok(Self(raw.to_owned()))
58    }
59}
60
61#[derive(rkyv::Archive, Clone, Debug, Eq, Hash, PartialEq, Serialize, rkyv::Serialize)]
62#[serde(transparent)]
63pub struct SessionName(String);
64
65impl<'de> Deserialize<'de> for SessionName {
66    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
67    where
68        D: Deserializer<'de>,
69    {
70        String::deserialize(deserializer)?
71            .parse()
72            .map_err(serde::de::Error::custom)
73    }
74}
75
76impl AsRef<str> for SessionName {
77    fn as_ref(&self) -> &str {
78        &self.0
79    }
80}
81
82impl Default for SessionName {
83    fn default() -> Self {
84        Self(DEFAULT_SESSION_NAME.to_owned())
85    }
86}
87
88impl fmt::Display for SessionName {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(self.as_ref())
91    }
92}
93
94impl FromStr for SessionName {
95    type Err = rootcause::Report;
96
97    fn from_str(raw: &str) -> Result<Self, Self::Err> {
98        self::validate_muxr_name(raw, "session")?;
99        Ok(Self(raw.to_owned()))
100    }
101}
102
103impl<D> rkyv::Deserialize<SessionName, D> for ArchivedSessionName
104where
105    D: rkyv::rancor::Fallible + ?Sized,
106    D::Error: rkyv::rancor::Source,
107{
108    fn deserialize(&self, deserializer: &mut D) -> Result<SessionName, D::Error> {
109        let raw = rkyv::Deserialize::<String, D>::deserialize(&self.0, deserializer)?;
110        raw.parse().map_err(|error: rootcause::Report| {
111            <D::Error as rkyv::rancor::Source>::new(io::Error::new(io::ErrorKind::InvalidData, error.to_string()))
112        })
113    }
114}
115
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117pub struct SessionPaths {
118    pub root: PathBuf,
119    pub socket: PathBuf,
120    pub pid: PathBuf,
121    pub layout: PathBuf,
122    pub panes: PathBuf,
123}
124
125impl SessionPaths {
126    /// Return the root directory containing all muxr session directories under `$HOME`.
127    ///
128    /// # Errors
129    /// - `HOME` is unavailable in the current environment.
130    pub fn sessions_root_from_home() -> rootcause::Result<PathBuf> {
131        let home = env::var_os("HOME").ok_or_else(|| report!("missing HOME env for muxr state root"))?;
132        Ok(Self::sessions_root_from_home_path(PathBuf::from(home)))
133    }
134
135    /// Return the root directory containing all muxr session directories under an explicit home path.
136    #[must_use]
137    pub fn sessions_root_from_home_path(home: PathBuf) -> PathBuf {
138        Self::state_root_from_home_path(home).join("sessions")
139    }
140
141    /// Build muxr session paths under `$HOME/.local/state/muxr`.
142    ///
143    /// # Errors
144    /// - `HOME` is unavailable in the current environment.
145    /// - The derived Unix socket path is too long for the platform socket address.
146    pub fn from_home(session: &SessionName) -> rootcause::Result<Self> {
147        let home = env::var_os("HOME").ok_or_else(|| report!("missing HOME env for muxr state root"))?;
148        Self::from_home_path(PathBuf::from(home), session)
149    }
150
151    /// Build muxr session paths from an explicit sessions root.
152    ///
153    /// # Errors
154    /// - The sessions root has no parent state directory.
155    /// - The derived Unix socket path is too long for the platform socket address.
156    pub fn from_sessions_root_path(sessions_root: &Path, session: &SessionName) -> rootcause::Result<Self> {
157        let state_root = sessions_root
158            .parent()
159            .ok_or_else(|| report!("muxr sessions root has no parent"))?;
160        Self::from_state_root_path(state_root, session)
161    }
162
163    /// Build the centralized directory containing muxr server logs.
164    ///
165    /// # Errors
166    /// - The session root path has no parent state root.
167    pub fn logs_root(&self) -> rootcause::Result<PathBuf> {
168        Ok(self.state_root()?.join(LOGS_DIR_NAME))
169    }
170
171    /// Build the centralized server log path for one muxr server process start.
172    ///
173    /// # Errors
174    /// - The session root path has no parent state root.
175    pub fn server_log_path(
176        &self,
177        session: &SessionName,
178        timestamp: &ServerLogTimestamp,
179        pid: u32,
180    ) -> rootcause::Result<PathBuf> {
181        Ok(self
182            .logs_root()?
183            .join(self::server_log_file_name(session, timestamp, pid)))
184    }
185
186    /// Return the pid-scoped server log filename pattern used in client startup failure hints.
187    ///
188    /// The timestamp is chosen inside the server, so the client can only know the session name and spawned pid.
189    #[must_use]
190    pub fn server_log_file_pattern(session: &SessionName, pid: u32) -> String {
191        format!("{session}-*-{pid}.log")
192    }
193
194    fn from_home_path(home: PathBuf, session: &SessionName) -> rootcause::Result<Self> {
195        Self::from_state_root_path(&Self::state_root_from_home_path(home), session)
196    }
197
198    fn from_state_root_path(state_root: &Path, session: &SessionName) -> rootcause::Result<Self> {
199        let root = state_root.join("sessions").join(session.as_ref());
200        let socket = state_root
201            .join(
202                SOCKET_HOME_PARTS
203                    .iter()
204                    .fold(PathBuf::new(), |path, part| path.join(part)),
205            )
206            .join(self::socket_file_name(session));
207
208        self::validate_socket_path(&socket)?;
209        Ok(Self {
210            socket,
211            pid: root.join("server.pid"),
212            layout: root.join("layout.json"),
213            panes: root.join("panes"),
214            root,
215        })
216    }
217
218    fn state_root_from_home_path(home: PathBuf) -> PathBuf {
219        STATE_HOME_PARTS.iter().fold(home, |path, part| path.join(part))
220    }
221
222    fn state_root(&self) -> rootcause::Result<&Path> {
223        self.root
224            .parent()
225            .and_then(Path::parent)
226            .ok_or_else(|| report!("muxr session root has no state parent"))
227    }
228}
229
230/// Validate that a muxr Unix socket path fits the portable filesystem-socket limit.
231///
232/// # Errors
233/// - The path is longer than the conservative macOS `sockaddr_un.sun_path` capacity.
234pub fn validate_socket_path(path: &Path) -> rootcause::Result<()> {
235    // Filesystem Unix socket paths include a trailing NUL in sockaddr_un; 103 bytes is the safe macOS payload.
236    let bytes = path.as_os_str().as_encoded_bytes().len();
237    if bytes > SOCKET_PATH_MAX_BYTES {
238        return Err(report!("muxr socket path is too long")
239            .attach(format!("limit={SOCKET_PATH_MAX_BYTES}"))
240            .attach(format!("actual={bytes}"))
241            .attach(format!("path={}", path.display())));
242    }
243
244    Ok(())
245}
246
247fn socket_file_name(session: &SessionName) -> String {
248    format!("{:016x}.sock", self::socket_hash(session))
249}
250
251fn socket_hash(session: &SessionName) -> u64 {
252    let mut hash = SOCKET_HASH_OFFSET;
253    for byte in session.as_ref().bytes() {
254        hash ^= u64::from(byte);
255        hash = hash.wrapping_mul(SOCKET_HASH_PRIME);
256    }
257    hash
258}
259
260fn server_log_file_name(session: &SessionName, timestamp: &ServerLogTimestamp, pid: u32) -> String {
261    format!("{session}-{timestamp}-{pid}.log")
262}
263
264fn validate_muxr_name(raw: &str, kind: &str) -> rootcause::Result<()> {
265    if raw.is_empty() {
266        return Err(report!("invalid muxr {kind} name {raw:?}").attach("reason=empty names are not allowed"));
267    }
268
269    if matches!(raw, "." | "..") {
270        return Err(report!("invalid muxr {kind} name {raw:?}").attach("reason=reserved names are not allowed"));
271    }
272
273    if raw.starts_with('-') {
274        // Names are CLI operands too; leading '-' is reserved for flags before the value reaches filesystem paths.
275        return Err(report!("invalid muxr {kind} name {raw:?}").attach("reason=names must not start with -"));
276    }
277
278    if raw.len() > 64 {
279        return Err(
280            report!("invalid muxr {kind} name {raw:?}").attach("reason=names longer than 64 bytes are not allowed")
281        );
282    }
283
284    if !raw
285        .chars()
286        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
287    {
288        return Err(report!("invalid muxr {kind} name {raw:?}")
289            .attach("reason=only ASCII alphanumeric, _, -, and . are allowed"));
290    }
291
292    Ok(())
293}
294
295#[cfg(test)]
296mod tests {
297    use std::path::Path;
298
299    use rstest::rstest;
300    use test_that::prelude::*;
301
302    use super::*;
303
304    #[rstest]
305    #[case::default("default")]
306    #[case::work("work")]
307    #[case::hyphen("a-b")]
308    #[case::underscore("a_b")]
309    #[case::dot("a.b")]
310    #[case::alphanumeric("abc123")]
311    fn test_session_name_from_str_when_name_is_valid_returns_session_name(#[case] raw: &str) -> rootcause::Result<()> {
312        assert_that!(raw.parse::<SessionName>()?.as_ref(), eq(raw));
313        Ok(())
314    }
315
316    #[rstest]
317    #[case::empty("")]
318    #[case::dot(".")]
319    #[case::dot_dot("..")]
320    #[case::forward_slash("a/b")]
321    #[case::backslash("a\\b")]
322    #[case::space("a b")]
323    #[case::tab("a\tb")]
324    #[case::leading_dash("-work")]
325    #[case::flag_like("--work")]
326    #[case::shell_metacharacters("$(x)")]
327    #[case::punctuation("name!")]
328    fn test_session_name_from_str_when_name_is_invalid_returns_error(#[case] raw: &str) {
329        assert_that!(
330            raw.parse::<SessionName>(),
331            err(displays_as(contains_substring(format!("{raw:?}"))))
332        );
333    }
334
335    #[test]
336    fn test_session_name_rkyv_deserialize_when_name_is_invalid_returns_error() -> rootcause::Result<()> {
337        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&SessionName("../x".to_owned()))?;
338        let archived = rkyv::access::<rkyv::Archived<SessionName>, rkyv::rancor::Error>(&bytes)?;
339
340        assert_that!(
341            rkyv::deserialize::<SessionName, rkyv::rancor::Error>(archived),
342            err(anything())
343        );
344        Ok(())
345    }
346
347    #[test]
348    fn test_session_name_default_returns_default_session() {
349        assert_that!(SessionName::default().as_ref(), eq(DEFAULT_SESSION_NAME));
350    }
351
352    #[rstest]
353    #[case::midnight("20260611000000")]
354    #[case::with_time("20260611143012")]
355    fn test_server_log_timestamp_from_str_when_timestamp_is_valid_returns_timestamp(
356        #[case] raw: &str,
357    ) -> rootcause::Result<()> {
358        assert_that!(raw.parse::<ServerLogTimestamp>()?.as_ref(), eq(raw));
359        Ok(())
360    }
361
362    #[rstest]
363    #[case::empty("")]
364    #[case::epoch_millis("1781181012000")]
365    #[case::old_separator("20260611-143012")]
366    #[case::slash("20260611/143012")]
367    #[case::letters("20260611abcdef")]
368    fn test_server_log_timestamp_from_str_when_timestamp_is_invalid_returns_error(#[case] raw: &str) {
369        assert_that!(raw.parse::<ServerLogTimestamp>(), err(anything()));
370    }
371
372    #[test]
373    fn test_session_paths_from_home_builds_expected_paths() -> rootcause::Result<()> {
374        let home = Path::new("/foo/bar");
375        let session = "work".parse()?;
376        let state_root = home.join(".local").join("state").join("muxr");
377        let root = state_root.join("sessions").join("work");
378
379        let paths = SessionPaths::from_home_path(home.to_path_buf(), &session)?;
380
381        assert_that!(
382            paths,
383            eq(SessionPaths {
384                socket: state_root.join("s").join(self::socket_file_name(&session)),
385                pid: root.join("server.pid"),
386                layout: root.join("layout.json"),
387                panes: root.join("panes"),
388                root,
389            })
390        );
391        assert_that!(
392            paths.socket.as_os_str().as_encoded_bytes().len(),
393            le(SOCKET_PATH_MAX_BYTES)
394        );
395        Ok(())
396    }
397
398    #[test]
399    fn test_session_paths_logs_root_returns_state_logs_path() -> rootcause::Result<()> {
400        let session = "work".parse()?;
401        let paths = SessionPaths::from_home_path(Path::new("/foo/bar").to_path_buf(), &session)?;
402
403        assert_that!(
404            paths.logs_root()?,
405            eq(Path::new("/foo/bar")
406                .join(".local")
407                .join("state")
408                .join("muxr")
409                .join("logs"))
410        );
411        Ok(())
412    }
413
414    #[test]
415    fn test_session_paths_server_log_path_returns_centralized_flat_log_path() -> rootcause::Result<()> {
416        let session = "work.review-1".parse()?;
417        let timestamp = "20260611143012".parse()?;
418        let paths = SessionPaths::from_home_path(Path::new("/foo/bar").to_path_buf(), &session)?;
419
420        assert_that!(
421            paths.server_log_path(&session, &timestamp, 12345)?,
422            eq(Path::new("/foo/bar")
423                .join(".local")
424                .join("state")
425                .join("muxr")
426                .join("logs")
427                .join("work.review-1-20260611143012-12345.log"))
428        );
429        Ok(())
430    }
431
432    #[test]
433    fn test_session_paths_server_log_file_pattern_returns_pid_scoped_pattern() -> rootcause::Result<()> {
434        let session = "work.review-1".parse()?;
435
436        assert_that!(
437            SessionPaths::server_log_file_pattern(&session, 12345),
438            eq("work.review-1-*-12345.log")
439        );
440        Ok(())
441    }
442
443    #[test]
444    fn test_session_paths_server_log_path_when_pid_differs_returns_distinct_path() -> rootcause::Result<()> {
445        let session = "work".parse()?;
446        let timestamp = "20260611143012".parse()?;
447        let paths = SessionPaths::from_home_path(Path::new("/foo/bar").to_path_buf(), &session)?;
448
449        assert_that!(
450            paths.server_log_path(&session, &timestamp, 12345)?,
451            not(eq(paths.server_log_path(&session, &timestamp, 12346)?))
452        );
453        Ok(())
454    }
455
456    #[test]
457    fn test_session_paths_from_home_path_when_session_name_is_max_length_keeps_socket_short() -> rootcause::Result<()> {
458        let session = "a".repeat(64).parse()?;
459        let paths = SessionPaths::from_home_path(Path::new("/foo/bar").to_path_buf(), &session)?;
460
461        assert_that!(
462            paths.root,
463            eq(Path::new("/foo/bar")
464                .join(".local")
465                .join("state")
466                .join("muxr")
467                .join("sessions")
468                .join(session.as_ref()))
469        );
470        assert_that!(
471            paths.socket.as_os_str().as_encoded_bytes().len(),
472            le(SOCKET_PATH_MAX_BYTES)
473        );
474        Ok(())
475    }
476
477    #[test]
478    fn test_session_paths_from_home_path_when_socket_path_is_too_long_returns_error() -> rootcause::Result<()> {
479        let home = Path::new("/").join("x".repeat(SOCKET_PATH_MAX_BYTES.saturating_add(1)));
480        let session = "work".parse()?;
481
482        let error = SessionPaths::from_home_path(home, &session).expect_err("expected socket path length error");
483
484        assert_that!(error.to_string(), contains_substring("muxr socket path is too long"));
485        Ok(())
486    }
487
488    #[test]
489    fn test_validate_socket_path_when_path_is_too_long_returns_error() {
490        let path = Path::new("/").join("x".repeat(SOCKET_PATH_MAX_BYTES.saturating_add(1)));
491
492        assert_that!(self::validate_socket_path(&path), err(anything()));
493    }
494}