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";
15pub 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#[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 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 #[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 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 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 pub fn logs_root(&self) -> rootcause::Result<PathBuf> {
168 Ok(self.state_root()?.join(LOGS_DIR_NAME))
169 }
170
171 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 #[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
230pub fn validate_socket_path(path: &Path) -> rootcause::Result<()> {
235 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 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, ×tamp, 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, ×tamp, 12345)?,
451 not(eq(paths.server_log_path(&session, ×tamp, 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}