Skip to main content

unifier/
namespace.rs

1//! Process-scoped key namespaces.
2//!
3//! `namespace set` binds a prefix to the calling process. Later `put`/`get`/`del`
4//! (and tick lock/unlock) from that process — or a descendant — prefix keys with
5//! it until the process exits or a new namespace is set.
6//!
7//! `--namespace` / `$UNIFIER_NAMESPACE` overrides the bound prefix for one
8//! invocation. A key starting with `/` is absolute (no prefix). Empty
9//! `$UNIFIER_NAMESPACE` disables the bound prefix for that invocation.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::error::{Error, Result};
15use crate::fs_text::{read_text, write_text_atomic};
16use crate::home::UnifierHome;
17
18const NAMESPACES: &str = "namespaces";
19const MAX_WALK: usize = 32;
20
21/// Bind `name` as the namespace for the calling process.
22pub fn set(home: &UnifierHome, name: &str) -> Result<()> {
23    validate_namespace(name)?;
24    let pid = owner_pid();
25    write_text_atomic(&binding_path(home, pid), &format!("{name}\n"))?;
26    Ok(())
27}
28
29/// Remove this process's namespace binding (ancestors are unchanged).
30pub fn clear(home: &UnifierHome) -> Result<bool> {
31    let path = binding_path(home, owner_pid());
32    if path.is_file() {
33        fs::remove_file(&path)?;
34        Ok(true)
35    } else {
36        Ok(false)
37    }
38}
39
40/// Effective namespace: explicit override, else nearest living ancestor binding.
41pub fn effective(home: &UnifierHome, override_ns: Option<&str>) -> Result<Option<String>> {
42    if let Some(name) = override_ns {
43        if name.is_empty() {
44            return Ok(None);
45        }
46        validate_namespace(name)?;
47        return Ok(Some(name.to_string()));
48    }
49    resolve_bound(home)
50}
51
52/// Apply the effective namespace to a key path.
53pub fn qualify_key(home: &UnifierHome, override_ns: Option<&str>, key: &str) -> Result<String> {
54    apply_prefix(effective(home, override_ns)?.as_deref(), key)
55}
56
57/// Prefix `key` with `ns`, unless `key` is absolute (`/...`).
58pub fn apply_prefix(ns: Option<&str>, key: &str) -> Result<String> {
59    let (key, absolute) = match key.strip_prefix('/') {
60        Some(rest) => (rest.trim_start_matches('/'), true),
61        None => (key, false),
62    };
63    validate_key_path(key)?;
64    if absolute {
65        return Ok(key.to_string());
66    }
67    match ns {
68        Some(ns) if !ns.is_empty() => {
69            let qualified = format!("{ns}/{key}");
70            validate_key_path(&qualified)?;
71            Ok(qualified)
72        }
73        _ => Ok(key.to_string()),
74    }
75}
76
77pub fn validate_namespace(name: &str) -> Result<()> {
78    if name.is_empty() {
79        return Err(Error::msg("namespace must not be empty"));
80    }
81    if name.contains("..") || name.starts_with('/') || name.ends_with('/') {
82        return Err(Error::msg(format!("invalid namespace: {name}")));
83    }
84    Ok(())
85}
86
87/// Drop bindings whose process is gone.
88pub fn reap_dead(home: &UnifierHome) -> Result<usize> {
89    let dir = namespaces_dir(home);
90    if !dir.is_dir() {
91        return Ok(0);
92    }
93    let mut n = 0;
94    for entry in fs::read_dir(dir)? {
95        let entry = entry?;
96        let Some(pid) = entry
97            .file_name()
98            .to_str()
99            .and_then(|s| s.parse::<u32>().ok())
100        else {
101            continue;
102        };
103        if !process_alive(pid) {
104            let path = entry.path();
105            if path.is_file() {
106                fs::remove_file(path)?;
107                n += 1;
108            }
109        }
110    }
111    Ok(n)
112}
113
114/// PID whose namespace this unifier invocation should bind or look up.
115///
116/// Defaults to the parent (the script or shell). Override with `$UNIFIER_OWNER_PID`.
117pub fn owner_pid() -> u32 {
118    if let Ok(v) = std::env::var("UNIFIER_OWNER_PID") {
119        if let Ok(pid) = v.parse::<u32>() {
120            if pid > 0 {
121                return pid;
122            }
123        }
124    }
125    #[cfg(unix)]
126    {
127        std::os::unix::process::parent_id()
128    }
129    #[cfg(not(unix))]
130    {
131        std::process::id()
132    }
133}
134
135fn resolve_bound(home: &UnifierHome) -> Result<Option<String>> {
136    let _ = reap_dead(home);
137    let mut pid = owner_pid();
138    for _ in 0..MAX_WALK {
139        if pid <= 1 {
140            break;
141        }
142        if process_alive(pid) {
143            if let Some(ns) = read_binding(home, pid)? {
144                return Ok(Some(ns));
145            }
146        } else {
147            let path = binding_path(home, pid);
148            if path.is_file() {
149                let _ = fs::remove_file(path);
150            }
151        }
152        match parent_of(pid) {
153            Some(parent) if parent > 1 && parent != pid => pid = parent,
154            _ => break,
155        }
156    }
157    Ok(None)
158}
159
160fn read_binding(home: &UnifierHome, pid: u32) -> Result<Option<String>> {
161    let path = binding_path(home, pid);
162    if !path.is_file() {
163        return Ok(None);
164    }
165    let name = read_text(&path)?;
166    if name.is_empty() {
167        return Ok(None);
168    }
169    validate_namespace(&name)?;
170    Ok(Some(name))
171}
172
173fn namespaces_dir(home: &UnifierHome) -> PathBuf {
174    home.path().join(".daemon").join(NAMESPACES)
175}
176
177fn binding_path(home: &UnifierHome, pid: u32) -> PathBuf {
178    namespaces_dir(home).join(pid.to_string())
179}
180
181fn validate_key_path(key: &str) -> Result<()> {
182    if key.is_empty() {
183        return Err(Error::msg("key must not be empty"));
184    }
185    if key.contains("..") {
186        return Err(Error::msg("key must not contain '..'"));
187    }
188    Ok(())
189}
190
191fn process_alive(pid: u32) -> bool {
192    Path::new(&format!("/proc/{pid}")).exists()
193}
194
195#[cfg(unix)]
196fn parent_of(pid: u32) -> Option<u32> {
197    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
198    let after_comm = stat.rsplit_once(')')?.1;
199    let ppid: u32 = after_comm.split_whitespace().nth(1)?.parse().ok()?;
200    if ppid == 0 || ppid == pid {
201        None
202    } else {
203        Some(ppid)
204    }
205}
206
207#[cfg(not(unix))]
208fn parent_of(_pid: u32) -> Option<u32> {
209    None
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::home::UnifierHome;
216    use tempfile::tempdir;
217
218    #[test]
219    fn prefix_joins_namespace_and_key() {
220        assert_eq!(
221            apply_prefix(Some("myscript"), "app/theme").unwrap(),
222            "myscript/app/theme"
223        );
224        assert_eq!(apply_prefix(None, "app/theme").unwrap(), "app/theme");
225        assert_eq!(apply_prefix(Some(""), "k").unwrap(), "k");
226    }
227
228    #[test]
229    fn leading_slash_skips_namespace() {
230        assert_eq!(
231            apply_prefix(Some("myscript"), "/global/k").unwrap(),
232            "global/k"
233        );
234        assert_eq!(apply_prefix(Some("myscript"), "//k").unwrap(), "k");
235    }
236
237    #[test]
238    fn rejects_empty_and_dotdot() {
239        assert!(apply_prefix(None, "").is_err());
240        assert!(apply_prefix(None, "/").is_err());
241        assert!(apply_prefix(Some("a/../b"), "k").is_err());
242        assert!(validate_namespace("..").is_err());
243        assert!(validate_namespace("/abs").is_err());
244        assert!(validate_namespace("trail/").is_err());
245    }
246
247    #[test]
248    fn bind_and_clear_for_owner() {
249        let tmp = tempdir().unwrap();
250        let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
251        home.ensure().unwrap();
252
253        set(&home, "job-a").unwrap();
254        assert_eq!(effective(&home, None).unwrap().as_deref(), Some("job-a"));
255        assert_eq!(qualify_key(&home, None, "theme").unwrap(), "job-a/theme");
256        assert!(clear(&home).unwrap());
257        assert_eq!(effective(&home, None).unwrap(), None);
258        assert_eq!(qualify_key(&home, None, "theme").unwrap(), "theme");
259    }
260
261    #[test]
262    fn override_beats_binding_and_empty_disables() {
263        let tmp = tempdir().unwrap();
264        let home = UnifierHome::resolve(Some(tmp.path().to_path_buf()), None).unwrap();
265        home.ensure().unwrap();
266        set(&home, "job-a").unwrap();
267
268        assert_eq!(qualify_key(&home, Some("other"), "k").unwrap(), "other/k");
269        assert_eq!(qualify_key(&home, Some(""), "k").unwrap(), "k");
270    }
271}