Skip to main content

runtime_paths/
lib.rs

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