Skip to main content

runtime_paths/
lib.rs

1mod endpoint;
2mod env;
3pub use env::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
243/// Initialize an embedded production runtime with an explicit workdir.
244/// Non-production callers must use `set_runtime_context` with their startup env.
245pub fn set_runtime_workdir(workdir: PathBuf) -> anyhow::Result<()> {
246    set_runtime_context(workdir, RuntimeEnv::Prod)
247}
248
249/// Freeze the environment and workdir supplied by the owning startup entrypoint.
250/// This does not consult HOME, build profiles, configuration, or directory names.
251pub fn set_runtime_context(workdir: PathBuf, env: RuntimeEnv) -> anyhow::Result<()> {
252    anyhow::ensure!(workdir.is_absolute(), "runtime workdir must be absolute");
253    match RUNTIME_WORKDIR.get() {
254        Some((existing, existing_env)) if existing == &workdir && *existing_env == env => Ok(()),
255        Some((existing, _)) => Err(anyhow::anyhow!(
256            "runtime workdir/environment already set to {}, cannot change to {}",
257            existing.display(),
258            workdir.display()
259        )),
260        None => RUNTIME_WORKDIR
261            .set((workdir, env))
262            .map_err(|_| anyhow::anyhow!("failed to set runtime workdir")),
263    }
264}
265
266pub fn runtime_workdir() -> PathBuf {
267    RUNTIME_WORKDIR
268        .get()
269        .expect("runtime workdir is not initialized at process startup")
270        .0
271        .clone()
272}
273
274/// Libraries used before runtime startup retain the historical production default.
275/// Running processes set their immutable workdir before accepting any requests.
276pub fn runtime_env() -> RuntimeEnv {
277    RUNTIME_WORKDIR
278        .get()
279        .map(|(_, env)| *env)
280        .unwrap_or_default()
281}
282
283pub fn runtime_paths() -> RuntimePaths {
284    RuntimePaths::new(runtime_workdir())
285}
286
287/// Resolve the runtime workdir from an explicit base home and startup env.
288/// The platform user home is needed only when no runtime home was supplied.
289pub fn resolve_runtime_workdir(home: Option<&Path>, env: RuntimeEnv) -> anyhow::Result<PathBuf> {
290    let home = match home {
291        Some(home) => home.to_path_buf(),
292        None => default_runtime_root()?,
293    };
294    anyhow::ensure!(home.is_absolute(), "runtime home must be absolute");
295    let workdir = if env.is_prod() {
296        home.clone()
297    } else {
298        home.join(env.as_str())
299    };
300    if !env.is_prod() {
301        match std::fs::symlink_metadata(&workdir) {
302            Ok(metadata) => anyhow::ensure!(
303                !metadata.file_type().is_symlink(),
304                "Dev/E2E workdir must not redirect to another environment"
305            ),
306            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
307            Err(error) => return Err(error.into()),
308        }
309    }
310    Ok(workdir)
311}
312
313/// Compatibility helper for callers that only select production or development.
314pub fn resolve_workdir(dev: bool) -> PathBuf {
315    resolve_runtime_workdir(
316        None,
317        if dev {
318            RuntimeEnv::Dev
319        } else {
320            RuntimeEnv::Prod
321        },
322    )
323    .expect("failed to resolve default runtime workdir")
324}
325
326pub fn default_runtime_root() -> anyhow::Result<PathBuf> {
327    #[cfg(windows)]
328    {
329        let local_app_data = std::env::var_os("LOCALAPPDATA")
330            .filter(|value| !value.is_empty())
331            .ok_or_else(|| anyhow::anyhow!("LOCALAPPDATA is not set"))?;
332        return Ok(PathBuf::from(local_app_data).join(".meow"));
333    }
334
335    #[cfg(not(windows))]
336    {
337        let home = home_dir().ok_or_else(|| anyhow::anyhow!("HOME is not set"))?;
338        Ok(home.join(".meow"))
339    }
340}
341
342#[cfg(not(windows))]
343fn home_dir() -> Option<PathBuf> {
344    std::env::var_os("HOME")
345        .filter(|value| !value.is_empty())
346        .map(PathBuf::from)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn service_log_paths_use_standard_log_names() {
355        let paths = RuntimePaths::for_workdir("/tmp/meow-runtime-test");
356
357        assert_eq!(
358            paths.service_log_path("camera"),
359            PathBuf::from("/tmp/meow-runtime-test/log/node-camera.log")
360        );
361        assert_eq!(
362            paths.service_log_path("node-camera"),
363            PathBuf::from("/tmp/meow-runtime-test/log/node-camera.log")
364        );
365        assert_eq!(
366            paths.service_log_path("meowcore"),
367            PathBuf::from("/tmp/meow-runtime-test/log/meow-core.log")
368        );
369    }
370
371    #[test]
372    fn tmp_root_is_workdir_scoped() {
373        let paths = RuntimePaths::for_workdir("/tmp/meow-runtime-test");
374        assert_eq!(
375            paths.tmp_root(),
376            PathBuf::from("/tmp/meow-runtime-test/tmp")
377        );
378    }
379
380    #[cfg(not(windows))]
381    #[test]
382    fn default_runtime_root_uses_dot_meow_under_home() {
383        let root = default_runtime_root().unwrap();
384        assert_eq!(
385            root.file_name().and_then(|value| value.to_str()),
386            Some(".meow")
387        );
388    }
389}