use std::io::{self, BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
use rich_ext::local_socket::{self, LocalListener, LocalStream};
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())
}
#[cfg(unix)]
pub fn current_uid() -> u32 {
local_socket::current_uid()
}
pub fn private_dir(path: &Path) -> io::Result<()> {
local_socket::private_dir(path)
}
pub fn check_private_dir(path: &Path) -> io::Result<()> {
local_socket::check_private_dir(path)
}
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 dir = match runtime.filter(|d| local_socket::is_own_dir(d)) {
Some(base) => base.join(app),
None => std::env::temp_dir().join(format!("{app}-{}", local_socket::current_user())),
};
private_dir(&dir)?;
Ok(dir)
}
pub fn check_socket(path: &Path) -> io::Result<()> {
local_socket::check_endpoint(path)?;
check_private_dir(path.parent().unwrap_or(Path::new("/")))
}
#[cfg(unix)]
pub fn peer_uid(stream: &std::os::unix::net::UnixStream) -> io::Result<u32> {
local_socket::peer_uid(stream)
}
pub fn peer_allowed(peer: io::Result<u32>, me: u32) -> io::Result<()> {
match peer {
Ok(uid) if uid == me => Ok(()),
Ok(uid) => Err(io::Error::new(
io::ErrorKind::PermissionDenied,
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: LocalListener,
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)?;
let listener = LocalListener::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(
stream: LocalStream,
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))?;
stream.peer_allowed()?;
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 _ = LocalStream::connect(&self.path);
}
self.remove();
}
fn remove(&self) {
local_socket::remove_endpoint(&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 = LocalStream::connect(path)?;
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 local_socket::is_endpoint(&path) {
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());
}
}