use std::fmt;
use std::fs::{self, DirBuilder, Metadata, OpenOptions};
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
use std::os::unix::net::SocketAddr;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use sha2::{Digest, Sha256};
static PROBES: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub struct Refusal {
pub code: &'static str,
detail: String,
}
impl Refusal {
fn new(code: &'static str, detail: impl fmt::Display) -> Self {
Self {
code,
detail: detail.to_string(),
}
}
}
impl fmt::Display for Refusal {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.code, self.detail)
}
}
impl std::error::Error for Refusal {}
#[derive(Debug)]
pub struct Endpoint {
pub name: String,
pub socket: PathBuf,
pub data_dir: PathBuf,
}
impl Endpoint {
pub fn check_created(&self) -> Result<(), Refusal> {
let actual = fs::canonicalize(&self.data_dir)
.map_err(|error| Refusal::new("data_dir_changed", error))?;
let metadata =
fs::metadata(&actual).map_err(|error| Refusal::new("data_dir_changed", error))?;
if actual != self.data_dir || !metadata.is_dir() {
return Err(Refusal::new(
"data_dir_changed",
"created store location changed",
));
}
Ok(())
}
}
pub fn resolve(
data_dir: &Path,
name: Option<&str>,
socket: Option<&Path>,
create: bool,
) -> Result<Endpoint, Refusal> {
resolve_with(data_dir, name, socket, create, os_root, probe_uid)
}
fn resolve_with(
data_dir: &Path,
name: Option<&str>,
socket: Option<&Path>,
create: bool,
root: impl FnOnce() -> Result<PathBuf, Refusal>,
uid: impl FnOnce(&Path) -> Result<u32, Refusal>,
) -> Result<Endpoint, Refusal> {
if let Some(name) = name {
validate_name(name)?;
}
let data_dir = canonical_data(data_dir, create)?;
let name = match name {
Some(name) => name.to_owned(),
None => data_dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
Refusal::new(
"invalid_store_name",
"data directory needs a UTF-8 basename",
)
})?
.to_owned(),
};
validate_name(&name)?;
if let Some(socket) = socket {
check_endpoint(socket)?;
let parent = socket
.parent()
.ok_or_else(|| Refusal::new("invalid_socket", "endpoint needs a parent"))?;
let metadata = private_directory(parent)?;
let owner = uid(parent)?;
check_identity(parent, &metadata, owner)?;
return Ok(Endpoint {
name,
socket: socket.to_path_buf(),
data_dir,
});
}
let root = root()?;
let metadata = private_directory(&root)?;
let namespace = root.join("haem");
let socket = namespace.join(filename(&name));
check_endpoint(&socket)?;
let owner = uid(&root)?;
check_identity(&root, &metadata, owner)?;
match DirBuilder::new().mode(0o700).create(&namespace) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(Refusal::new("invalid_runtime_location", error)),
}
let namespace_metadata = private_directory(&namespace)?;
check_identity(&namespace, &namespace_metadata, owner)?;
check_identity(&root, &metadata, owner)?;
Ok(Endpoint {
name,
socket,
data_dir,
})
}
fn canonical_data(data: &Path, create: bool) -> Result<PathBuf, Refusal> {
if !data.is_absolute() {
return Err(Refusal::new(
"invalid_data_dir",
"data directory must be absolute",
));
}
if create {
let Some(Component::Normal(name)) = data.components().next_back() else {
return Err(Refusal::new(
"invalid_data_dir",
"creation needs a normal final component",
));
};
if name.to_str().is_none() {
return Err(Refusal::new(
"invalid_store_name",
"creation needs a UTF-8 final component",
));
}
let parent = data
.parent()
.ok_or_else(|| Refusal::new("invalid_data_dir", "creation needs an existing parent"))?;
let parent =
fs::canonicalize(parent).map_err(|error| Refusal::new("invalid_data_dir", error))?;
let metadata =
fs::metadata(&parent).map_err(|error| Refusal::new("invalid_data_dir", error))?;
if !metadata.is_dir() {
return Err(Refusal::new(
"invalid_data_dir",
"creation parent is not a directory",
));
}
Ok(parent.join(name))
} else {
let canonical =
fs::canonicalize(data).map_err(|error| Refusal::new("invalid_data_dir", error))?;
let metadata =
fs::metadata(&canonical).map_err(|error| Refusal::new("invalid_data_dir", error))?;
if !metadata.is_dir() {
return Err(Refusal::new("invalid_data_dir", "store is not a directory"));
}
Ok(canonical)
}
}
fn validate_name(name: &str) -> Result<(), Refusal> {
if name.is_empty()
|| name.len() > 128
|| matches!(name, "." | "..")
|| name.contains(['/', '\\', '\0'])
{
return Err(Refusal::new(
"invalid_store_name",
"name must be 1 to 128 UTF-8 bytes without separators or NUL",
));
}
Ok(())
}
fn check_endpoint(socket: &Path) -> Result<(), Refusal> {
if !socket.is_absolute() || socket.as_os_str().as_bytes().contains(&0) {
return Err(Refusal::new(
"invalid_socket",
"endpoint must be an absolute path without NUL",
));
}
SocketAddr::from_pathname(socket).map_err(|error| Refusal::new("endpoint_too_long", error))?;
Ok(())
}
fn private_directory(path: &Path) -> Result<Metadata, Refusal> {
if !path.is_absolute() {
return Err(Refusal::new(
"invalid_runtime_location",
"runtime directory must be absolute",
));
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| Refusal::new("invalid_runtime_location", error))?;
if !metadata.is_dir() || metadata.permissions().mode() & 0o7777 != 0o700 {
return Err(Refusal::new(
"invalid_runtime_location",
"runtime directory must be a non-symlink directory with mode 0700",
));
}
Ok(metadata)
}
fn check_identity(path: &Path, expected: &Metadata, owner: u32) -> Result<(), Refusal> {
let actual = private_directory(path)?;
if actual.uid() != owner || (actual.dev(), actual.ino()) != (expected.dev(), expected.ino()) {
return Err(Refusal::new(
"invalid_runtime_location",
"runtime directory owner or identity changed",
));
}
Ok(())
}
fn probe_uid(root: &Path) -> Result<u32, Refusal> {
let sequence = PROBES
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
value.checked_add(1)
})
.map_err(|error| Refusal::new("runtime_uid_unavailable", error))?;
let path = root.join(format!(".haem-uid-{}-{sequence}", std::process::id()));
let file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.map_err(|error| Refusal::new("runtime_uid_unavailable", error))?;
let metadata = file.metadata();
let cleanup = fs::remove_file(&path);
drop(file);
finish_probe(metadata, cleanup)
}
fn finish_probe(metadata: io::Result<Metadata>, cleanup: io::Result<()>) -> Result<u32, Refusal> {
match (metadata, cleanup) {
(Ok(metadata), Ok(())) => {
if !metadata.is_file() || metadata.permissions().mode() & 0o7777 != 0o600 {
return Err(Refusal::new(
"runtime_uid_unavailable",
"uid probe must be a regular mode-0600 file",
));
}
Ok(metadata.uid())
}
(Err(error), Ok(())) => Err(Refusal::new("runtime_uid_unavailable", error)),
(Ok(_), Err(error)) => Err(Refusal::new("runtime_cleanup_failed", error)),
(Err(read), Err(cleanup)) => Err(Refusal::new(
"runtime_cleanup_failed",
format!("uid read: {read}; probe removal: {cleanup}"),
)),
}
}
fn filename(name: &str) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut encoded = String::with_capacity(43);
let mut pending = 0_u16;
let mut bits = 0_u32;
for byte in Sha256::digest(name.as_bytes()) {
pending = ((pending & ((1 << bits) - 1)) << 8) | u16::from(byte);
bits += 8;
while bits >= 6 {
bits -= 6;
encoded.push(char::from(ALPHABET[usize::from((pending >> bits) & 63)]));
}
}
if bits != 0 {
encoded.push(char::from(
ALPHABET[usize::from((pending << (6 - bits)) & 63)],
));
}
encoded
}
#[cfg(target_os = "linux")]
fn os_root() -> Result<PathBuf, Refusal> {
linux_root(std::env::var_os("XDG_RUNTIME_DIR"))
}
#[cfg(target_os = "macos")]
fn os_root() -> Result<PathBuf, Refusal> {
getconf_root(
std::process::Command::new("/usr/bin/getconf")
.arg("DARWIN_USER_TEMP_DIR")
.output(),
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn os_root() -> Result<PathBuf, Refusal> {
Err(Refusal::new(
"runtime_location_unavailable",
"platform has no supported OS runtime query",
))
}
#[cfg(any(target_os = "linux", test))]
fn linux_root(input: Option<std::ffi::OsString>) -> Result<PathBuf, Refusal> {
let input = input.filter(|value| !value.is_empty()).ok_or_else(|| {
Refusal::new(
"runtime_location_unavailable",
"XDG_RUNTIME_DIR is unavailable",
)
})?;
let path = PathBuf::from(input);
if !path.is_absolute() || path.to_str().is_none() || path.as_os_str().as_bytes().contains(&0) {
return Err(Refusal::new(
"invalid_runtime_location",
"XDG_RUNTIME_DIR is not an absolute runtime path",
));
}
Ok(path)
}
#[cfg(any(target_os = "macos", test))]
fn getconf_root(input: io::Result<std::process::Output>) -> Result<PathBuf, Refusal> {
let output = input.map_err(|error| Refusal::new("runtime_location_unavailable", error))?;
if !output.status.success() {
return Err(Refusal::new(
"runtime_location_unavailable",
format!("OS runtime query exited {}", output.status),
));
}
let output = std::str::from_utf8(&output.stdout)
.map_err(|error| Refusal::new("invalid_runtime_location", error))?;
let output = output.strip_suffix('\n').map_or(output, |path| path);
if output.is_empty() {
return Err(Refusal::new(
"runtime_location_unavailable",
"OS runtime query returned no path",
));
}
if output.contains(['\n', '\r', '\0']) || !Path::new(output).is_absolute() {
return Err(Refusal::new(
"invalid_runtime_location",
"OS runtime query needs one absolute UTF-8 path",
));
}
Ok(PathBuf::from(output))
}
#[cfg(test)]
pub(crate) fn resolve_input(
data_dir: &Path,
name: Option<&str>,
socket: Option<&Path>,
create: bool,
root: &Path,
uid: u32,
) -> Result<Endpoint, Refusal> {
resolve_with(
data_dir,
name,
socket,
create,
|| Ok(root.to_path_buf()),
|_| Ok(uid),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::process::ExitStatusExt;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn fixture() -> Result<tempfile::TempDir, std::io::Error> {
let directory = tempfile::Builder::new().prefix("r").tempdir_in("/tmp")?;
fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700))?;
Ok(directory)
}
fn query(bytes: &[u8], status: i32) -> std::process::Output {
std::process::Output {
status: std::process::ExitStatus::from_raw(status),
stdout: bytes.to_vec(),
stderr: Vec::new(),
}
}
fn injected(
root: &Path,
uid: u32,
data: &Path,
name: Option<&str>,
socket: Option<&Path>,
create: bool,
) -> Result<Endpoint, Refusal> {
resolve_with(
data,
name,
socket,
create,
|| Ok(root.to_path_buf()),
|_| Ok(uid),
)
}
#[test]
fn unavailable_os_queries_are_named() -> TestResult {
assert_eq!(
linux_root(None).err().ok_or("accepted missing root")?.code,
"runtime_location_unavailable"
);
let missing = io::Error::new(io::ErrorKind::NotFound, "missing query");
assert_eq!(
getconf_root(Err(missing))
.err()
.ok_or("accepted missing query")?
.code,
"runtime_location_unavailable"
);
assert_eq!(
getconf_root(Ok(query(b"ignored\n", 256)))
.err()
.ok_or("accepted failed query")?
.code,
"runtime_location_unavailable"
);
for bytes in [b"".as_slice(), b"\n"] {
assert_eq!(
getconf_root(Ok(query(bytes, 0)))
.err()
.ok_or("accepted empty query")?
.code,
"runtime_location_unavailable"
);
}
Ok(())
}
#[test]
fn query_output_requires_one_absolute_utf8_path() -> TestResult {
for bytes in [
b"relative\n".as_slice(),
b"\xff\n",
b"/one\n/two\n",
b"/one\0\n",
] {
assert_eq!(
getconf_root(Ok(query(bytes, 0)))
.err()
.ok_or("accepted invalid query output")?
.code,
"invalid_runtime_location"
);
}
assert_eq!(
getconf_root(Ok(query(b"/private/runtime/\n", 0)))?,
PathBuf::from("/private/runtime/")
);
Ok(())
}
#[test]
fn root_refusals_leave_no_namespace() -> TestResult {
let directory = fixture()?;
let root = directory.path();
let uid = fs::metadata(root)?.uid();
let data = root.join("store");
fs::create_dir(&data)?;
for candidate in [PathBuf::from("relative"), root.join("missing")] {
assert_eq!(
injected(&candidate, uid, &data, None, None, false)
.err()
.ok_or("accepted invalid root")?
.code,
"invalid_runtime_location"
);
}
let wrong_uid = uid.checked_add(1).ok_or("uid overflow")?;
assert_eq!(
injected(root, wrong_uid, &data, None, None, false)
.err()
.ok_or("accepted foreign owner")?
.code,
"invalid_runtime_location"
);
for mode in [0o720, 0o702, 0o755] {
fs::set_permissions(root, fs::Permissions::from_mode(mode))?;
assert_eq!(
injected(root, uid, &data, None, None, false)
.err()
.ok_or("accepted nonprivate root")?
.code,
"invalid_runtime_location"
);
assert!(!root.join("haem").try_exists()?);
}
fs::set_permissions(root, fs::Permissions::from_mode(0o700))?;
let link = root.join("substituted");
std::os::unix::fs::symlink(root, &link)?;
assert_eq!(
injected(&link, uid, &data, None, None, false)
.err()
.ok_or("accepted root symlink")?
.code,
"invalid_runtime_location"
);
assert!(!root.join("haem").try_exists()?);
directory.close()?;
Ok(())
}
#[test]
fn names_are_validated_before_runtime_entries() -> TestResult {
let directory = fixture()?;
let root = directory.path();
let data = root.join("store");
fs::create_dir(&data)?;
let uid = fs::metadata(root)?.uid();
let long = "x".repeat(129);
for name in [
"",
".",
"..",
"../store",
"back\\slash",
"nul\0name",
long.as_str(),
] {
assert_eq!(
injected(root, uid, &data, Some(name), None, false)
.err()
.ok_or("accepted invalid name")?
.code,
"invalid_store_name"
);
assert_eq!(fs::read_dir(root)?.collect::<Result<Vec<_>, _>>()?.len(), 1);
}
directory.close()?;
Ok(())
}
#[test]
fn directory_names_and_explicit_names_choose_deterministic_private_endpoints() -> TestResult {
let directory = fixture()?;
let root = directory.path();
let uid = fs::metadata(root)?.uid();
let first = root.join("first");
let second = root.join("second");
fs::create_dir(&first)?;
fs::create_dir(&second)?;
let a = injected(root, uid, &first, None, None, false)?;
let b = injected(root, uid, &second, None, None, false)?;
assert_eq!(a.name, "first");
assert_eq!(b.name, "second");
assert_ne!(a.socket, b.socket);
assert_eq!(a.socket.parent(), Some(root.join("haem").as_path()));
assert_eq!(
fs::symlink_metadata(root.join("haem"))?
.permissions()
.mode()
& 0o7777,
0o700
);
assert_eq!(
injected(root, uid, &first, None, None, false)?.socket,
a.socket
);
let named = injected(root, uid, &first, Some("chosen"), None, false)?;
assert_eq!(named.name, "chosen");
assert_ne!(named.socket, a.socket);
SocketAddr::from_pathname(&named.socket)?;
let left = first.join("same");
let right = second.join("same");
fs::create_dir(&left)?;
fs::create_dir(&right)?;
assert_eq!(
injected(root, uid, &left, None, None, false)?.socket,
injected(root, uid, &right, None, None, false)?.socket
);
assert_ne!(
injected(root, uid, &left, Some("left"), None, false)?.socket,
injected(root, uid, &right, Some("right"), None, false)?.socket
);
directory.close()?;
Ok(())
}
#[test]
fn explicit_socket_is_preserved_without_querying_the_os() -> TestResult {
let directory = fixture()?;
let data = directory.path().join("store");
fs::create_dir(&data)?;
let socket = directory.path().join("legacy.sock");
let uid = fs::metadata(directory.path())?.uid();
let endpoint = resolve_with(
&data,
None,
Some(&socket),
false,
|| {
Err(Refusal::new(
"runtime_location_unavailable",
"must not query",
))
},
|_| Ok(uid),
)?;
assert_eq!(endpoint.socket, socket);
assert_eq!(endpoint.name, "store");
assert!(!directory.path().join("haem").try_exists()?);
directory.close()?;
Ok(())
}
#[test]
fn namespace_permission_and_symlink_substitution_are_refused() -> TestResult {
let directory = fixture()?;
let root = directory.path();
let uid = fs::metadata(root)?.uid();
let data = root.join("store");
fs::create_dir(&data)?;
let namespace = root.join("haem");
fs::create_dir(&namespace)?;
fs::set_permissions(&namespace, fs::Permissions::from_mode(0o755))?;
assert_eq!(
injected(root, uid, &data, None, None, false)
.err()
.ok_or("accepted wrong namespace mode")?
.code,
"invalid_runtime_location"
);
assert_eq!(
fs::metadata(&namespace)?.permissions().mode() & 0o777,
0o755
);
fs::remove_dir(&namespace)?;
std::os::unix::fs::symlink(&data, &namespace)?;
assert_eq!(
injected(root, uid, &data, None, None, false)
.err()
.ok_or("accepted namespace symlink")?
.code,
"invalid_runtime_location"
);
assert!(fs::symlink_metadata(&namespace)?.file_type().is_symlink());
directory.close()?;
Ok(())
}
#[test]
fn endpoint_length_is_refused_before_namespace_creation() -> TestResult {
let directory = fixture()?;
let root = directory.path().join("x".repeat(110));
fs::create_dir(&root)?;
fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?;
let data = directory.path().join("store");
fs::create_dir(&data)?;
assert_eq!(
injected(&root, fs::metadata(&root)?.uid(), &data, None, None, false)
.err()
.ok_or("accepted oversized endpoint")?
.code,
"endpoint_too_long"
);
assert!(!root.join("haem").try_exists()?);
directory.close()?;
Ok(())
}
#[test]
fn creation_canonicalizes_parent_and_checks_the_created_path() -> TestResult {
let directory = fixture()?;
let root = directory.path();
let uid = fs::metadata(root)?.uid();
let data = root.join("new-store");
let endpoint = injected(root, uid, &data, None, None, true)?;
assert_eq!(endpoint.name, "new-store");
assert_eq!(endpoint.data_dir, fs::canonicalize(root)?.join("new-store"));
fs::create_dir(&data)?;
endpoint.check_created()?;
fs::remove_dir(&data)?;
let other = root.join("other");
fs::create_dir(&other)?;
std::os::unix::fs::symlink(&other, &data)?;
assert_eq!(
endpoint
.check_created()
.err()
.ok_or("accepted changed created path")?
.code,
"data_dir_changed"
);
directory.close()?;
Ok(())
}
#[test]
fn uid_probe_is_removed_and_matches_the_created_file_owner() -> TestResult {
let directory = fixture()?;
assert_eq!(
probe_uid(directory.path())?,
fs::metadata(directory.path())?.uid()
);
assert_eq!(
fs::read_dir(directory.path())?
.collect::<Result<Vec<_>, _>>()?
.len(),
0
);
directory.close()?;
Ok(())
}
#[test]
fn uid_read_and_cleanup_failures_are_preserved() -> TestResult {
let directory = fixture()?;
assert_eq!(
finish_probe(fs::metadata(directory.path()), Ok(()))
.err()
.ok_or("accepted invalid probe shape")?
.code,
"runtime_uid_unavailable"
);
let read = io::Error::other("read failed");
assert_eq!(
finish_probe(Err(read), Ok(()))
.err()
.ok_or("accepted failed uid read")?
.code,
"runtime_uid_unavailable"
);
let cleanup = io::Error::other("cleanup failed");
assert_eq!(
finish_probe(fs::metadata(directory.path()), Err(cleanup))
.err()
.ok_or("accepted failed cleanup")?
.code,
"runtime_cleanup_failed"
);
let both = finish_probe(
Err(io::Error::other("read failed")),
Err(io::Error::other("cleanup failed")),
)
.err()
.ok_or("accepted both failures")?;
assert_eq!(both.code, "runtime_cleanup_failed");
assert!(both.to_string().contains("read failed"));
assert!(both.to_string().contains("cleanup failed"));
directory.close()?;
Ok(())
}
#[test]
fn byte_limits_and_full_digest_encoding_preserve_names() -> TestResult {
validate_name(&"é".repeat(64))?;
assert_eq!(
validate_name(&"é".repeat(65))
.err()
.ok_or("accepted 130-byte name")?
.code,
"invalid_store_name"
);
let a = filename("store");
let b = filename("other");
assert_eq!(a.len(), 43);
assert_eq!(a, "gk2A1xmF8IKiaZeo24i10d1Ft3fXNYXQPSNjA-Ib3pc");
assert!(
a.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
);
assert_ne!(a, b);
assert_eq!(a, filename("store"));
Ok(())
}
}