1use 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
21pub 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
29pub 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
40pub 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
52pub 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
57pub 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
87pub 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
114pub 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}