use std::os::unix::fs::DirBuilderExt;
pub fn socket_dir() -> anyhow::Result<std::path::PathBuf> {
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.ok()
.map(std::path::PathBuf::from);
socket_dir_impl(runtime_dir.as_deref())
}
fn socket_dir_impl(runtime_dir: Option<&std::path::Path>) -> anyhow::Result<std::path::PathBuf> {
let uid = nix::unistd::getuid();
let dir = match runtime_dir {
Some(xdg) if xdg_parent_is_trustworthy(xdg, uid) => xdg.join("retach"),
_ => std::path::PathBuf::from(format!("/tmp/retach-{}", uid)),
};
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let meta = std::fs::symlink_metadata(&dir)
.map_err(|e| anyhow::anyhow!("cannot stat socket directory {dir:?}: {e}"))?;
if meta.file_type().is_symlink() {
anyhow::bail!(
"socket directory {dir:?} is a symlink — possible symlink attack, refusing to start"
);
}
use std::os::unix::fs::MetadataExt;
if meta.uid() != uid.as_raw() {
anyhow::bail!(
"socket directory {dir:?} owned by uid {} (expected {}) — possible attack",
meta.uid(),
uid.as_raw(),
);
}
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o777 != 0o700 {
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).map_err(
|e| anyhow::anyhow!("failed to fix socket directory permissions {dir:?}: {e}"),
)?;
}
}
Err(e) => {
return Err(anyhow::anyhow!(
"failed to create socket directory {dir:?}: {e}"
));
}
}
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&dir)
.map_err(|e| anyhow::anyhow!("cannot stat socket directory {dir:?}: {e}"))?
.permissions()
.mode()
& 0o777;
if mode != 0o700 {
anyhow::bail!(
"socket directory {dir:?} has mode {mode:#o} (expected 0o700) — refusing to start"
);
}
Ok(dir)
}
fn xdg_parent_is_trustworthy(xdg: &std::path::Path, uid: nix::unistd::Uid) -> bool {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let meta = match std::fs::symlink_metadata(xdg) {
Ok(m) => m,
Err(_) => return false,
};
meta.is_dir()
&& !meta.file_type().is_symlink()
&& meta.uid() == uid.as_raw()
&& meta.permissions().mode() & 0o777 == 0o700
}
pub fn socket_path() -> anyhow::Result<std::path::PathBuf> {
Ok(socket_dir()?.join("retach.sock"))
}
pub fn lock_path() -> anyhow::Result<std::path::PathBuf> {
Ok(socket_dir()?.join("retach.lock"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_dir_returns_correct_format() {
let uid = nix::unistd::getuid();
let dir = socket_dir().unwrap();
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
let expected = std::path::PathBuf::from(xdg).join("retach");
assert_eq!(dir, expected);
} else {
let expected = std::path::PathBuf::from(format!("/tmp/retach-{}", uid));
assert_eq!(dir, expected);
}
}
#[test]
fn socket_path_ends_with_sock() {
let path = socket_path().unwrap();
assert!(
path.ends_with("retach.sock"),
"socket_path should end with 'retach.sock', got: {:?}",
path
);
}
#[test]
fn socket_dir_creates_directory() {
let dir = socket_dir().unwrap();
assert!(
dir.exists(),
"socket_dir() should create the directory at {:?}",
dir
);
assert!(
dir.is_dir(),
"socket_dir() path should be a directory, not a file"
);
}
#[test]
fn socket_dir_has_correct_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = socket_dir().unwrap();
let meta = std::fs::metadata(&dir).expect("should be able to stat socket directory");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"socket directory should have mode 0o700, got: {:#o}",
mode
);
}
#[test]
fn socket_dir_idempotent() {
let first = socket_dir().unwrap();
let second = socket_dir().unwrap();
assert_eq!(
first, second,
"calling socket_dir() twice should return the same path"
);
assert!(
second.exists(),
"directory should still exist after second call"
);
}
#[test]
fn socket_dir_rejects_symlink() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let real_dir = tmp.path().join("real");
let sym_dir = tmp.path().join("retach");
std::fs::create_dir(&real_dir).unwrap();
std::os::unix::fs::symlink(&real_dir, &sym_dir).unwrap();
let result = socket_dir_impl(Some(tmp.path()));
assert!(result.is_err(), "should reject symlink socket directory");
let err = result.unwrap_err().to_string();
assert!(
err.contains("symlink"),
"error should mention symlink: {}",
err
);
}
#[test]
fn socket_dir_repairs_wrong_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let dir = tmp.path().join("retach");
std::fs::DirBuilder::new().mode(0o755).create(&dir).unwrap();
let result = socket_dir_impl(Some(tmp.path()));
assert!(result.is_ok(), "should succeed and repair permissions");
let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"permissions should be repaired to 0o700, got: {:#o}",
mode
);
}
#[test]
fn socket_dir_fails_when_repair_cannot_set_mode() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let dir = tmp.path().join("retach");
std::fs::DirBuilder::new().mode(0o755).create(&dir).unwrap();
let set_immutable = |on: bool| {
#[cfg(target_os = "macos")]
let ok = std::process::Command::new("chflags")
.arg(if on { "uchg" } else { "nouchg" })
.arg(&dir)
.status()
.map(|s| s.success())
.unwrap_or(false);
#[cfg(target_os = "linux")]
let ok = std::process::Command::new("chattr")
.arg(if on { "+i" } else { "-i" })
.arg(&dir)
.status()
.map(|s| s.success())
.unwrap_or(false);
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let ok = false;
ok
};
if !set_immutable(true) {
eprintln!("skipping: could not set immutable flag");
return;
}
let result = socket_dir_impl(Some(tmp.path()));
set_immutable(false);
assert!(
result.is_err(),
"failed permission repair must be fatal, got: {:?}",
result
);
}
#[test]
fn socket_dir_falls_back_when_xdg_parent_world_writable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
let uid = nix::unistd::getuid();
let result = socket_dir_impl(Some(tmp.path())).unwrap();
let _ = std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700));
assert_eq!(
result,
std::path::PathBuf::from(format!("/tmp/retach-{}", uid)),
"untrusted XDG parent should fall back to /tmp path"
);
}
#[test]
fn socket_dir_uses_xdg_when_parent_trustworthy() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let result = socket_dir_impl(Some(tmp.path())).unwrap();
assert_eq!(
result,
tmp.path().join("retach"),
"trustworthy XDG parent should be used"
);
}
#[test]
fn lock_path_format() {
let path = lock_path().unwrap();
assert!(
path.ends_with("retach.lock"),
"lock_path should end with 'retach.lock', got: {:?}",
path
);
}
}