unifier-cli 0.3.0

Filesystem postbox for inter-process communication via a Unix tree
Documentation
//! Process-scoped key namespaces.
//!
//! `namespace set` binds a prefix to the calling process. Later `put`/`get`/`del`
//! (and tick lock/unlock) from that process — or a descendant — prefix keys with
//! it until the process exits or a new namespace is set.
//!
//! `--namespace` / `$UNIFIER_NAMESPACE` overrides the bound prefix for one
//! invocation. A key starting with `/` is absolute (no prefix). Empty
//! `$UNIFIER_NAMESPACE` disables the bound prefix for that invocation.

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;

/// Bind `name` as the namespace for the calling process.
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(())
}

/// Remove this process's namespace binding (ancestors are unchanged).
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)
    }
}

/// Effective namespace: explicit override, else nearest living ancestor binding.
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)
}

/// Apply the effective namespace to a key path.
pub fn qualify_key(home: &UnifierHome, override_ns: Option<&str>, key: &str) -> Result<String> {
    apply_prefix(effective(home, override_ns)?.as_deref(), key)
}

/// Prefix `key` with `ns`, unless `key` is absolute (`/...`).
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(())
}

/// Drop bindings whose process is gone.
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)
}

/// PID whose namespace this unifier invocation should bind or look up.
///
/// Defaults to the parent (the script or shell). Override with `$UNIFIER_OWNER_PID`.
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");
    }
}