Skip to main content

runtime_paths/
lib.rs

1mod endpoint;
2mod env;
3pub use env::{env_for_workdir, RuntimeEnv};
4#[cfg(unix)]
5mod unix_process;
6#[cfg(windows)]
7mod windows_process;
8
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::OnceLock;
12
13pub use endpoint::{
14    meowclient_endpoint_label_for_workdir, meowclient_named_pipe_name_for_workdir,
15    workdir_identity_hash,
16};
17#[cfg(unix)]
18pub use unix_process::{
19    clear_stale_unix_socket, is_pid_alive, process_executable_path, unix_peer_pid,
20};
21#[cfg(windows)]
22pub use windows_process::{is_pid_alive, process_executable_path};
23
24/// Compare process image paths, preferring canonical forms when available.
25pub fn paths_match(actual: &Path, expected: &Path) -> bool {
26    match (actual.canonicalize(), expected.canonicalize()) {
27        (Ok(actual), Ok(expected)) => actual == expected,
28        _ => actual == expected,
29    }
30}
31
32/// True when `pid` is alive and its executable matches `expected_binary`.
33pub fn pid_matches_executable(pid: u32, expected_binary: &Path) -> bool {
34    if !is_pid_alive(pid) {
35        return false;
36    }
37    match process_executable_path(pid) {
38        Some(path) => paths_match(&path, expected_binary),
39        None => false,
40    }
41}
42
43/// If `pid_file` names a live process whose executable matches `expected_binary`,
44/// returns its pid. Missing files return `Ok(None)`. Dead processes have their pid
45/// file removed and return `Ok(None)`. Live processes with a different executable
46/// are left untouched and return `Ok(None)`.
47pub fn owned_daemon_pid_from_file(
48    pid_file: &Path,
49    expected_binary: &Path,
50) -> Result<Option<u32>, String> {
51    let raw = match fs::read_to_string(pid_file) {
52        Ok(value) => value,
53        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
54        Err(err) => {
55            return Err(format!(
56                "failed to read daemon pid file {}: {}",
57                pid_file.display(),
58                err
59            ))
60        }
61    };
62    let pid = raw
63        .trim()
64        .parse::<u32>()
65        .map_err(|err| format!("invalid daemon pid in {}: {}", pid_file.display(), err))?;
66
67    if !is_pid_alive(pid) {
68        match fs::remove_file(pid_file) {
69            Ok(()) => {}
70            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
71            Err(err) => {
72                return Err(format!(
73                    "failed to remove stale daemon pid file {}: {}",
74                    pid_file.display(),
75                    err
76                ))
77            }
78        }
79        return Ok(None);
80    }
81    if !pid_matches_executable(pid, expected_binary) {
82        return Ok(None);
83    }
84    Ok(Some(pid))
85}
86
87/// Convenience wrapper: verify the default meowclient pid file against `expected_binary`.
88pub fn owned_meowclient_pid(expected_binary: &Path) -> Result<Option<u32>, String> {
89    owned_daemon_pid_from_file(&runtime_paths().meowclient_pid_file(), expected_binary)
90}
91
92static RUNTIME_WORKDIR: OnceLock<(PathBuf, RuntimeEnv)> = OnceLock::new();
93
94#[derive(Debug, Clone)]
95pub struct RuntimePaths {
96    workdir: PathBuf,
97}
98
99impl RuntimePaths {
100    pub fn new(workdir: PathBuf) -> Self {
101        Self { workdir }
102    }
103
104    pub fn for_workdir(workdir: impl AsRef<Path>) -> Self {
105        Self::new(workdir.as_ref().to_path_buf())
106    }
107
108    pub fn workdir(&self) -> &Path {
109        &self.workdir
110    }
111
112    pub fn system_root(&self) -> PathBuf {
113        self.workdir.join("system")
114    }
115
116    pub fn services_root(&self) -> PathBuf {
117        self.service_packages_root()
118    }
119
120    pub fn packages_root(&self) -> PathBuf {
121        self.workdir.join("packages")
122    }
123
124    pub fn service_packages_root(&self) -> PathBuf {
125        self.packages_root().join("services")
126    }
127
128    pub fn service_package_dir(&self, service_id: &str) -> PathBuf {
129        self.service_packages_root().join(service_id)
130    }
131
132    pub fn config_root(&self) -> PathBuf {
133        self.workdir.join("config")
134    }
135
136    pub fn run_root(&self) -> PathBuf {
137        self.workdir.join("run")
138    }
139
140    pub fn log_root(&self) -> PathBuf {
141        self.workdir.join("log")
142    }
143
144    pub fn data_root(&self) -> PathBuf {
145        self.workdir.join("data")
146    }
147
148    pub fn state_root(&self) -> PathBuf {
149        self.workdir.join("state")
150    }
151
152    pub fn downloads_root(&self) -> PathBuf {
153        self.workdir.join("downloads")
154    }
155
156    pub fn tools_root(&self) -> PathBuf {
157        self.workdir.join("tools")
158    }
159
160    pub fn downloads_tools_root(&self) -> PathBuf {
161        self.downloads_root().join("tools")
162    }
163
164    pub fn artifacts_root(&self) -> PathBuf {
165        self.workdir.join("artifacts")
166    }
167
168    /// Transient files owned by workdir-scoped framework services.
169    pub fn tmp_root(&self) -> PathBuf {
170        self.workdir.join("tmp")
171    }
172
173    pub fn component_config_path(&self, component_name: &str) -> PathBuf {
174        self.config_root().join(format!("{component_name}.yaml"))
175    }
176
177    pub fn component_run_dir(&self, component_name: &str) -> PathBuf {
178        self.run_root().join(component_name)
179    }
180
181    pub fn component_data_dir(&self, component_name: &str) -> PathBuf {
182        self.data_root().join(component_name)
183    }
184
185    pub fn component_state_dir(&self, component_name: &str) -> PathBuf {
186        self.state_root().join(component_name)
187    }
188
189    pub fn component_system_dir(&self, component_name: &str) -> PathBuf {
190        self.system_root().join(component_name)
191    }
192
193    pub fn component_log_path(&self, component_name: &str) -> PathBuf {
194        self.log_root().join(format!("{component_name}.log"))
195    }
196
197    pub fn service_data_dir(&self, service_id: &str) -> PathBuf {
198        self.data_root().join("services").join(service_id)
199    }
200
201    pub fn service_run_dir(&self, service_id: &str) -> PathBuf {
202        self.run_root().join("services").join(service_id)
203    }
204
205    pub fn service_log_path(&self, service_id: &str) -> PathBuf {
206        self.log_root()
207            .join(format!("{}.log", normalized_service_log_name(service_id)))
208    }
209
210    pub fn identity_file_path(&self) -> PathBuf {
211        self.system_root().join("device_identity.json")
212    }
213
214    pub fn meowclient_socket_path(&self) -> PathBuf {
215        self.component_run_dir("meowclient").join("meowd.sock")
216    }
217
218    pub fn meowclient_pid_file(&self) -> PathBuf {
219        self.component_run_dir("meowclient").join("meowd.pid")
220    }
221
222    pub fn meowclient_ipc_token_file(&self) -> PathBuf {
223        self.component_run_dir("meowclient").join("meowd.ipc.token")
224    }
225
226    pub fn meowclient_named_pipe_name(&self) -> String {
227        meowclient_named_pipe_name_for_workdir(&self.workdir)
228    }
229
230    pub fn meowclient_endpoint_label(&self) -> String {
231        meowclient_endpoint_label_for_workdir(&self.workdir)
232    }
233}
234
235fn normalized_service_log_name(service_id: &str) -> String {
236    match service_id {
237        "meowcore" | "meow-core" => "meow-core".to_string(),
238        value if value.starts_with("node-") => value.to_string(),
239        value => format!("node-{value}"),
240    }
241}
242
243pub fn set_runtime_workdir(workdir: PathBuf) -> anyhow::Result<()> {
244    let env = env_for_workdir(&workdir, &default_runtime_root()?)?;
245    match RUNTIME_WORKDIR.get() {
246        Some((existing, existing_env)) if existing == &workdir && *existing_env == env => Ok(()),
247        Some((existing, _)) => Err(anyhow::anyhow!(
248            "runtime workdir/environment already set to {}, cannot change to {}",
249            existing.display(),
250            workdir.display()
251        )),
252        None => RUNTIME_WORKDIR
253            .set((workdir, env))
254            .map_err(|_| anyhow::anyhow!("failed to set runtime workdir")),
255    }
256}
257
258pub fn runtime_workdir() -> PathBuf {
259    RUNTIME_WORKDIR
260        .get()
261        .expect("runtime workdir is not initialized at process startup")
262        .0
263        .clone()
264}
265
266/// Libraries used before runtime startup retain the historical production default.
267/// Running processes set their immutable workdir before accepting any requests.
268pub fn runtime_env() -> RuntimeEnv {
269    RUNTIME_WORKDIR
270        .get()
271        .map(|(_, env)| *env)
272        .unwrap_or_default()
273}
274
275pub fn runtime_paths() -> RuntimePaths {
276    RuntimePaths::new(runtime_workdir())
277}
278
279pub fn resolve_workdir(dev: bool) -> PathBuf {
280    let root = default_runtime_root().expect("failed to resolve default runtime workdir");
281    if dev {
282        root.join("dev")
283    } else {
284        root
285    }
286}
287
288pub fn default_runtime_root() -> anyhow::Result<PathBuf> {
289    #[cfg(windows)]
290    {
291        let local_app_data = std::env::var_os("LOCALAPPDATA")
292            .filter(|value| !value.is_empty())
293            .ok_or_else(|| anyhow::anyhow!("LOCALAPPDATA is not set"))?;
294        return Ok(PathBuf::from(local_app_data).join(".meow"));
295    }
296
297    #[cfg(not(windows))]
298    {
299        let home = home_dir().ok_or_else(|| anyhow::anyhow!("HOME is not set"))?;
300        Ok(home.join(".meow"))
301    }
302}
303
304#[cfg(not(windows))]
305fn home_dir() -> Option<PathBuf> {
306    std::env::var_os("HOME")
307        .filter(|value| !value.is_empty())
308        .map(PathBuf::from)
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn service_log_paths_use_standard_log_names() {
317        let paths = RuntimePaths::for_workdir("/tmp/meow-runtime-test");
318
319        assert_eq!(
320            paths.service_log_path("camera"),
321            PathBuf::from("/tmp/meow-runtime-test/log/node-camera.log")
322        );
323        assert_eq!(
324            paths.service_log_path("node-camera"),
325            PathBuf::from("/tmp/meow-runtime-test/log/node-camera.log")
326        );
327        assert_eq!(
328            paths.service_log_path("meowcore"),
329            PathBuf::from("/tmp/meow-runtime-test/log/meow-core.log")
330        );
331    }
332
333    #[test]
334    fn tmp_root_is_workdir_scoped() {
335        let paths = RuntimePaths::for_workdir("/tmp/meow-runtime-test");
336        assert_eq!(
337            paths.tmp_root(),
338            PathBuf::from("/tmp/meow-runtime-test/tmp")
339        );
340    }
341
342    #[cfg(not(windows))]
343    #[test]
344    fn default_runtime_root_uses_dot_meow_under_home() {
345        let root = default_runtime_root().unwrap();
346        assert_eq!(
347            root.file_name().and_then(|value| value.to_str()),
348            Some(".meow")
349        );
350    }
351}