pmacs/src/socket_path.rs

266 lines
9.4 KiB
Rust

// 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 `<runtime>/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`.
//!
//! `<runtime>` is `$XDG_RUNTIME_DIR` when set, else
//! `/tmp/pmacs-<uid>`. 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 (`<runtime>/pmacs/` or
//! `/tmp/pmacs-<uid>/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<std::io::Error> 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-<uid>` 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-<uid>`.
#[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 `<runtime>/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");
}
}