use super::{EvalFn, ListWindowsFn, PressHooksRef, handle_connection};
use crate::error::Error;
use crate::eval::EvalEngine;
use crate::recorder::Recorder;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::io::AsRawFd;
use std::sync::Arc;
use tokio::net::UnixListener;
pub struct SocketGuard {
path: std::path::PathBuf,
inode: u64,
}
impl Drop for SocketGuard {
fn drop(&mut self) {
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = std::fs::metadata(&self.path)
&& meta.ino() == self.inode
{
let _ = std::fs::remove_file(&self.path);
tracing::info!(path = %self.path.display(), "socket removed");
}
}
}
fn inode_from_raw_fd(fd: std::os::unix::io::RawFd) -> u64 {
unsafe {
let mut stat = std::mem::MaybeUninit::<libc::stat>::uninit();
if libc::fstat(fd, stat.as_mut_ptr()) == 0 { stat.assume_init().st_ino } else { 0 }
}
}
fn is_private_dir(path: &std::path::Path) -> bool {
use std::os::unix::fs::MetadataExt;
match std::fs::metadata(path) {
Ok(m) => {
let my_uid = unsafe { libc::getuid() };
m.is_dir() && m.uid() == my_uid && m.mode().trailing_zeros() >= 6
}
Err(_) => false,
}
}
fn socket_dir_from(xdg: Option<std::ffi::OsString>) -> std::path::PathBuf {
if let Some(val) = xdg.filter(|v| !v.is_empty()) {
let path = std::path::PathBuf::from(&val);
if is_private_dir(&path) {
return path;
}
tracing::warn!(
path = %path.display(),
"XDG_RUNTIME_DIR is not a private directory, falling back to /tmp"
);
}
std::path::PathBuf::from("/tmp")
}
fn socket_dir() -> std::path::PathBuf {
socket_dir_from(std::env::var_os("XDG_RUNTIME_DIR"))
}
pub fn socket_path(identifier: &str) -> std::path::PathBuf {
socket_dir().join(format!("tauri-hasgard-{identifier}.sock"))
}
pub fn bind(socket_path: &std::path::Path) -> Result<(std::os::unix::net::UnixListener, SocketGuard), Error> {
let old_mask = unsafe { libc::umask(0o177) };
let first_bind = std::os::unix::net::UnixListener::bind(socket_path);
unsafe { libc::umask(old_mask) };
let listener = match first_bind {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
match std::os::unix::net::UnixStream::connect(socket_path) {
Ok(_) => {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
format!("socket already in use: {}", socket_path.display()),
)));
}
Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => {
let _ = std::fs::remove_file(socket_path);
let old_mask = unsafe { libc::umask(0o177) };
let retry_bind = std::os::unix::net::UnixListener::bind(socket_path);
unsafe { libc::umask(old_mask) };
retry_bind?
}
Err(e) => {
return Err(Error::Io(e));
}
}
}
Err(e) => return Err(Error::Io(e)),
};
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
listener.set_nonblocking(true)?;
tracing::info!(version = env!("CARGO_PKG_VERSION"), path = %socket_path.display(), "tauri-hasgard socket listening");
let inode = inode_from_raw_fd(listener.as_raw_fd());
Ok((listener, SocketGuard { path: socket_path.to_path_buf(), inode }))
}
pub async fn run(
listener: std::os::unix::net::UnixListener, _guard: SocketGuard, engine: EvalEngine, eval_fn: Option<EvalFn>,
list_fn: Option<ListWindowsFn>, press_hooks: Option<PressHooksRef>, recorder: Recorder,
) {
let listener = match UnixListener::from_std(listener) {
Ok(l) => l,
Err(e) => {
tracing::error!("failed to convert listener to tokio: {e}");
return;
}
};
if let Err(e) = accept_loop(listener, engine, eval_fn, list_fn, press_hooks, recorder).await {
tracing::error!("socket server error: {e}");
}
}
async fn accept_loop(
listener: UnixListener, engine: EvalEngine, eval_fn: Option<EvalFn>, list_fn: Option<ListWindowsFn>,
press_hooks: Option<PressHooksRef>, recorder: Recorder,
) -> Result<(), Error> {
let ctx = Arc::new((engine, eval_fn, list_fn, press_hooks, recorder));
loop {
let (stream, _addr) = match listener.accept().await {
Ok(conn) => conn,
Err(e) => {
tracing::warn!("accept error: {e}");
continue;
}
};
match stream.peer_cred() {
Ok(cred) => {
let my_uid = unsafe { libc::getuid() };
if cred.uid() != my_uid {
tracing::warn!(
peer_uid = cred.uid(),
expected_uid = my_uid,
"rejected connection from different user"
);
continue;
}
}
Err(e) => {
tracing::warn!("failed to get peer credentials: {e}");
continue;
}
}
let ctx = Arc::clone(&ctx);
tokio::spawn(async move {
if let Err(e) =
handle_connection(stream, &ctx.0, ctx.1.as_ref(), ctx.2.as_ref(), ctx.3.as_ref(), &ctx.4).await
{
tracing::warn!("connection error: {e}");
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::Response;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
fn unique_socket_path() -> PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
PathBuf::from(format!("/tmp/tauri-hasgard-test-{}-{n}.sock", std::process::id()))
}
async fn start_test_server(path: &Path) -> tokio::task::JoinHandle<()> {
let (listener, guard) = bind(path).expect("bind test socket");
let engine = EvalEngine::new();
let handle = tokio::spawn(async move {
run(listener, guard, engine, None, None, None, Recorder::new()).await;
});
tokio::time::sleep(Duration::from_millis(50)).await;
handle
}
#[tokio::test]
async fn test_server_responds_ping_ok() {
let socket = unique_socket_path();
let handle = start_test_server(&socket).await;
let stream = UnixStream::connect(&socket).await.expect("connect test socket");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
writer.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n").await.expect("write ping request");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read response");
let resp: Response = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp.id, serde_json::json!(1));
assert!(resp.error.is_none());
let result = resp.result.expect("ping returns a result");
assert_eq!(result["status"], serde_json::json!("ok"));
assert_eq!(result["plugin_version"], serde_json::json!(env!("CARGO_PKG_VERSION")));
handle.abort();
let _ = std::fs::remove_file(&socket);
}
#[tokio::test]
async fn test_server_handles_invalid_json() {
let socket = unique_socket_path();
let handle = start_test_server(&socket).await;
let stream = UnixStream::connect(&socket).await.expect("connect test socket");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
writer.write_all(b"not json\n").await.expect("write invalid request");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read response");
let resp: Response = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp.id, serde_json::Value::Null);
let err = resp.error.expect("error payload present");
assert_eq!(err.code, -32700);
handle.abort();
let _ = std::fs::remove_file(&socket);
}
#[tokio::test]
async fn test_server_handles_multiple_requests() {
let socket = unique_socket_path();
let handle = start_test_server(&socket).await;
let stream = UnixStream::connect(&socket).await.expect("connect test socket");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
for i in 1..=3 {
let req = format!("{{\"jsonrpc\":\"2.0\",\"id\":{i},\"method\":\"test\"}}\n");
writer.write_all(req.as_bytes()).await.expect("write request");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read response");
let resp: Response = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp.id, serde_json::json!(i));
}
handle.abort();
let _ = std::fs::remove_file(&socket);
}
#[test]
fn test_socket_dir_from_returns_xdg_runtime_dir_when_set_and_private() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("tauri-hasgard-xdg-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).expect("set dir permissions");
let result = socket_dir_from(Some(dir.as_os_str().to_owned()));
let _ = std::fs::remove_dir(&dir);
assert_eq!(result, dir);
}
#[test]
fn test_socket_dir_from_falls_back_to_tmp_when_none() {
let result = socket_dir_from(None);
assert_eq!(result, std::path::PathBuf::from("/tmp"));
}
#[test]
fn test_socket_dir_from_falls_back_to_tmp_when_empty() {
let result = socket_dir_from(Some(std::ffi::OsString::new()));
assert_eq!(result, std::path::PathBuf::from("/tmp"));
}
#[tokio::test]
async fn test_bind_socket_has_mode_0o600() {
use std::os::unix::fs::PermissionsExt;
let socket = unique_socket_path();
let (listener, guard) = bind(&socket).expect("bind test socket");
let meta = std::fs::metadata(&socket).expect("socket metadata");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "socket must be owner-only (0o600), got {mode:#o}");
drop(listener);
drop(guard);
}
}