use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
pub const MAX_SOCKET_PATH_LEN: usize = 104;
pub fn runtime_dir() -> Result<PathBuf> {
let base = dirs::data_dir().context("could not determine the user data directory")?;
Ok(base.join("omni-dev"))
}
pub fn socket_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("daemon.sock"))
}
pub fn token_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("bridge.token"))
}
pub fn worktrees_polling_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("worktrees-polling.json"))
}
pub fn worktrees_pr_cache_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("worktrees-pr-cache.json"))
}
pub fn token_path_for_socket(socket: &Path) -> PathBuf {
socket.parent().map_or_else(
|| PathBuf::from("bridge.token"),
|dir| dir.join("bridge.token"),
)
}
pub fn log_path_for_socket(socket: &Path) -> PathBuf {
socket
.parent()
.map_or_else(|| PathBuf::from("daemon.log"), |dir| dir.join("daemon.log"))
}
pub fn sessions_journal_dir_for_socket(socket: &Path) -> PathBuf {
socket
.parent()
.map_or_else(|| PathBuf::from("sessions"), |dir| dir.join("sessions"))
}
pub fn ensure_dir_0700(dir: &Path) -> Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder
.create(dir)
.with_context(|| format!("failed to create directory {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("failed to set 0700 on {}", dir.display()))?;
}
Ok(())
}
pub fn write_file_0600(path: &Path, contents: &[u8]) -> Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(path)
.with_context(|| format!("failed to create file {}", path.display()))?;
ensure_handle_0600(&file)
.with_context(|| format!("failed to set 0600 on {}", path.display()))?;
file.write_all(contents)
.with_context(|| format!("failed to write file {}", path.display()))?;
Ok(())
}
pub fn ensure_handle_0600(file: &std::fs::File) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = file
.metadata()
.context("failed to read file metadata")?
.permissions()
.mode();
if mode & 0o077 != 0 {
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.context("failed to set 0600 on open file")?;
}
}
#[cfg(not(unix))]
{
let _ = file;
}
Ok(())
}
pub fn ensure_parent_dir_0700(path: &Path) -> Result<()> {
if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
if !dir.exists() {
ensure_dir_0700(dir)?;
}
}
Ok(())
}
pub fn create_new_file_0600(path: &Path) -> Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options
.open(path)
.with_context(|| format!("failed to exclusively create file {}", path.display()))?;
if let Err(err) = ensure_handle_0600(&file) {
if let Err(remove_err) = std::fs::remove_file(path) {
tracing::debug!(
"failed to remove {} after its fchmod failed: {remove_err}",
path.display()
);
}
return Err(err).with_context(|| format!("failed to set 0600 on {}", path.display()));
}
Ok(file)
}
pub fn is_already_exists_error(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::AlreadyExists)
})
}
#[derive(Debug)]
pub enum FileLockError {
Busy,
Io(anyhow::Error),
}
#[derive(Debug)]
pub struct FileLock {
#[cfg(unix)]
#[allow(dead_code)] inner: nix::fcntl::Flock<std::fs::File>,
#[cfg(not(unix))]
path: PathBuf,
}
#[cfg(unix)]
const LOCK_REPLACEMENT_RETRIES: u32 = 3;
#[cfg(unix)]
pub fn try_lock_file_exclusive(path: &Path) -> std::result::Result<FileLock, FileLockError> {
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
for _ in 0..LOCK_REPLACEMENT_RETRIES {
let mut options = std::fs::OpenOptions::new();
options.create(true).write(true).truncate(false).mode(0o600);
let file = options
.open(path)
.with_context(|| format!("failed to open lock file {}", path.display()))
.map_err(FileLockError::Io)?;
ensure_handle_0600(&file)
.with_context(|| format!("failed to set 0600 on {}", path.display()))
.map_err(FileLockError::Io)?;
let locked =
match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) {
Ok(locked) => locked,
Err((_file, nix::errno::Errno::EWOULDBLOCK)) => return Err(FileLockError::Busy),
Err((_file, errno)) => {
return Err(FileLockError::Io(anyhow::anyhow!(
"failed to lock {}: {errno}",
path.display()
)))
}
};
let locked_meta = locked
.metadata()
.with_context(|| format!("failed to stat locked handle for {}", path.display()))
.map_err(FileLockError::Io)?;
match std::fs::metadata(path) {
Ok(current_meta)
if current_meta.dev() == locked_meta.dev()
&& current_meta.ino() == locked_meta.ino() =>
{
return Ok(FileLock { inner: locked });
}
_ => {}
}
}
Err(FileLockError::Io(anyhow::anyhow!(
"gave up acquiring the lock on {} after {LOCK_REPLACEMENT_RETRIES} attempts — the file \
kept being replaced out from under us",
path.display()
)))
}
#[cfg(not(unix))]
pub fn try_lock_file_exclusive(path: &Path) -> std::result::Result<FileLock, FileLockError> {
match create_new_file_0600(path) {
Ok(_) => Ok(FileLock {
path: path.to_path_buf(),
}),
Err(err) if is_already_exists_error(&err) => Err(FileLockError::Busy),
Err(err) => Err(FileLockError::Io(err)),
}
}
#[cfg(not(unix))]
impl Drop for FileLock {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_file(&self.path) {
tracing::debug!("failed to remove lock file {}: {err}", self.path.display());
}
}
}
pub(crate) fn try_lock_or_busy(path: &Path, what: &str) -> Result<Option<FileLock>> {
match try_lock_file_exclusive(path) {
Ok(inner) => Ok(Some(inner)),
Err(FileLockError::Busy) => Ok(None),
Err(FileLockError::Io(err)) => {
Err(err.context(format!("failed to lock {what} at {}", path.display())))
}
}
}
pub fn set_file_0600(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to set 0600 on {}", path.display()))?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
pub fn check_socket_path_len(path: &Path) -> Result<()> {
let len = path.as_os_str().len();
if len >= MAX_SOCKET_PATH_LEN {
bail!(
"socket path is {len} bytes, exceeding the {MAX_SOCKET_PATH_LEN}-byte limit: {} — \
pass a shorter --socket path",
path.display()
);
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn default_paths_sit_under_the_runtime_dir() {
let rt = runtime_dir().unwrap();
assert!(socket_path().unwrap().starts_with(&rt));
assert!(token_path().unwrap().starts_with(&rt));
let polling = worktrees_polling_path().unwrap();
assert!(polling.starts_with(&rt));
assert_eq!(polling.file_name().unwrap(), "worktrees-polling.json");
}
#[test]
fn token_sits_beside_its_socket() {
assert_eq!(
token_path_for_socket(Path::new("/tmp/x/daemon.sock")),
Path::new("/tmp/x/bridge.token")
);
assert_eq!(
token_path_for_socket(Path::new("daemon.sock")),
Path::new("bridge.token")
);
}
#[test]
fn log_sits_beside_its_socket() {
assert_eq!(
log_path_for_socket(Path::new("/tmp/x/daemon.sock")),
Path::new("/tmp/x/daemon.log")
);
assert_eq!(
log_path_for_socket(Path::new("daemon.sock")),
Path::new("daemon.log")
);
}
#[test]
fn journals_sit_beside_their_socket() {
assert_eq!(
sessions_journal_dir_for_socket(Path::new("/tmp/x/daemon.sock")),
Path::new("/tmp/x/sessions")
);
assert_eq!(
sessions_journal_dir_for_socket(Path::new("daemon.sock")),
Path::new("sessions")
);
let socket = socket_path().unwrap();
assert_eq!(
sessions_journal_dir_for_socket(&socket),
runtime_dir().unwrap().join("sessions")
);
}
#[test]
fn socket_path_length_guard() {
check_socket_path_len(Path::new("/tmp/short.sock")).unwrap();
let too_long = PathBuf::from(format!("/{}", "a".repeat(MAX_SOCKET_PATH_LEN)));
assert!(check_socket_path_len(&too_long).is_err());
}
#[test]
fn write_file_0600_creates_owner_only() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("fresh.token");
write_file_0600(&file, b"secret").unwrap();
assert_eq!(std::fs::read(&file).unwrap(), b"secret");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[cfg(unix)]
#[test]
fn write_file_0600_retightens_preexisting_loose_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("stale.token");
std::fs::write(&file, "old-secret-with-longer-content").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
write_file_0600(&file, b"new").unwrap();
assert_eq!(std::fs::read(&file).unwrap(), b"new");
assert_eq!(
std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn ensure_dir_and_file_perms() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("run");
ensure_dir_0700(&sub).unwrap();
assert!(sub.is_dir());
let file = sub.join("k");
std::fs::write(&file, "x").unwrap();
set_file_0600(&file).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&sub).unwrap().permissions().mode() & 0o777,
0o700
);
assert_eq!(
std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[test]
fn ensure_parent_dir_0700_creates_a_missing_parent() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("run").join("k");
ensure_parent_dir_0700(&file).unwrap();
assert!(dir.path().join("run").is_dir());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(dir.path().join("run"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
}
}
#[test]
fn ensure_parent_dir_0700_is_a_noop_when_the_parent_already_exists() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("k");
ensure_parent_dir_0700(&file).unwrap();
assert!(dir.path().is_dir());
}
#[test]
fn ensure_parent_dir_0700_is_a_noop_for_a_bare_filename() {
ensure_parent_dir_0700(Path::new("bare.txt")).unwrap();
}
#[test]
fn is_already_exists_error_detects_the_wrapped_io_kind() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("collide");
create_new_file_0600(&path).unwrap();
let err = create_new_file_0600(&path).unwrap_err();
assert!(is_already_exists_error(&err), "{err:?}");
}
#[test]
fn is_already_exists_error_is_false_for_an_unrelated_failure() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no-such-parent").join("child");
let err = create_new_file_0600(&path).unwrap_err();
assert!(!is_already_exists_error(&err), "{err:?}");
}
}