use std::io::{self, BufRead, BufReader, Read, Write};
use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
use serde_json::{Value, json};
pub const MAX_REQUEST: u64 = 64 * 1024;
pub const MAX_REPLY: u64 = 16 << 20;
pub const IO_TIMEOUT: Duration = Duration::from_secs(5);
pub const MAX_CONNECTIONS: usize = 8;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Verb {
pub name: &'static str,
pub mutates: bool,
pub about: &'static str,
}
impl Verb {
pub const fn read(name: &'static str, about: &'static str) -> Verb {
Verb {
name,
mutates: false,
about,
}
}
pub const fn write(name: &'static str, about: &'static str) -> Verb {
Verb {
name,
mutates: true,
about,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Request {
pub verb: String,
pub args: Value,
}
impl Request {
pub fn new(verb: impl Into<String>, args: Value) -> Request {
Request {
verb: verb.into(),
args,
}
}
pub fn to_line(&self) -> String {
let mut line = json!({"verb": self.verb, "args": self.args}).to_string();
line.push('\n');
line
}
pub fn parse(line: &str) -> io::Result<Request> {
let value: Value = serde_json::from_str(line.trim()).map_err(|e| invalid(e.to_string()))?;
let verb = value
.get("verb")
.and_then(Value::as_str)
.ok_or_else(|| invalid("a request needs a string \"verb\""))?;
let args = value.get("args").cloned().unwrap_or(Value::Null);
if !(args.is_object() || args.is_null()) {
return Err(invalid("\"args\" must be an object"));
}
Ok(Request::new(verb, args))
}
}
pub type Reply = Result<Value, String>;
pub fn reply_line(reply: &Reply) -> String {
let mut line = match reply {
Ok(result) => json!({"ok": true, "result": result}),
Err(error) => json!({"ok": false, "error": error}),
}
.to_string();
line.push('\n');
line
}
pub fn parse_reply(line: &str) -> io::Result<Reply> {
let value: Value = serde_json::from_str(line.trim()).map_err(|e| invalid(e.to_string()))?;
match value.get("ok").and_then(Value::as_bool) {
Some(true) => Ok(Ok(value.get("result").cloned().unwrap_or(Value::Null))),
Some(false) => Ok(Err(value
.get("error")
.and_then(Value::as_str)
.unwrap_or("refused")
.to_string())),
None => Err(invalid("a reply needs \"ok\"")),
}
}
fn invalid(why: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, why.into())
}
fn refused(why: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::PermissionDenied, why.into())
}
#[allow(unsafe_code)] pub fn current_uid() -> u32 {
unsafe { libc::geteuid() }
}
pub fn private_dir(path: &Path) -> io::Result<()> {
match std::fs::DirBuilder::new().mode(0o700).create(path) {
Ok(()) => check_private_dir(path),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => check_private_dir(path),
Err(e) => Err(e),
}
}
pub fn check_private_dir(path: &Path) -> io::Result<()> {
let meta = std::fs::symlink_metadata(path)?;
let shown = path.display();
if meta.file_type().is_symlink() {
return Err(refused(format!("{shown} is a symbolic link")));
}
if !meta.is_dir() {
return Err(refused(format!("{shown} is not a directory")));
}
if meta.uid() != current_uid() {
return Err(refused(format!("{shown} is another user's")));
}
if meta.mode() & 0o077 != 0 {
return Err(refused(format!(
"{shown} is open to other users (mode {:o}; it must be 700)",
meta.mode() & 0o777
)));
}
Ok(())
}
pub fn runtime_dir(app: &str) -> io::Result<PathBuf> {
runtime_dir_in(app, std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from))
}
pub fn runtime_dir_in(app: &str, runtime: Option<PathBuf>) -> io::Result<PathBuf> {
if app.is_empty()
|| !app
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
{
return Err(invalid(format!("{app:?} is not an app name")));
}
let usable = |dir: &Path| {
dir.is_absolute()
&& std::fs::symlink_metadata(dir)
.is_ok_and(|m| m.is_dir() && m.uid() == current_uid() && m.mode() & 0o022 == 0)
};
let dir = match runtime.filter(|d| usable(d)) {
Some(base) => base.join(app),
None => std::env::temp_dir().join(format!("{app}-{}", current_uid())),
};
private_dir(&dir)?;
Ok(dir)
}
pub fn check_socket(path: &Path) -> io::Result<()> {
let meta = std::fs::symlink_metadata(path)?;
let shown = path.display();
if meta.file_type().is_symlink() {
return Err(refused(format!("{shown} is a symbolic link")));
}
if !meta.file_type().is_socket() {
return Err(refused(format!("{shown} is not a socket")));
}
if meta.uid() != current_uid() {
return Err(refused(format!("{shown} is another user's socket")));
}
check_private_dir(path.parent().unwrap_or(Path::new("/")))
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[allow(unsafe_code)] pub fn peer_uid(stream: &UnixStream) -> io::Result<u32> {
use std::os::fd::AsRawFd;
let mut cred = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let done = unsafe {
libc::getsockopt(
stream.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
(&mut cred as *mut libc::ucred).cast(),
&mut len,
)
};
if done != 0 {
return Err(io::Error::last_os_error());
}
Ok(cred.uid)
}
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
))]
#[allow(unsafe_code)] pub fn peer_uid(stream: &UnixStream) -> io::Result<u32> {
use std::os::fd::AsRawFd;
let (mut uid, mut gid) = (0, 0);
if unsafe { libc::getpeereid(stream.as_raw_fd(), &mut uid, &mut gid) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(uid)
}
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
)))]
pub fn peer_uid(_stream: &UnixStream) -> io::Result<u32> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"this platform does not tell a socket's peer",
))
}
pub fn peer_allowed(peer: io::Result<u32>, me: u32) -> io::Result<()> {
match peer {
Ok(uid) if uid == me => Ok(()),
Ok(uid) => Err(refused(format!("the peer is another user ({uid})"))),
Err(e) if e.kind() == io::ErrorKind::Unsupported => Ok(()),
Err(e) => Err(e),
}
}
fn read_line(stream: impl Read, limit: u64) -> io::Result<String> {
let mut bytes = Vec::new();
BufReader::new(stream.take(limit + 1)).read_until(b'\n', &mut bytes)?;
if bytes.len() as u64 > limit {
return Err(invalid(format!("longer than {limit} bytes")));
}
String::from_utf8(bytes).map_err(|_| invalid("not UTF-8"))
}
#[derive(Debug)]
pub struct ControlServer {
listener: UnixListener,
path: PathBuf,
verbs: Vec<Verb>,
read_only: bool,
timeout: Duration,
closed: Arc<AtomicBool>,
}
impl ControlServer {
pub fn bind(path: impl AsRef<Path>, verbs: &[Verb]) -> io::Result<ControlServer> {
let path = path.as_ref().to_path_buf();
let dir = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or_else(|| invalid("a socket path needs a directory"))?;
check_private_dir(dir)?;
match std::fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(refused(format!("{} is a symbolic link", path.display())));
}
Ok(meta)
if meta.file_type().is_socket()
&& meta.uid() == current_uid()
&& UnixStream::connect(&path).is_err() =>
{
std::fs::remove_file(&path)?;
}
Ok(_) => {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("{} is in use", path.display()),
));
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let listener = UnixListener::bind(&path)?;
Ok(ControlServer {
listener,
path,
verbs: verbs.to_vec(),
read_only: false,
timeout: IO_TIMEOUT,
closed: Arc::new(AtomicBool::new(false)),
})
}
#[must_use]
pub fn read_only(mut self, on: bool) -> ControlServer {
self.read_only = on;
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> ControlServer {
self.timeout = timeout;
self
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn is_read_only(&self) -> bool {
self.read_only
}
pub fn verbs(&self) -> &[Verb] {
&self.verbs
}
pub fn closer(&self) -> Closer {
Closer {
path: self.path.clone(),
closed: self.closed.clone(),
}
}
pub fn serve(self, handler: impl Fn(&Request) -> Reply + Send + Sync + 'static) {
let handler = Arc::new(handler);
let active = Arc::new(AtomicUsize::new(0));
let verbs: Arc<[Verb]> = self.verbs.clone().into();
for stream in self.listener.incoming() {
if self.closed.load(Ordering::SeqCst) {
break;
}
let Ok(stream) = stream else { continue };
if active.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
active.fetch_sub(1, Ordering::SeqCst);
continue;
}
let (handler, active, verbs) = (handler.clone(), active.clone(), verbs.clone());
let (read_only, timeout) = (self.read_only, self.timeout);
std::thread::spawn(move || {
let _ = answer(stream, &verbs, read_only, timeout, &*handler);
active.fetch_sub(1, Ordering::SeqCst);
});
}
}
}
impl Drop for ControlServer {
fn drop(&mut self) {
self.closer().remove();
}
}
fn answer(
mut stream: UnixStream,
verbs: &[Verb],
read_only: bool,
timeout: Duration,
handler: &(dyn Fn(&Request) -> Reply + Send + Sync),
) -> io::Result<()> {
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
peer_allowed(peer_uid(&stream), current_uid())?;
let reply = match read_line(&stream, MAX_REQUEST).and_then(|line| Request::parse(&line)) {
Err(e) => Err(format!("bad request: {e}")),
Ok(request) => match verbs.iter().find(|v| v.name == request.verb) {
None => Err(format!("unknown verb {:?}", request.verb)),
Some(verb) if verb.mutates && read_only => Err(format!(
"read-only session: {:?} would change the page",
verb.name
)),
Some(_) => handler(&request),
},
};
let mut line = reply_line(&reply);
if line.len() as u64 > MAX_REPLY {
line = reply_line(&Err(format!("the reply is longer than {MAX_REPLY} bytes")));
}
stream.write_all(line.as_bytes())?;
stream.flush()
}
#[derive(Clone, Debug)]
pub struct Closer {
path: PathBuf,
closed: Arc<AtomicBool>,
}
impl Closer {
pub fn close(&self) {
if !self.closed.swap(true, Ordering::SeqCst) {
let _ = UnixStream::connect(&self.path);
}
self.remove();
}
fn remove(&self) {
if std::fs::symlink_metadata(&self.path).is_ok_and(|m| m.file_type().is_socket()) {
let _ = std::fs::remove_file(&self.path);
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
pub fn call(path: &Path, request: &Request, timeout: Duration) -> io::Result<Reply> {
check_socket(path)?;
let mut stream = UnixStream::connect(path)?;
peer_allowed(peer_uid(&stream), current_uid())?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let line = request.to_line();
if line.len() as u64 > MAX_REQUEST {
return Err(invalid(format!(
"the request is longer than {MAX_REQUEST} bytes"
)));
}
stream.write_all(line.as_bytes())?;
stream.flush()?;
parse_reply(&read_line(&stream, MAX_REPLY)?)
}
pub fn sessions(dir: &Path, prefix: &str) -> io::Result<Vec<PathBuf>> {
check_private_dir(dir)?;
let mut found = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(prefix) || !name.ends_with(".sock") {
continue;
}
let path = entry.path();
if std::fs::symlink_metadata(&path)
.is_ok_and(|m| m.file_type().is_socket() && m.uid() == current_uid())
{
found.push(path);
}
}
found.sort();
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_and_replies_round_trip() {
let request = Request::new("click", json!({"target": "12"}));
assert_eq!(Request::parse(&request.to_line()).unwrap(), request);
assert!(Request::parse("{\"verb\": 3}").is_err());
assert!(Request::parse("{\"verb\": \"x\", \"args\": [1]}").is_err());
assert_eq!(
parse_reply(&reply_line(&Ok(json!(1)))).unwrap(),
Ok(json!(1))
);
assert_eq!(
parse_reply(&reply_line(&Err("no".into()))).unwrap(),
Err("no".into())
);
}
#[test]
fn a_peer_must_be_this_user_where_the_platform_tells() {
assert!(peer_allowed(Ok(1000), 1000).is_ok());
let other = peer_allowed(Ok(1001), 1000).unwrap_err();
assert_eq!(other.kind(), io::ErrorKind::PermissionDenied);
let untold = io::Error::new(io::ErrorKind::Unsupported, "no");
assert!(peer_allowed(Err(untold), 1000).is_ok());
assert!(peer_allowed(Err(io::Error::other("broken")), 1000).is_err());
}
#[test]
fn a_line_is_bounded() {
assert_eq!(read_line(&b"abc\nrest"[..], 10).unwrap(), "abc\n");
assert!(read_line(&[b'x'; 20][..], 10).is_err());
}
}