use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::fs_text::{read_text, write_text_atomic};
use crate::home::UnifierHome;
const NAMESPACES: &str = "namespaces";
const MAX_WALK: usize = 32;
pub fn set(home: &UnifierHome, name: &str) -> Result<()> {
validate_namespace(name)?;
let pid = owner_pid();
write_text_atomic(&binding_path(home, pid), &format!("{name}\n"))?;
Ok(())
}
pub fn clear(home: &UnifierHome) -> Result<bool> {
let path = binding_path(home, owner_pid());
if path.is_file() {
fs::remove_file(&path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn effective(home: &UnifierHome, override_ns: Option<&str>) -> Result<Option<String>> {
if let Some(name) = override_ns {
if name.is_empty() {
return Ok(None);
}
validate_namespace(name)?;
return Ok(Some(name.to_string()));
}
resolve_bound(home)
}
pub fn qualify_key(home: &UnifierHome, override_ns: Option<&str>, key: &str) -> Result<String> {
apply_prefix(effective(home, override_ns)?.as_deref(), key)
}
pub fn apply_prefix(ns: Option<&str>, key: &str) -> Result<String> {
let (key, absolute) = match key.strip_prefix('/') {
Some(rest) => (rest.trim_start_matches('/'), true),
None => (key, false),
};
validate_key_path(key)?;
if absolute {
return Ok(key.to_string());
}
match ns {
Some(ns) if !ns.is_empty() => {
let qualified = format!("{ns}/{key}");
validate_key_path(&qualified)?;
Ok(qualified)
}
_ => Ok(key.to_string()),
}
}
pub fn validate_namespace(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::msg("namespace must not be empty"));
}
if name.contains("..") || name.starts_with('/') || name.ends_with('/') {
return Err(Error::msg(format!("invalid namespace: {name}")));
}
Ok(())
}
pub fn reap_dead(home: &UnifierHome) -> Result<usize> {
let dir = namespaces_dir(home);
if !dir.is_dir() {
return Ok(0);
}
let mut n = 0;
for entry in fs::read_dir(dir)? {
let entry = entry?;
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|s| s.parse::<u32>().ok())
else {
continue;
};
if !process_alive(pid) {
let path = entry.path();
if path.is_file() {
fs::remove_file(path)?;
n += 1;
}
}
}
Ok(n)
}
pub fn owner_pid() -> u32 {
if let Ok(v) = std::env::var("UNIFIER_OWNER_PID") {
if let Ok(pid) = v.parse::<u32>() {
if pid > 0 {
return pid;
}
}
}
#[cfg(unix)]
{
std::os::unix::process::parent_id()
}
#[cfg(not(unix))]
{
std::process::id()
}
}
fn resolve_bound(home: &UnifierHome) -> Result<Option<String>> {
let _ = reap_dead(home);
let mut pid = owner_pid();
for _ in 0..MAX_WALK {
if pid <= 1 {
break;
}
if process_alive(pid) {
if let Some(ns) = read_binding(home, pid)? {
return Ok(Some(ns));
}
} else {
let path = binding_path(home, pid);
if path.is_file() {
let _ = fs::remove_file(path);
}
}
match parent_of(pid) {
Some(parent) if parent > 1 && parent != pid => pid = parent,
_ => break,
}
}
Ok(None)
}
fn read_binding(home: &UnifierHome, pid: u32) -> Result<Option<String>> {
let path = binding_path(home, pid);
if !path.is_file() {
return Ok(None);
}
let name = read_text(&path)?;
if name.is_empty() {
return Ok(None);
}
validate_namespace(&name)?;
Ok(Some(name))
}
fn namespaces_dir(home: &UnifierHome) -> PathBuf {
home.path().join(".daemon").join(NAMESPACES)
}
fn binding_path(home: &UnifierHome, pid: u32) -> PathBuf {
namespaces_dir(home).join(pid.to_string())
}
fn validate_key_path(key: &str) -> Result<()> {
if key.is_empty() {
return Err(Error::msg("key must not be empty"));
}
if key.contains("..") {
return Err(Error::msg("key must not contain '..'"));
}
Ok(())
}
fn process_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(unix)]
fn parent_of(pid: u32) -> Option<u32> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rsplit_once(')')?.1;
let ppid: u32 = after_comm.split_whitespace().nth(1)?.parse().ok()?;
if ppid == 0 || ppid == pid {
None
} else {
Some(ppid)
}
}
#[cfg(not(unix))]
fn parent_of(_pid: u32) -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::home::UnifierHome;
use tempfile::tempdir;
#[test]
fn prefix_joins_namespace_and_key() {
assert_eq!(
apply_prefix(Some("myscript"), "app/theme").unwrap(),
"myscript/app/theme"
);
assert_eq!(apply_prefix(None, "app/theme").unwrap(), "app/theme");
assert_eq!(apply_prefix(Some(""), "k").unwrap(), "k");
}
#[test]
fn leading_slash_skips_namespace() {
assert_eq!(
apply_prefix(Some("myscript"), "/global/k").unwrap(),
"global/k"
);
assert_eq!(apply_prefix(Some("myscript"), "//k").unwrap(), "k");
}
#[test]
fn rejects_empty_and_dotdot() {
assert!(apply_prefix(None, "").is_err());
assert!(apply_prefix(None, "/").is_err());
assert!(apply_prefix(Some("a/../b"), "k").is_err());
assert!(validate_namespace("..").is_err());
assert!(validate_namespace("/abs").is_err());
assert!(validate_namespace("trail/").is_err());
}
#[test]
fn bind_and_clear_for_owner() {
let tmp = tempdir().unwrap();
let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
home.ensure().unwrap();
set(&home, "job-a").unwrap();
assert_eq!(effective(&home, None).unwrap().as_deref(), Some("job-a"));
assert_eq!(qualify_key(&home, None, "theme").unwrap(), "job-a/theme");
assert!(clear(&home).unwrap());
assert_eq!(effective(&home, None).unwrap(), None);
assert_eq!(qualify_key(&home, None, "theme").unwrap(), "theme");
}
#[test]
fn override_beats_binding_and_empty_disables() {
let tmp = tempdir().unwrap();
let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
home.ensure().unwrap();
set(&home, "job-a").unwrap();
assert_eq!(qualify_key(&home, Some("other"), "k").unwrap(), "other/k");
assert_eq!(qualify_key(&home, Some(""), "k").unwrap(), "k");
}
}