use super::{TransportError, TransportResult, MAX_MESSAGE_SIZE};
use std::path::{Path, PathBuf};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub fn ensure_private_socket_dir(socket_path: &Path, create: bool) -> TransportResult<()> {
use std::os::unix::fs::PermissionsExt;
let Some(dir) = socket_path.parent() else {
return Err(TransportError::Other(format!(
"socket path has no parent directory: {}",
socket_path.display()
)));
};
match std::fs::symlink_metadata(dir) {
Ok(meta) => {
if meta.file_type().is_symlink() {
return Err(TransportError::Other(format!(
"refusing IPC socket directory (symlink): {}",
dir.display()
)));
}
use std::os::unix::fs::MetadataExt;
if meta.uid() != unsafe { libc::geteuid() } {
return Err(TransportError::Other(format!(
"refusing IPC socket directory (not owned by the current user): {}",
dir.display()
)));
}
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(TransportError::Other(format!(
"refusing IPC socket directory (not owner-only, mode {:o}): {} \
— the daemon and clients only accept sockets inside a private \
runtime directory (0700)",
mode & 0o777,
dir.display()
)));
}
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => {
std::fs::create_dir_all(dir).map_err(TransportError::Io)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(TransportError::Io)?;
Ok(())
}
Err(e) => Err(TransportError::Io(e)),
}
}
pub struct UnixSocketConnection {
stream: tokio::net::UnixStream,
}
impl UnixSocketConnection {
pub fn from_stream(stream: tokio::net::UnixStream) -> Self {
Self { stream }
}
pub async fn connect(path: PathBuf) -> TransportResult<Self> {
ensure_private_socket_dir(&path, false)?;
let stream = tokio::net::UnixStream::connect(&path).await.map_err(|e| {
TransportError::ConnectionFailed(format!(
"Failed to connect to {}: {}",
path.display(),
e
))
})?;
Ok(Self { stream })
}
pub async fn read_message(&mut self) -> TransportResult<Vec<u8>> {
let mut length_buf = [0u8; 4];
self.stream.read_exact(&mut length_buf).await?;
let length = u32::from_be_bytes(length_buf) as usize;
if length == 0 || length > MAX_MESSAGE_SIZE {
return Err(TransportError::MessageTooLarge {
size: length,
max: MAX_MESSAGE_SIZE,
});
}
let mut buffer = vec![0u8; length];
self.stream.read_exact(&mut buffer).await?;
Ok(buffer)
}
pub async fn write_message(&mut self, data: &[u8]) -> TransportResult<()> {
let length = data.len() as u32;
if length as usize > MAX_MESSAGE_SIZE {
return Err(TransportError::MessageTooLarge {
size: data.len(),
max: MAX_MESSAGE_SIZE,
});
}
self.stream.write_all(&length.to_be_bytes()).await?;
self.stream.write_all(data).await?;
self.stream.flush().await?;
Ok(())
}
pub async fn close(&mut self) -> TransportResult<()> {
self.stream.shutdown().await?;
Ok(())
}
pub fn is_open(&self) -> bool {
self.stream.peer_addr().is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn private_dir() -> PathBuf {
let dir = tempfile::TempDir::new().unwrap().keep();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
}
dir
}
#[tokio::test]
async fn test_unix_socket_connection_roundtrip() {
let dir = private_dir();
let socket_path = dir.join(format!("test_ipc_{}.sock", uuid_v4()));
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
let server_handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut conn = UnixSocketConnection::from_stream(stream);
let msg = conn.read_message().await.unwrap();
conn.write_message(&msg).await.unwrap();
conn.close().await.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
let mut client = UnixSocketConnection::connect(socket_path).await.unwrap();
let test_data = b"Hello, IPC!";
client.write_message(test_data).await.unwrap();
let received = client.read_message().await.unwrap();
assert_eq!(received, test_data);
server_handle.await.unwrap();
}
#[tokio::test]
async fn client_refuses_socket_in_loose_directory() {
let outer = tempfile::TempDir::new().unwrap();
let loose = outer.path().join("loose");
std::fs::create_dir_all(&loose).unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&loose, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let socket_path = loose.join("sock");
let err = match UnixSocketConnection::connect(socket_path).await {
Err(err) => err,
Ok(_) => panic!("loose socket dir must be refused"),
};
assert!(
err.to_string().contains("owner-only"),
"refusal must name the policy: {err}"
);
}
#[tokio::test]
async fn client_accepts_socket_in_private_directory() {
let dir = private_dir();
let socket_path = dir.join("sock");
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
tokio::spawn(async move {
let listener = listener;
let _ = listener.accept().await;
});
tokio::time::sleep(Duration::from_millis(50)).await;
let client = UnixSocketConnection::connect(socket_path).await;
assert!(client.is_ok(), "private-dir socket must be accepted");
}
fn uuid_v4() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("proto{n}", n = nanos)
}
}