fix(store): a root too deep for a socket's address is served on macOS too
A Unix socket's address holds about 104 bytes. Linux reached a deep
root's sockets through /proc/self/fd; every other Unix refused the
root ("too long for its sockets"), which the portable macOS job met
with a test home under the runner's temp folder, and a Mac with a deep
home would meet too. On those systems the socket path now goes through
a short link of this account's, /tmp/wanderbooks-<uid>/<hash of the
dir>, in a folder made 0700 and refused unless it is this account's
own; a stale or foreign link in its place is replaced or refused. The
server and its clients compute the same link, so nothing else changes.
Tests (tests/socket_path.rs, every Unix): a deep root gets a path that
fits and reaches the dir; the link is private, stable, and replaces a
stale one of ours.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
1da01c3123
commit
74f1691f77
2 files changed
+147
-3
No files matched your search
@@ -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<std::fs::File>,
|
||||
}
|
||||
|
||||
/// A short path that reaches `dir`: the symbolic link
|
||||
/// `/tmp/wanderbooks-<uid>/<sha256 of dir, 16 hex>` → `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<PathBuf> {
|
||||
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<Self> {
|
||||
@@ -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`",
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user