// socket_path.rs --- Resolve and prepare the daemon socket path. //! Socket-path resolution and directory preparation for the M5.5 //! local-attach transport (T M5.5c). //! //! # Path semantics //! //! - `--socket NAME` (no `/`) resolves to `/pmacs/NAME.sock`. //! - `--socket PATH` (any `/`) is used verbatim — relative paths pass //! through; resolution happens at `bind(2)` time, which is the //! user's choice. //! - omitted argument is equivalent to `--socket default`. //! //! `` is `$XDG_RUNTIME_DIR` when set, else //! `/tmp/pmacs-`. The fallback is the standard XDG-spec fallback //! for environments where the runtime dir isn't provisioned (macOS, //! minimal containers, some CI configurations). //! //! # Permission posture //! //! Both the parent directory chain (`/pmacs/` or //! `/tmp/pmacs-/pmacs/`) and the eventual socket file must be //! inaccessible to non-owner. [`ensure_runtime_subdir`] enforces this //! on the directory side; the socket file itself gets mode 0600 via //! `bind(2)` under `umask(0077)` in M5.5d. use std::env; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; /// Errors from socket-path resolution and directory preparation. #[derive(Debug)] pub enum SocketPathError { /// Could not create or access the runtime directory. Io(std::io::Error), /// An existing directory is too permissive — at least one /// group-or-other permission bit is set, which would expose the /// daemon socket beyond the owning user. DirectoryTooPermissive { /// The path that failed the check. path: PathBuf, /// Octal permission bits as returned by `stat(2)` (file-type /// bits masked off; e.g. `0o755`). mode: u32, }, } impl std::fmt::Display for SocketPathError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "socket-path I/O error: {e}"), Self::DirectoryTooPermissive { path, mode } => write!( f, "directory {} is too permissive (mode {mode:#o}); expected mode 0700 or stricter", path.display() ), } } } impl std::error::Error for SocketPathError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e), Self::DirectoryTooPermissive { .. } => None, } } } impl From for SocketPathError { fn from(e: std::io::Error) -> Self { Self::Io(e) } } /// Resolve a `--socket` argument to a socket path. /// /// Reads `$XDG_RUNTIME_DIR` from the environment; falls back to /// `/tmp/pmacs-` when unset or empty. The path is constructed — /// directory creation is in [`ensure_runtime_subdir`]. #[must_use] pub fn resolve_socket_path(arg: Option<&str>) -> PathBuf { resolve_socket_path_with_runtime(arg, &runtime_dir()) } /// Pure path-construction logic. /// /// Exposed so tests can vary the runtime dir without mutating the /// process environment (which is `unsafe` under Rust 2024 and would /// race with parallel test threads anyway). #[must_use] pub fn resolve_socket_path_with_runtime(arg: Option<&str>, runtime: &Path) -> PathBuf { match arg { None => runtime.join("pmacs").join("default.sock"), Some(s) if s.contains('/') => PathBuf::from(s), Some(name) => runtime.join("pmacs").join(format!("{name}.sock")), } } /// Effective runtime directory: `$XDG_RUNTIME_DIR` if set and /// non-empty, else `/tmp/pmacs-`. #[must_use] pub fn runtime_dir() -> PathBuf { if let Some(xdg) = env::var_os("XDG_RUNTIME_DIR") && !xdg.is_empty() { return PathBuf::from(xdg); } PathBuf::from(format!("/tmp/pmacs-{}", current_uid())) } /// Create the parent directory chain of `socket_path` if missing, with /// mode 0700. If the parent already exists, verify that no /// group-or-other permission bit is set; reject otherwise. /// /// Called by the daemon at startup to prepare `/pmacs/` /// before `bind(2)`. pub fn ensure_runtime_subdir(socket_path: &Path) -> Result<(), SocketPathError> { let Some(parent) = socket_path.parent() else { return Ok(()); }; // `Path::parent` returns `Some("")` for paths with no directory // component (e.g. `"nope.sock"`); treat that as "no parent." if parent.as_os_str().is_empty() { return Ok(()); } if !parent.exists() { fs::create_dir_all(parent)?; fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; return Ok(()); } let metadata = fs::metadata(parent)?; // Mask off file-type bits (S_IFMT, top 4 bits): only the // permission bits matter here. let mode = metadata.permissions().mode() & 0o7777; if (mode & 0o077) != 0 { return Err(SocketPathError::DirectoryTooPermissive { path: parent.to_path_buf(), mode, }); } Ok(()) } #[must_use] fn current_uid() -> u32 { nix::unistd::getuid().as_raw() } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; #[test] fn default_arg_resolves_to_default_sock_under_runtime_pmacs() { let runtime = Path::new("/run/user/1000"); let p = resolve_socket_path_with_runtime(None, runtime); assert_eq!(p, Path::new("/run/user/1000/pmacs/default.sock")); } #[test] fn bare_name_resolves_to_named_sock_under_runtime_pmacs() { let runtime = Path::new("/run/user/1000"); let p = resolve_socket_path_with_runtime(Some("research"), runtime); assert_eq!(p, Path::new("/run/user/1000/pmacs/research.sock")); } #[test] fn absolute_path_passes_through_unchanged() { let runtime = Path::new("/run/user/1000"); let p = resolve_socket_path_with_runtime(Some("/tmp/foo.sock"), runtime); assert_eq!(p, Path::new("/tmp/foo.sock")); } #[test] fn relative_path_with_slash_passes_through_unchanged() { // The slash detection sends ./relative through verbatim. The // user took the leap of typing a slash; we trust them. let runtime = Path::new("/run/user/1000"); let p = resolve_socket_path_with_runtime(Some("./relative.sock"), runtime); assert_eq!(p, Path::new("./relative.sock")); } #[test] fn creates_runtime_subdir_with_mode_0700_when_missing() { let tempdir = TempDir::new().unwrap(); let socket_path = tempdir.path().join("pmacs").join("default.sock"); ensure_runtime_subdir(&socket_path).expect("create"); let parent = socket_path.parent().unwrap(); assert!(parent.is_dir()); let mode = fs::metadata(parent).unwrap().permissions().mode() & 0o7777; assert_eq!(mode, 0o700, "expected mode 0700, got {mode:#o}"); } #[test] fn accepts_existing_runtime_subdir_with_mode_0700() { let tempdir = TempDir::new().unwrap(); let parent = tempdir.path().join("pmacs"); fs::create_dir(&parent).unwrap(); fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap(); let socket_path = parent.join("default.sock"); ensure_runtime_subdir(&socket_path).expect("accept"); } #[test] fn accepts_existing_runtime_subdir_with_stricter_mode() { // 0500 (read+execute, no write) is stricter than 0700; still // owner-only, no group/other bits, accepted. let tempdir = TempDir::new().unwrap(); let parent = tempdir.path().join("pmacs"); fs::create_dir(&parent).unwrap(); fs::set_permissions(&parent, fs::Permissions::from_mode(0o500)).unwrap(); let socket_path = parent.join("default.sock"); ensure_runtime_subdir(&socket_path).expect("accept stricter"); } #[test] fn rejects_existing_runtime_subdir_with_group_readable_mode() { let tempdir = TempDir::new().unwrap(); let parent = tempdir.path().join("pmacs"); fs::create_dir(&parent).unwrap(); fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap(); let socket_path = parent.join("default.sock"); match ensure_runtime_subdir(&socket_path) { Err(SocketPathError::DirectoryTooPermissive { mode, path }) => { assert_eq!(mode, 0o755); assert_eq!(path, parent); } other => panic!("expected DirectoryTooPermissive, got {other:?}"), } } #[test] fn rejects_existing_runtime_subdir_with_other_readable_mode() { let tempdir = TempDir::new().unwrap(); let parent = tempdir.path().join("pmacs"); fs::create_dir(&parent).unwrap(); fs::set_permissions(&parent, fs::Permissions::from_mode(0o704)).unwrap(); let socket_path = parent.join("default.sock"); match ensure_runtime_subdir(&socket_path) { Err(SocketPathError::DirectoryTooPermissive { mode, .. }) => { assert_eq!(mode, 0o704); } other => panic!("expected DirectoryTooPermissive, got {other:?}"), } } #[test] fn ensure_runtime_subdir_with_no_parent_is_ok() { // Edge case: a path with no parent (e.g. "default.sock" with // no directory component) should not panic. There's nothing // to create or check. let p = PathBuf::from("nope.sock"); ensure_runtime_subdir(&p).expect("no parent → no-op"); } }