use crate::paths;
use crate::proto::{Request, Response};
use anyhow::{bail, Context, Result};
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::{Duration, Instant};
const SPAWN_TIMEOUT: Duration = Duration::from_secs(10);
pub struct Client {
stream: UnixStream,
reader: BufReader<UnixStream>,
}
impl Client {
pub fn connect() -> Result<Self> {
Self::connect_or_explain().map_err(name_the_sandbox)
}
fn connect_or_explain() -> Result<Self> {
let socket = paths::socket_path()?;
if let Some(stream) = try_connect(&socket) {
return Client::with_stream(stream);
}
let _lock = SpawnLock::acquire()?;
if let Some(stream) = try_connect(&socket) {
return Client::with_stream(stream);
}
spawn_daemon()?;
let stream = wait_for_socket(&socket, SPAWN_TIMEOUT)?;
Client::with_stream(stream)
}
pub fn connect_existing() -> Option<Self> {
let socket = paths::socket_path().ok()?;
let stream = try_connect(&socket)?;
Client::with_stream(stream).ok()
}
fn with_stream(stream: UnixStream) -> Result<Self> {
let reader = BufReader::new(stream.try_clone().context("copying the socket handle")?);
Ok(Self { stream, reader })
}
pub fn call(&mut self, request: &Request) -> Result<Response> {
self.send(request)?;
self.recv()
}
pub fn send(&mut self, request: &Request) -> Result<()> {
let mut line = serde_json::to_string(request).context("writing the request")?;
line.push('\n');
self.stream
.write_all(line.as_bytes())
.context("sending the request to the coordinator")?;
self.stream.flush().ok();
Ok(())
}
pub fn recv(&mut self) -> Result<Response> {
match self.recv_opt()? {
Some(response) => Ok(response),
None => bail!("the coordinator closed the connection without an answer"),
}
}
pub fn recv_opt(&mut self) -> Result<Option<Response>> {
let mut line = String::new();
let n = self
.reader
.read_line(&mut line)
.context("reading the answer of the coordinator")?;
if n == 0 {
return Ok(None);
}
serde_json::from_str(&line)
.map(Some)
.with_context(|| format!("reading this answer of the coordinator: {}", line.trim()))
}
pub fn wait_readable(&self, timeout: Duration) -> std::io::Result<bool> {
use std::os::unix::io::AsRawFd;
let mut fds = libc::pollfd {
fd: self.stream.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let ms = timeout.as_millis().min(i32::MAX as u128) as i32;
let rc = unsafe { libc::poll(&mut fds, 1, ms) };
match rc {
0 => Ok(false),
-1 => {
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::Interrupted {
Ok(false)
} else {
Err(e)
}
}
_ => Ok(true),
}
}
pub fn set_read_timeout(&mut self, timeout: Option<Duration>) -> Result<()> {
self.stream
.set_read_timeout(timeout)
.context("setting the timeout of the socket")
}
}
fn try_connect(socket: &Path) -> Option<UnixStream> {
UnixStream::connect(socket).ok()
}
fn wait_for_socket(socket: &Path, timeout: Duration) -> Result<UnixStream> {
let deadline = Instant::now() + timeout;
let mut delay = Duration::from_millis(2);
let probe_at = Instant::now() + Duration::from_secs(1);
let mut probed = false;
while Instant::now() < deadline {
if let Some(stream) = try_connect(socket) {
return Ok(stream);
}
if !probed && Instant::now() >= probe_at {
probed = true;
if let Some(message) = socket_is_refused() {
bail!("{message}");
}
}
std::thread::sleep(delay);
delay = (delay * 2).min(Duration::from_millis(50));
}
let log = paths::daemon_log_path()?;
if let Some(message) = socket_is_refused() {
bail!("{message}");
}
let tail = last_lines_of(&log, 5);
bail!(
"the coordinator did not start in {} seconds.\n\
Its log file is {}{}",
timeout.as_secs(),
log.display(),
tail
)
}
fn socket_is_refused() -> Option<String> {
let socket = paths::socket_path().ok()?;
socket_refused_in(socket.parent()?)
}
fn socket_refused_in(dir: &Path) -> Option<String> {
classify_refusal(dir, probe_a_socket).map(|refusal| refusal.message(dir))
}
enum Refusal {
Socket(std::io::Error),
Directory(std::io::Error),
}
impl Refusal {
fn message(&self, dir: &Path) -> String {
let page = "IF YOU ARE AN AGENT: tell the person that you work with. They must let qex \
run outside the sandbox, or give the sandbox permission for that \
directory. The page for them is:\n\
\x20 https://github.com/stephenc/qex/blob/main/docs/sandbox.md";
match self {
Refusal::Socket(e) => format!(
"qex cannot make a Unix socket in {}: {e}. An ordinary file in that directory \
works, so this is the socket and not the directory.\n\n\
qex keeps one coordinator process, and every qex command talks to it through \
a Unix socket. A sandbox that refuses that socket stops qex completely, and \
that is the usual cause of this message.\n\n{page}",
dir.display()
),
Refusal::Directory(e) => format!(
"qex cannot write in {}: {e}.\n\n\
qex keeps its records, its log files and the socket of its coordinator in \
that directory. A sandbox that gives it a read-only mount, or no mount at \
all, gives this fault.\n\n{page}",
dir.display()
),
}
}
}
fn classify_refusal(dir: &Path, bind: impl Fn(&Path) -> std::io::Result<()>) -> Option<Refusal> {
let name = format!("probe-{}", std::process::id());
let file = dir.join(format!("{name}.tmp"));
if let Err(e) = std::fs::write(&file, b"qex") {
return a_refusal(e).map(Refusal::Directory);
}
std::fs::remove_file(&file).ok();
let socket = dir.join(name);
std::fs::remove_file(&socket).ok();
let answer = bind(&socket);
std::fs::remove_file(&socket).ok();
answer.err().and_then(a_refusal).map(Refusal::Socket)
}
fn a_refusal(error: std::io::Error) -> Option<std::io::Error> {
let refused = matches!(
error.raw_os_error(),
Some(libc::EACCES)
| Some(libc::EPERM)
| Some(libc::EROFS)
| Some(libc::EAFNOSUPPORT)
| Some(libc::EPROTONOSUPPORT)
| Some(libc::EOPNOTSUPP)
| Some(libc::ENOSYS)
);
let refused = refused
|| (error.raw_os_error().is_none()
&& matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported
));
refused.then_some(error)
}
fn probe_a_socket(path: &Path) -> std::io::Result<()> {
let listener = std::os::unix::net::UnixListener::bind(path)?;
let answer = UnixStream::connect(path).map(|_| ());
drop(listener);
answer
}
fn name_the_sandbox(error: anyhow::Error) -> anyhow::Error {
if format!("{error:#}").contains("docs/sandbox.md") {
return error;
}
if !looks_like_a_sandbox(&error) {
return error;
}
anyhow::anyhow!(
"{error:#}\n\n\
qex could not make the files that it needs. A sandbox that gives qex a read-only \
mount, or no permission for that directory, gives this fault.\n\
IF YOU ARE AN AGENT: tell the person that you work with. The page for them is \
https://github.com/stephenc/qex/blob/main/docs/sandbox.md"
)
}
fn looks_like_a_sandbox(error: &anyhow::Error) -> bool {
for cause in error.chain() {
let Some(io) = cause.downcast_ref::<std::io::Error>() else {
continue;
};
if io.raw_os_error() == Some(libc::EROFS) {
return true;
}
if matches!(
io.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported
) {
return true;
}
}
false
}
fn last_lines_of(path: &Path, count: usize) -> String {
let Ok(text) = std::fs::read_to_string(path) else {
return String::new();
};
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.is_empty() {
return String::new();
}
let tail = lines[lines.len().saturating_sub(count)..].join("\n ");
format!(", and it ends with:\n {tail}")
}
fn spawn_daemon() -> Result<()> {
use std::os::unix::process::CommandExt;
let exe = paths::program_path()?;
let log_path = paths::daemon_log_path()?;
paths::ensure_dir(&paths::runtime_dir()?, 0o700)?;
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("opening the log file {}", log_path.display()))?;
let log_err = log.try_clone().context("copying the log file handle")?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("daemon")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(log_err))
.current_dir("/");
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
cmd.spawn().context("starting the coordinator")?;
Ok(())
}
pub struct SpawnLock {
file: std::fs::File,
}
impl SpawnLock {
pub fn acquire() -> Result<Self> {
let dir = paths::runtime_dir()?;
paths::ensure_dir(&dir, 0o700)?;
let path = paths::spawn_lock_path()?;
let file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening the lock file {}", path.display()))?;
use std::os::unix::io::AsRawFd;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
if rc != 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("locking {}", path.display()));
}
Ok(Self { file })
}
}
impl Drop for SpawnLock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(self.file.as_raw_fd(), libc::LOCK_UN);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn refused(kind: std::io::ErrorKind) -> impl Fn(&Path) -> std::io::Result<()> {
move |_| Err(std::io::Error::new(kind, "refused"))
}
fn writable_dir(name: &str) -> Option<std::path::PathBuf> {
let dir = std::env::temp_dir().join(format!("qx-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok()?;
match std::fs::write(dir.join("control"), b"qex") {
Ok(()) => {
std::fs::remove_file(dir.join("control")).ok();
Some(dir)
}
Err(_) => {
std::fs::remove_dir_all(&dir).ok();
None
}
}
}
#[test]
fn each_refusal_has_its_own_words() {
let socket = Refusal::Socket(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
.message(Path::new("/state/run"));
assert!(socket.contains("Unix socket in /state/run"));
assert!(socket.contains("docs/sandbox.md"));
let directory =
Refusal::Directory(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
.message(Path::new("/state/run"));
assert!(directory.contains("cannot write in /state/run"));
assert!(
!directory.contains("Unix socket in"),
"the message of a directory must not blame the socket: {directory}"
);
assert!(directory.contains("docs/sandbox.md"));
}
#[test]
fn a_socket_that_is_refused_names_the_cause_and_the_page() {
let Some(dir) = writable_dir("sock") else {
return;
};
let answer = classify_refusal(&dir, refused(std::io::ErrorKind::PermissionDenied));
std::fs::remove_dir_all(&dir).ok();
let message = answer
.expect("a socket that is refused must give a message")
.message(Path::new("/state/run"));
assert!(message.contains("Unix socket"), "got: {message}");
assert!(message.contains("docs/sandbox.md"), "got: {message}");
}
#[test]
fn a_socket_that_works_is_not_a_fault() {
let Some(dir) = writable_dir("ok") else {
return;
};
let answer = classify_refusal(&dir, |_| Ok(()));
std::fs::remove_dir_all(&dir).ok();
assert!(answer.is_none(), "a socket that works is not a fault");
}
#[test]
fn a_disk_that_filled_is_not_a_sandbox() {
let full = std::io::Error::from_raw_os_error(libc::ENOSPC);
assert!(a_refusal(full).is_none());
let too_many_files = std::io::Error::from_raw_os_error(libc::EMFILE);
assert!(a_refusal(too_many_files).is_none());
for code in [libc::EACCES, libc::EPERM, libc::EROFS, libc::EAFNOSUPPORT] {
assert!(
a_refusal(std::io::Error::from_raw_os_error(code)).is_some(),
"the code {code} must count as a refusal"
);
}
}
#[test]
fn a_directory_that_qex_cannot_write_is_not_the_socket() {
let Some(parent) = writable_dir("deny") else {
return;
};
let dir = parent.join("locked");
std::fs::create_dir_all(&dir).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let answer = classify_refusal(&dir, |_| Ok(()));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).ok();
std::fs::remove_dir_all(&parent).ok();
if unsafe { libc::geteuid() } == 0 {
return;
}
let message = answer
.expect("a directory that qex cannot write must give a message")
.message(&dir);
assert!(message.contains("cannot write"), "got: {message}");
assert!(!message.contains("Unix socket in"), "got: {message}");
}
#[test]
fn an_ordinary_error_does_not_name_a_sandbox() {
let error = anyhow::anyhow!(std::io::Error::from(std::io::ErrorKind::NotFound));
assert!(!format!("{:#}", name_the_sandbox(error)).contains("docs/sandbox.md"));
let refused = anyhow::anyhow!(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
assert!(format!("{:#}", name_the_sandbox(refused)).contains("docs/sandbox.md"));
}
}