diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index 0d0d9f9..eb19f4b 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -314,14 +314,82 @@ const SOCKET_PATH_MAX: usize = 100; /// The path to a socket named `name` in `dir`, short enough for the /// system: the path itself when it fits; else, on Linux, the same place /// reached through `/proc/self/fd` and a handle on `dir` kept open while -/// this lives (it is good only in this process). Elsewhere a path too -/// long is refused, saying so plainly (no path named). +/// this lives (it is good only in this process); on the other Unixes +/// (macOS), the same place reached through a short link of this +/// account's under `/tmp` (`short_link_for`), which the server and its +/// clients compute alike. A root in a deep home on a Mac (the portable +/// job found it: a test home under the runner's temp folder plus +/// `Library/Application Support/wanderbooks/run/`) is served as any other. #[derive(Debug)] pub struct SocketPath { path: PathBuf, _dir: Option, } +/// A short path that reaches `dir`: the symbolic link +/// `/tmp/wanderbooks-/` → `dir`, inside a +/// folder of this account's alone (made 0700; refused if it is anyone +/// else's, writable by others, or not a folder, since `/tmp` is shared), +/// and the link itself refused unless it is this account's and names +/// `dir` exactly (a stale or foreign link is replaced). Short enough for +/// a socket under it with the longest name in use. +#[cfg(unix)] +pub fn short_link_for(dir: &Path) -> std::io::Result { + use sha2::{Digest, Sha256}; + use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; + let not_ours = |what: &str| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{what} under /tmp isn't this account's own private one: remove it"), + ) + }; + // SAFETY: getuid has no preconditions and never fails. + let uid = unsafe { libc::getuid() }; + let base = PathBuf::from("/tmp").join(format!("wanderbooks-{uid}")); + match std::fs::symlink_metadata(&base) { + Ok(meta) => { + if !meta.file_type().is_dir() + || meta.uid() != uid + || meta.permissions().mode() & 0o077 != 0 + { + return Err(not_ours("the sockets' folder")); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + std::fs::DirBuilder::new().mode(0o700).create(&base)?; + let meta = std::fs::symlink_metadata(&base)?; + if meta.uid() != uid || meta.permissions().mode() & 0o077 != 0 { + return Err(not_ours("the sockets' folder")); + } + } + Err(e) => return Err(e), + } + let dir = std::fs::canonicalize(dir)?; + let link = base.join(&hex::encode(Sha256::digest(dir.as_os_str().as_encoded_bytes()))[..16]); + match std::fs::symlink_metadata(&link) { + Ok(meta) => { + let same = meta.file_type().is_symlink() + && meta.uid() == uid + && std::fs::read_link(&link).map(|t| t == dir).unwrap_or(false); + if same { + return Ok(link); + } + if meta.uid() != uid { + return Err(not_ours("the socket link")); + } + if meta.file_type().is_dir() { + std::fs::remove_dir(&link)?; + } else { + std::fs::remove_file(&link)?; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + std::os::unix::fs::symlink(&dir, &link)?; + Ok(link) +} + impl SocketPath { /// The socket `name` in `dir`. pub fn new(dir: &Path, name: &str) -> std::io::Result { @@ -342,7 +410,15 @@ impl SocketPath { _dir: Some(handle), }) } - #[cfg(not(target_os = "linux"))] + #[cfg(all(unix, not(target_os = "linux")))] + { + let short = short_link_for(dir)?; + Ok(Self { + path: short.join(name), + _dir: None, + }) + } + #[cfg(not(unix))] Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "the books' private folder's path is too long for its sockets: choose a shorter place with `wanderbooks setup --root`", diff --git a/crates/ledger-store/tests/socket_path.rs b/crates/ledger-store/tests/socket_path.rs new file mode 100644 index 0000000..9d944c6 --- /dev/null +++ b/crates/ledger-store/tests/socket_path.rs @@ -0,0 +1,68 @@ +//! Socket paths for roots too deep for a Unix socket's address (about 104 +//! bytes): Linux reaches them through `/proc/self/fd`, the other Unixes +//! through a short private link under `/tmp`, so a books root in a deep +//! home is served like any other (the portable macOS job found the +//! refusal). +#![cfg(unix)] + +use ledger_store::root::{SocketPath, short_link_for}; +use std::path::PathBuf; + +fn deep_dir(tag: &str) -> PathBuf { + let deep = std::env::temp_dir() + .join(format!("wb-socket-{tag}-{}", std::process::id())) + .join("a".repeat(60)) + .join("b".repeat(60)) + .join("run"); + std::fs::create_dir_all(&deep).unwrap(); + deep +} + +/// A root deeper than the address allows still gets a socket path that +/// fits, and a second asker (a client) reaches the same place. +#[test] +fn a_deep_root_gets_a_socket_path_that_fits() { + let deep = deep_dir("fits"); + assert!(deep.as_os_str().len() > 100); + let a = SocketPath::new(&deep, "mcp.sock").unwrap(); + assert!(a.path().as_os_str().len() <= 100, "{:?}", a.path()); + // The place it reaches is the dir itself. + let reached = std::fs::canonicalize(a.path().parent().unwrap()).unwrap(); + assert_eq!(reached, std::fs::canonicalize(&deep).unwrap()); +} + +/// The short link is this account's, names the dir exactly, is the same +/// on a second ask, and replaces a stale link of ours in its place. +#[test] +fn the_short_link_is_private_stable_and_replaces_a_stale_one() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let deep = deep_dir("link"); + let link = short_link_for(&deep).unwrap(); + assert!( + link.as_os_str().len() + "/lease.sock".len() <= 100, + "{link:?}" + ); + // SAFETY: getuid has no preconditions and never fails. + let uid = unsafe { libc::getuid() }; + assert_eq!( + link.parent().unwrap(), + PathBuf::from(format!("/tmp/wanderbooks-{uid}")) + ); + let folder = std::fs::symlink_metadata(link.parent().unwrap()).unwrap(); + assert_eq!(folder.uid(), uid); + assert_eq!(folder.permissions().mode() & 0o777, 0o700); + assert_eq!( + std::fs::read_link(&link).unwrap(), + std::fs::canonicalize(&deep).unwrap() + ); + assert_eq!(short_link_for(&deep).unwrap(), link); + // A stale link of ours (to somewhere else) in that place is replaced. + let elsewhere = deep_dir("elsewhere"); + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&elsewhere, &link).unwrap(); + assert_eq!(short_link_for(&deep).unwrap(), link); + assert_eq!( + std::fs::read_link(&link).unwrap(), + std::fs::canonicalize(&deep).unwrap() + ); +}