use anyhow::{Context, Result};
use std::path::PathBuf;
fn home() -> Result<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.context("HOME is not set; qex cannot locate its config or state directory")
}
fn xdg(var: &str, default_suffix: &str) -> Result<PathBuf> {
match std::env::var_os(var) {
Some(v) if !v.is_empty() => Ok(PathBuf::from(v)),
_ => Ok(home()?.join(default_suffix)),
}
}
pub fn config_file() -> Result<PathBuf> {
Ok(xdg("XDG_CONFIG_HOME", ".config")?.join("qex.toml"))
}
pub fn state_dir() -> Result<PathBuf> {
Ok(xdg("XDG_STATE_HOME", ".local/state")?.join("qex"))
}
pub fn jobs_dir() -> Result<PathBuf> {
Ok(state_dir()?.join("jobs"))
}
pub fn runtime_dir() -> Result<PathBuf> {
Ok(state_dir()?.join("run"))
}
const MAX_SOCKET_PATH: usize = 100;
pub fn socket_path() -> Result<PathBuf> {
let preferred = runtime_dir()?.join("s");
if preferred.as_os_str().len() <= MAX_SOCKET_PATH {
return Ok(preferred);
}
Ok(short_socket_dir(&preferred)?.join("s"))
}
fn short_socket_dir(preferred: &std::path::Path) -> Result<PathBuf> {
use std::os::unix::fs::MetadataExt;
let uid = unsafe { libc::getuid() };
let dir = std::env::temp_dir().join(format!("qex-{uid}-{}", path_hash(preferred)));
match std::fs::symlink_metadata(&dir) {
Ok(meta) => {
if !meta.is_dir() {
anyhow::bail!(
"qex needs the directory {} for its socket, but that path is a file",
dir.display()
);
}
if meta.uid() != uid {
anyhow::bail!(
"the directory {} belongs to the user {}, and qex will not use it",
dir.display(),
meta.uid()
);
}
ensure_dir(&dir, 0o700)?;
}
Err(_) => ensure_dir(&dir, 0o700)?,
}
Ok(dir)
}
pub fn reap_stale_socket_dirs() {
use std::os::unix::fs::MetadataExt;
let uid = unsafe { libc::getuid() };
let prefix = format!("qex-{uid}-");
let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(&prefix) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(entry.path()) else {
continue;
};
if !meta.is_dir() || meta.uid() != uid {
continue;
}
let socket = entry.path().join("s");
if socket.exists() && std::os::unix::net::UnixStream::connect(&socket).is_ok() {
continue;
}
std::fs::remove_dir_all(entry.path()).ok();
}
}
fn path_hash(path: &std::path::Path) -> String {
use std::os::unix::ffi::OsStrExt;
let mut hash: u64 = 0xcbf29ce484222325;
for byte in path.as_os_str().as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:08x}")
}
pub fn program_path() -> Result<PathBuf> {
let exe = std::env::current_exe().context("finding the qex program file")?;
if exe.exists() {
return Ok(exe);
}
let text = exe.to_string_lossy();
if let Some(stripped) = text.strip_suffix(" (deleted)") {
let path = PathBuf::from(stripped);
if path.exists() {
return Ok(path);
}
anyhow::bail!(
"the qex program file {} no longer exists. Something replaced or deleted it \
while the coordinator was operating. Stop the coordinator and start it again: \
`qex info --no-start --json` gives its process id.",
stripped
);
}
anyhow::bail!(
"the qex program file {} no longer exists. Stop the coordinator and start it again.",
exe.display()
)
}
pub fn program_file_changed() -> bool {
match std::env::current_exe() {
Ok(exe) => !exe.exists(),
Err(_) => false,
}
}
pub fn spawn_lock_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("spawn.lock"))
}
pub fn daemon_log_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("daemon.log"))
}
pub fn job_dir(id: &uuid::Uuid) -> Result<PathBuf> {
Ok(jobs_dir()?.join(id.to_string()))
}
pub fn ensure_dir(path: &std::path::Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if !path.exists() {
std::fs::create_dir_all(path)
.with_context(|| format!("creating directory {}", path.display()))?;
}
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("setting mode {mode:o} on {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{env_lock, EnvVar};
#[test]
fn xdg_overrides_are_honoured() {
let _guard = env_lock();
let _c = EnvVar::set("XDG_CONFIG_HOME", "/tmp/qex-test-cfg");
let _s = EnvVar::set("XDG_STATE_HOME", "/tmp/qex-test-state");
assert_eq!(
config_file().unwrap(),
PathBuf::from("/tmp/qex-test-cfg/qex.toml")
);
assert_eq!(
jobs_dir().unwrap(),
PathBuf::from("/tmp/qex-test-state/qex/jobs")
);
}
#[test]
fn defaults_fall_back_to_home() {
let _guard = env_lock();
let _h = EnvVar::set("HOME", "/home/example");
let _c = EnvVar::unset("XDG_CONFIG_HOME");
let _s = EnvVar::unset("XDG_STATE_HOME");
let _r = EnvVar::unset("XDG_RUNTIME_DIR");
assert_eq!(
config_file().unwrap(),
PathBuf::from("/home/example/.config/qex.toml")
);
assert_eq!(
state_dir().unwrap(),
PathBuf::from("/home/example/.local/state/qex")
);
assert_eq!(
runtime_dir().unwrap(),
PathBuf::from("/home/example/.local/state/qex/run")
);
}
#[test]
fn socket_path_stays_within_sun_path_limits() {
let _guard = env_lock();
let _s = EnvVar::set("XDG_STATE_HOME", "/tmp/qex-sock-test");
let p = socket_path().unwrap();
assert_eq!(p, PathBuf::from("/tmp/qex-sock-test/qex/run/s"));
assert!(p.as_os_str().len() <= MAX_SOCKET_PATH);
}
#[test]
fn the_socket_does_not_depend_on_the_runtime_variable() {
let _guard = env_lock();
let _s = EnvVar::set("XDG_STATE_HOME", "/tmp/qex-one-state");
let with_variable = {
let _r = EnvVar::set("XDG_RUNTIME_DIR", "/run/user/1000");
socket_path().unwrap()
};
let without_variable = {
let _r = EnvVar::unset("XDG_RUNTIME_DIR");
socket_path().unwrap()
};
assert_eq!(
with_variable, without_variable,
"one state directory must give one socket, and thus one coordinator"
);
}
#[test]
fn a_long_state_directory_gives_a_short_socket_path() {
let _guard = env_lock();
let long = format!("/tmp/{}", "very-long-directory-name/".repeat(8));
let _s = EnvVar::set("XDG_STATE_HOME", &long);
let p = socket_path().unwrap();
assert!(
p.as_os_str().len() <= MAX_SOCKET_PATH,
"the socket path {} is still too long",
p.display()
);
assert_eq!(p, socket_path().unwrap());
let _s2 = EnvVar::set("XDG_STATE_HOME", &format!("{long}other/"));
assert_ne!(p, socket_path().unwrap());
std::fs::remove_dir_all(p.parent().unwrap()).ok();
}
#[test]
fn ensure_dir_applies_mode_regardless_of_umask() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("qex-mode-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
ensure_dir(&dir, 0o700).unwrap();
let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"the group and other users must not read job directories"
);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
ensure_dir(&dir, 0o700).unwrap();
let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
std::fs::remove_dir_all(&dir).ok();
}
}