use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use engenho_config::{SocketAccess, check_socket_path_len};
use engenho_store::data_dir_lock::{DataDirLock, LockError};
use tokio::net::UnixListener;
#[derive(Debug, thiserror::Error)]
pub enum SocketError {
#[error("control socket path: {0}")]
Path(String),
#[error("another engenho daemon serves {}: {source}", path.display())]
InUse {
path: PathBuf,
source: LockError,
},
#[error("refusing to replace {}: it is a {found}, not a socket", path.display())]
NotASocket {
path: PathBuf,
found: &'static str,
},
#[error("the control socket directory {} {problem}", dir.display())]
UnsafeDirectory {
dir: PathBuf,
problem: String,
},
#[error("{op} {}: {source}", path.display())]
Io {
op: &'static str,
path: PathBuf,
source: std::io::Error,
},
}
#[derive(Debug)]
pub struct BoundSocket {
pub listener: UnixListener,
pub guard: SocketGuard,
}
#[derive(Debug)]
pub struct SocketGuard {
path: PathBuf,
_lock: DataDirLock,
}
impl SocketGuard {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for SocketGuard {
fn drop(&mut self) {
if std::fs::symlink_metadata(&self.path).is_ok_and(|m| m.file_type().is_socket()) {
let _ = std::fs::remove_file(&self.path);
}
}
}
#[must_use]
pub const fn socket_mode(access: SocketAccess) -> u32 {
match access {
SocketAccess::Owner => 0o600,
SocketAccess::Group => 0o660,
}
}
pub fn bind(path: &Path, access: SocketAccess, euid: u32) -> Result<BoundSocket, SocketError> {
if !path.is_absolute() {
return Err(SocketError::Path(format!(
"{} is not absolute",
path.display()
)));
}
check_socket_path_len(path).map_err(SocketError::Path)?;
let dir = path
.parent()
.ok_or_else(|| SocketError::Path(format!("{} has no directory", path.display())))?;
prepare_dir(dir, access, euid)?;
let lock = DataDirLock::acquire(path).map_err(|source| SocketError::InUse {
path: path.to_path_buf(),
source,
})?;
let io = |op: &'static str| {
let path = path.to_path_buf();
move |source| SocketError::Io { op, path, source }
};
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_socket() => {
std::fs::remove_file(path).map_err(io("remove the stale socket"))?;
}
Ok(meta) => {
return Err(SocketError::NotASocket {
path: path.to_path_buf(),
found: kind(&meta),
});
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(io("inspect")(err)),
}
let listener = UnixListener::bind(path).map_err(io("bind"))?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(socket_mode(access)))
.map_err(io("set the mode of"))?;
Ok(BoundSocket {
listener,
guard: SocketGuard {
path: path.to_path_buf(),
_lock: lock,
},
})
}
fn prepare_dir(dir: &Path, access: SocketAccess, euid: u32) -> Result<(), SocketError> {
let io = |op: &'static str| {
let path = dir.to_path_buf();
move |source| SocketError::Io { op, path, source }
};
let created = !dir.exists();
if created {
std::fs::create_dir_all(dir).map_err(io("create"))?;
}
let meta = std::fs::symlink_metadata(dir).map_err(io("inspect"))?;
let unsafe_dir = |problem: String| SocketError::UnsafeDirectory {
dir: dir.to_path_buf(),
problem,
};
if !meta.is_dir() {
return Err(unsafe_dir(format!("is a {}, not a directory", kind(&meta))));
}
if meta.uid() != euid {
return Err(unsafe_dir(format!(
"is owned by uid {}, not by the daemon's uid {euid}",
meta.uid()
)));
}
match access {
SocketAccess::Owner => {
if meta.mode() & 0o777 != 0o700 {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(io("set the mode of"))?;
}
}
SocketAccess::Group => {
if meta.mode() & 0o002 != 0 {
return Err(unsafe_dir(format!(
"is writable by anyone (mode {:o})",
meta.mode() & 0o7777
)));
}
}
}
Ok(())
}
fn kind(meta: &std::fs::Metadata) -> &'static str {
let t = meta.file_type();
if t.is_symlink() {
"symlink"
} else if t.is_dir() {
"directory"
} else if t.is_file() {
"regular file"
} else if t.is_socket() {
"socket"
} else {
"special file"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn euid() -> u32 {
nix::unistd::geteuid().as_raw()
}
#[tokio::test]
async fn a_socket_is_private_and_a_second_daemon_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("run").join("control.sock");
let bound = bind(&path, SocketAccess::Owner, euid()).expect("bind");
let mode = std::fs::metadata(&path).expect("stat").mode() & 0o777;
assert_eq!(mode, 0o600);
let dir_mode = std::fs::metadata(path.parent().expect("dir"))
.expect("stat")
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700);
assert!(matches!(
bind(&path, SocketAccess::Owner, euid()),
Err(SocketError::InUse { .. })
));
drop(bound);
assert!(!path.exists(), "the guard removes the socket");
}
#[tokio::test]
async fn a_dead_daemons_socket_is_replaced() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("control.sock");
let stale = std::os::unix::net::UnixListener::bind(&path).expect("stale socket");
drop(stale);
assert!(path.exists());
bind(&path, SocketAccess::Owner, euid()).expect("the stale socket is replaced");
}
#[tokio::test]
async fn nothing_but_a_socket_is_ever_removed() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = tmp.path().join("precious");
std::fs::write(&target, b"keep me").expect("write");
let path = tmp.path().join("control.sock");
std::os::unix::fs::symlink(&target, &path).expect("symlink");
assert!(matches!(
bind(&path, SocketAccess::Owner, euid()),
Err(SocketError::NotASocket {
found: "symlink",
..
})
));
assert!(
std::fs::symlink_metadata(&path).is_ok(),
"the symlink is still there"
);
assert_eq!(std::fs::read(&target).expect("read"), b"keep me");
let file = tmp.path().join("file.sock");
std::fs::write(&file, b"x").expect("write");
assert!(matches!(
bind(&file, SocketAccess::Owner, euid()),
Err(SocketError::NotASocket {
found: "regular file",
..
})
));
}
#[tokio::test]
async fn a_path_too_long_for_a_socket_is_refused_by_name() {
let path = PathBuf::from(format!("/tmp/{}/control.sock", "d".repeat(120)));
assert!(matches!(
bind(&path, SocketAccess::Owner, euid()),
Err(SocketError::Path(_))
));
}
#[tokio::test]
async fn a_directory_someone_else_owns_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("control.sock");
let other = euid().wrapping_add(1);
assert!(matches!(
bind(&path, SocketAccess::Owner, other),
Err(SocketError::UnsafeDirectory { .. })
));
}
}