use std::env;
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use sha2::{Digest, Sha256};
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
use crate::model::RuntimePaths;
#[cfg(unix)]
pub const DIR_MODE: u32 = 0o700;
#[cfg(unix)]
pub const FILE_MODE: u32 = 0o600;
#[cfg(target_os = "macos")]
pub const SOCKET_PATH_MAX: usize = 104;
#[cfg(all(unix, not(target_os = "macos")))]
pub const SOCKET_PATH_MAX: usize = 108;
#[cfg(not(unix))]
pub const SOCKET_PATH_MAX: usize = 108;
pub fn create_dir_secure(path: &Path) -> Result<()> {
#[cfg(unix)]
{
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
let mut builder = fs::DirBuilder::new();
builder.recursive(true).mode(DIR_MODE);
builder.create(path)?;
let perms = fs::Permissions::from_mode(DIR_MODE);
fs::set_permissions(path, perms)?;
}
#[cfg(not(unix))]
{
fs::create_dir_all(path)?;
}
Ok(())
}
pub fn build_instance_id(
session: Option<&str>,
config_dir: &Path,
config_files: &[PathBuf],
) -> String {
let mut hasher = Sha256::new();
if let Some(name) = session {
hasher.update(b"session:");
hasher.update(name.as_bytes());
} else {
let dir_display = canonical_or_original(config_dir);
hasher.update(dir_display.as_bytes());
let mut sorted: Vec<String> = config_files
.iter()
.map(|p| canonical_or_original(p))
.collect();
sorted.sort();
for file in &sorted {
hasher.update([0]);
hasher.update(file.as_bytes());
}
}
let digest = hasher.finalize();
let hex = hex_encode(&digest);
hex.chars().take(16).collect()
}
pub fn runtime_dir() -> Result<PathBuf> {
let home = home_dir()?;
let dir = socket_root_with_env(
&home,
env::var_os("XDG_RUNTIME_DIR").as_deref(),
env::var_os("XDG_STATE_HOME").as_deref(),
);
Ok(dir)
}
pub fn runtime_paths_for(instance: &str) -> Result<RuntimePaths> {
let home = home_dir()?;
let socket_root = socket_root_with_env(
&home,
env::var_os("XDG_RUNTIME_DIR").as_deref(),
env::var_os("XDG_STATE_HOME").as_deref(),
);
let state_root = state_root_with_env(&home, env::var_os("XDG_STATE_HOME").as_deref());
create_dir_secure(&socket_root)?;
create_dir_secure(&state_root)?;
let socket = socket_root.join(format!("{instance}.sock"));
check_socket_path_length(&socket)?;
Ok(RuntimePaths {
socket,
pid: state_root.join(format!("{instance}.pid")),
daemon_log: state_root.join(format!("{instance}.log")),
lock: state_root.join(format!("{instance}.lock")),
})
}
pub fn check_socket_path_length(path: &Path) -> Result<()> {
let bytes = path.as_os_str().as_encoded_bytes().len();
let usable = SOCKET_PATH_MAX.saturating_sub(1);
if bytes > usable {
let limit = SOCKET_PATH_MAX;
let os = if cfg!(target_os = "macos") {
"macOS"
} else {
"Linux"
};
return Err(anyhow!(
"socket path is too long for the kernel's sockaddr_un.sun_path buffer \
({os} limit: {limit} bytes including NUL; usable: {usable} bytes; \
got {bytes} bytes): {path}\n\
\n\
Set a shorter XDG_RUNTIME_DIR to work around this, e.g.:\n \
export XDG_RUNTIME_DIR=/tmp/xdg-run",
path = path.display(),
));
}
Ok(())
}
pub fn socket_root_with_env(
home: &Path,
xdg_runtime_dir: Option<&OsStr>,
xdg_state_home: Option<&OsStr>,
) -> PathBuf {
if let Some(dir) = xdg_runtime_dir {
return PathBuf::from(dir).join("decompose");
}
if let Some(dir) = xdg_state_home {
return PathBuf::from(dir).join("decompose");
}
home.join(".local").join("decompose")
}
pub fn state_root_with_env(home: &Path, xdg_state_home: Option<&OsStr>) -> PathBuf {
if let Some(dir) = xdg_state_home {
return PathBuf::from(dir).join("decompose");
}
home.join(".local").join("state").join("decompose")
}
fn canonical_or_original(path: &Path) -> String {
path.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string()
}
fn home_dir() -> Result<PathBuf> {
let raw = env::var_os("HOME").ok_or_else(|| anyhow!("HOME is not set"))?;
Ok(PathBuf::from(raw))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instance_id_is_stable_for_same_inputs() {
let files = vec![PathBuf::from("/a/decompose.yaml")];
let id1 = build_instance_id(None, Path::new("/a"), &files);
let id2 = build_instance_id(None, Path::new("/a"), &files);
assert_eq!(id1, id2);
assert_eq!(id1.len(), 16);
}
#[test]
fn instance_id_changes_when_config_dir_changes() {
let files = vec![PathBuf::from("/a/decompose.yaml")];
let id1 = build_instance_id(None, Path::new("/a"), &files);
let id2 = build_instance_id(None, Path::new("/b"), &files);
assert_ne!(id1, id2);
}
#[test]
fn instance_id_changes_when_config_files_change() {
let files1 = vec![PathBuf::from("/a/one.yaml")];
let files2 = vec![PathBuf::from("/a/two.yaml")];
let id1 = build_instance_id(None, Path::new("/a"), &files1);
let id2 = build_instance_id(None, Path::new("/a"), &files2);
assert_ne!(id1, id2);
}
#[test]
fn instance_id_with_session_override() {
let files = vec![PathBuf::from("/a/decompose.yaml")];
let id1 = build_instance_id(Some("my-project"), Path::new("/a"), &files);
let id2 = build_instance_id(Some("my-project"), Path::new("/b"), &files);
assert_eq!(id1, id2);
assert_eq!(id1.len(), 16);
}
#[test]
fn instance_id_session_differs_from_no_session() {
let files = vec![PathBuf::from("/a/decompose.yaml")];
let id_session = build_instance_id(Some("my-project"), Path::new("/a"), &files);
let id_no_session = build_instance_id(None, Path::new("/a"), &files);
assert_ne!(id_session, id_no_session);
}
#[test]
fn instance_id_different_sessions_differ() {
let files = vec![PathBuf::from("/a/decompose.yaml")];
let id1 = build_instance_id(Some("project-a"), Path::new("/a"), &files);
let id2 = build_instance_id(Some("project-b"), Path::new("/a"), &files);
assert_ne!(id1, id2);
}
#[test]
fn instance_id_multi_file_order_independent() {
let files_a = vec![
PathBuf::from("/a/base.yaml"),
PathBuf::from("/a/override.yaml"),
];
let files_b = vec![
PathBuf::from("/a/override.yaml"),
PathBuf::from("/a/base.yaml"),
];
let id1 = build_instance_id(None, Path::new("/a"), &files_a);
let id2 = build_instance_id(None, Path::new("/a"), &files_b);
assert_eq!(id1, id2);
}
#[test]
fn instance_id_multi_file_differs_from_single() {
let single = vec![PathBuf::from("/a/base.yaml")];
let multi = vec![
PathBuf::from("/a/base.yaml"),
PathBuf::from("/a/override.yaml"),
];
let id1 = build_instance_id(None, Path::new("/a"), &single);
let id2 = build_instance_id(None, Path::new("/a"), &multi);
assert_ne!(id1, id2);
}
#[test]
fn socket_root_prefers_xdg_runtime() {
let root = socket_root_with_env(
Path::new("/home/u"),
Some(OsStr::new("/run/user/1000")),
Some(OsStr::new("/state")),
);
assert_eq!(root, Path::new("/run/user/1000").join("decompose"));
}
#[test]
fn socket_root_falls_back_to_xdg_state_then_home_local() {
let state_root =
socket_root_with_env(Path::new("/home/u"), None, Some(OsStr::new("/state/home")));
assert_eq!(state_root, Path::new("/state/home").join("decompose"));
let home_root = socket_root_with_env(Path::new("/home/u"), None, None);
assert_eq!(home_root, Path::new("/home/u/.local/decompose"));
}
#[test]
fn state_root_uses_xdg_state_or_default() {
let xdg = state_root_with_env(Path::new("/home/u"), Some(OsStr::new("/state/home")));
assert_eq!(xdg, Path::new("/state/home").join("decompose"));
let fallback = state_root_with_env(Path::new("/home/u"), None);
assert_eq!(fallback, Path::new("/home/u/.local/state/decompose"));
}
#[cfg(unix)]
#[test]
fn create_dir_secure_sets_0o700_on_new_dir() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("new_dir");
create_dir_secure(&target).unwrap();
let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn check_socket_path_length_accepts_short_path() {
let p = PathBuf::from("/tmp/decompose/abc.sock");
assert!(check_socket_path_length(&p).is_ok());
}
#[test]
fn check_socket_path_length_accepts_path_at_limit() {
let usable = SOCKET_PATH_MAX - 1;
let mut s = String::from("/");
s.push_str(&"a".repeat(usable - 1));
let p = PathBuf::from(&s);
assert_eq!(p.as_os_str().len(), usable);
assert!(check_socket_path_length(&p).is_ok());
}
#[test]
fn check_socket_path_length_rejects_overlong_path() {
let over = SOCKET_PATH_MAX; let mut s = String::from("/");
s.push_str(&"a".repeat(over - 1));
let p = PathBuf::from(&s);
assert_eq!(p.as_os_str().len(), over);
let err = check_socket_path_length(&p).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("socket path is too long"), "msg={msg}");
assert!(msg.contains(&format!("{SOCKET_PATH_MAX}")), "msg={msg}");
assert!(msg.contains(&format!("{over}")), "msg={msg}");
assert!(msg.contains("XDG_RUNTIME_DIR"), "msg={msg}");
}
#[test]
fn check_socket_path_length_rejects_deeply_nested_runtime_dir() {
let long_runtime = "/Users/averyverylongusernamewithmanycharacters/Library/Application Support/xdg-runtime-dir/decompose/session-scope";
let mut p = PathBuf::from(long_runtime);
p.push("0123456789abcdef.sock");
assert!(
p.as_os_str().len() > SOCKET_PATH_MAX - 1,
"path len {} must exceed usable limit {}",
p.as_os_str().len(),
SOCKET_PATH_MAX - 1
);
assert!(check_socket_path_length(&p).is_err());
}
#[cfg(unix)]
#[test]
fn create_dir_secure_tightens_existing_loose_dir() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("loose_dir");
fs::create_dir(&target).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
create_dir_secure(&target).unwrap();
let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
}