mhome-runtime-paths 1.2.0

Runtime paths, local endpoints, and daemon ownership checks for mHome processes
Documentation
mod endpoint;
mod env;
pub use env::RuntimeEnv;
#[cfg(unix)]
mod unix_process;
#[cfg(windows)]
mod windows_process;

use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

pub use endpoint::{
    meowclient_endpoint_label_for_workdir, meowclient_named_pipe_name_for_workdir,
    workdir_identity_hash,
};
#[cfg(unix)]
pub use unix_process::{
    clear_stale_unix_socket, is_pid_alive, process_executable_path, unix_peer_pid,
};
#[cfg(windows)]
pub use windows_process::{is_pid_alive, process_executable_path};

/// Compare process image paths, preferring canonical forms when available.
pub fn paths_match(actual: &Path, expected: &Path) -> bool {
    match (actual.canonicalize(), expected.canonicalize()) {
        (Ok(actual), Ok(expected)) => actual == expected,
        _ => actual == expected,
    }
}

/// True when `pid` is alive and its executable matches `expected_binary`.
pub fn pid_matches_executable(pid: u32, expected_binary: &Path) -> bool {
    if !is_pid_alive(pid) {
        return false;
    }
    match process_executable_path(pid) {
        Some(path) => paths_match(&path, expected_binary),
        None => false,
    }
}

/// If `pid_file` names a live process whose executable matches `expected_binary`,
/// returns its pid. Missing files return `Ok(None)`. Dead processes have their pid
/// file removed and return `Ok(None)`. Live processes with a different executable
/// are left untouched and return `Ok(None)`.
pub fn owned_daemon_pid_from_file(
    pid_file: &Path,
    expected_binary: &Path,
) -> Result<Option<u32>, String> {
    let raw = match fs::read_to_string(pid_file) {
        Ok(value) => value,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(err) => {
            return Err(format!(
                "failed to read daemon pid file {}: {}",
                pid_file.display(),
                err
            ))
        }
    };
    let pid = raw
        .trim()
        .parse::<u32>()
        .map_err(|err| format!("invalid daemon pid in {}: {}", pid_file.display(), err))?;

    if !is_pid_alive(pid) {
        match fs::remove_file(pid_file) {
            Ok(()) => {}
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
            Err(err) => {
                return Err(format!(
                    "failed to remove stale daemon pid file {}: {}",
                    pid_file.display(),
                    err
                ))
            }
        }
        return Ok(None);
    }
    if !pid_matches_executable(pid, expected_binary) {
        return Ok(None);
    }
    Ok(Some(pid))
}

/// Convenience wrapper: verify the default meowclient pid file against `expected_binary`.
pub fn owned_meowclient_pid(expected_binary: &Path) -> Result<Option<u32>, String> {
    owned_daemon_pid_from_file(&runtime_paths().meowclient_pid_file(), expected_binary)
}

static RUNTIME_WORKDIR: OnceLock<(PathBuf, RuntimeEnv)> = OnceLock::new();

#[derive(Debug, Clone)]
pub struct RuntimePaths {
    workdir: PathBuf,
}

impl RuntimePaths {
    pub fn new(workdir: PathBuf) -> Self {
        Self { workdir }
    }

    pub fn for_workdir(workdir: impl AsRef<Path>) -> Self {
        Self::new(workdir.as_ref().to_path_buf())
    }

    pub fn workdir(&self) -> &Path {
        &self.workdir
    }

    pub fn system_root(&self) -> PathBuf {
        self.workdir.join("system")
    }

    pub fn services_root(&self) -> PathBuf {
        self.service_packages_root()
    }

    pub fn packages_root(&self) -> PathBuf {
        self.workdir.join("packages")
    }

    pub fn service_packages_root(&self) -> PathBuf {
        self.packages_root().join("services")
    }

    pub fn service_package_dir(&self, service_id: &str) -> PathBuf {
        self.service_packages_root().join(service_id)
    }

    pub fn config_root(&self) -> PathBuf {
        self.workdir.join("config")
    }

    pub fn run_root(&self) -> PathBuf {
        self.workdir.join("run")
    }

    pub fn log_root(&self) -> PathBuf {
        self.workdir.join("log")
    }

    pub fn data_root(&self) -> PathBuf {
        self.workdir.join("data")
    }

    pub fn state_root(&self) -> PathBuf {
        self.workdir.join("state")
    }

    pub fn downloads_root(&self) -> PathBuf {
        self.workdir.join("downloads")
    }

    pub fn tools_root(&self) -> PathBuf {
        self.workdir.join("tools")
    }

    pub fn downloads_tools_root(&self) -> PathBuf {
        self.downloads_root().join("tools")
    }

    pub fn artifacts_root(&self) -> PathBuf {
        self.workdir.join("artifacts")
    }

    /// Transient files owned by workdir-scoped framework services.
    pub fn tmp_root(&self) -> PathBuf {
        self.workdir.join("tmp")
    }

    pub fn component_config_path(&self, component_name: &str) -> PathBuf {
        self.config_root().join(format!("{component_name}.yaml"))
    }

    pub fn component_run_dir(&self, component_name: &str) -> PathBuf {
        self.run_root().join(component_name)
    }

    pub fn component_data_dir(&self, component_name: &str) -> PathBuf {
        self.data_root().join(component_name)
    }

    pub fn component_state_dir(&self, component_name: &str) -> PathBuf {
        self.state_root().join(component_name)
    }

    pub fn component_system_dir(&self, component_name: &str) -> PathBuf {
        self.system_root().join(component_name)
    }

    pub fn component_log_path(&self, component_name: &str) -> PathBuf {
        self.log_root().join(format!("{component_name}.log"))
    }

    pub fn service_data_dir(&self, service_id: &str) -> PathBuf {
        self.data_root().join("services").join(service_id)
    }

    pub fn service_run_dir(&self, service_id: &str) -> PathBuf {
        self.run_root().join("services").join(service_id)
    }

    pub fn service_log_path(&self, service_id: &str) -> PathBuf {
        self.log_root()
            .join(format!("{}.log", normalized_service_log_name(service_id)))
    }

    pub fn identity_file_path(&self) -> PathBuf {
        self.system_root().join("device_identity.json")
    }

    pub fn meowclient_socket_path(&self) -> PathBuf {
        self.component_run_dir("meowclient").join("meowd.sock")
    }

    pub fn meowclient_pid_file(&self) -> PathBuf {
        self.component_run_dir("meowclient").join("meowd.pid")
    }

    pub fn meowclient_ipc_token_file(&self) -> PathBuf {
        self.component_run_dir("meowclient").join("meowd.ipc.token")
    }

    pub fn meowclient_named_pipe_name(&self) -> String {
        meowclient_named_pipe_name_for_workdir(&self.workdir)
    }

    pub fn meowclient_endpoint_label(&self) -> String {
        meowclient_endpoint_label_for_workdir(&self.workdir)
    }
}

fn normalized_service_log_name(service_id: &str) -> String {
    match service_id {
        "meowcore" | "meow-core" => "meow-core".to_string(),
        value if value.starts_with("node-") => value.to_string(),
        value => format!("node-{value}"),
    }
}

/// Initialize an embedded production runtime with an explicit workdir.
/// Non-production callers must use `set_runtime_context` with their startup env.
pub fn set_runtime_workdir(workdir: PathBuf) -> anyhow::Result<()> {
    set_runtime_context(workdir, RuntimeEnv::Prod)
}

/// Freeze the environment and workdir supplied by the owning startup entrypoint.
/// This does not consult HOME, build profiles, configuration, or directory names.
pub fn set_runtime_context(workdir: PathBuf, env: RuntimeEnv) -> anyhow::Result<()> {
    anyhow::ensure!(workdir.is_absolute(), "runtime workdir must be absolute");
    match RUNTIME_WORKDIR.get() {
        Some((existing, existing_env)) if existing == &workdir && *existing_env == env => Ok(()),
        Some((existing, _)) => Err(anyhow::anyhow!(
            "runtime workdir/environment already set to {}, cannot change to {}",
            existing.display(),
            workdir.display()
        )),
        None => RUNTIME_WORKDIR
            .set((workdir, env))
            .map_err(|_| anyhow::anyhow!("failed to set runtime workdir")),
    }
}

pub fn runtime_workdir() -> PathBuf {
    RUNTIME_WORKDIR
        .get()
        .expect("runtime workdir is not initialized at process startup")
        .0
        .clone()
}

/// Libraries used before runtime startup retain the historical production default.
/// Running processes set their immutable workdir before accepting any requests.
pub fn runtime_env() -> RuntimeEnv {
    RUNTIME_WORKDIR
        .get()
        .map(|(_, env)| *env)
        .unwrap_or_default()
}

pub fn runtime_paths() -> RuntimePaths {
    RuntimePaths::new(runtime_workdir())
}

/// Resolve the runtime workdir from an explicit base home and startup env.
/// The platform user home is needed only when no runtime home was supplied.
pub fn resolve_runtime_workdir(home: Option<&Path>, env: RuntimeEnv) -> anyhow::Result<PathBuf> {
    let home = match home {
        Some(home) => home.to_path_buf(),
        None => default_runtime_root()?,
    };
    anyhow::ensure!(home.is_absolute(), "runtime home must be absolute");
    let workdir = if env.is_prod() {
        home.clone()
    } else {
        home.join(env.as_str())
    };
    if !env.is_prod() {
        match std::fs::symlink_metadata(&workdir) {
            Ok(metadata) => anyhow::ensure!(
                !metadata.file_type().is_symlink(),
                "Dev/E2E workdir must not redirect to another environment"
            ),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(error.into()),
        }
    }
    Ok(workdir)
}

/// Compatibility helper for callers that only select production or development.
pub fn resolve_workdir(dev: bool) -> PathBuf {
    resolve_runtime_workdir(
        None,
        if dev {
            RuntimeEnv::Dev
        } else {
            RuntimeEnv::Prod
        },
    )
    .expect("failed to resolve default runtime workdir")
}

pub fn default_runtime_root() -> anyhow::Result<PathBuf> {
    #[cfg(windows)]
    {
        let local_app_data = std::env::var_os("LOCALAPPDATA")
            .filter(|value| !value.is_empty())
            .ok_or_else(|| anyhow::anyhow!("LOCALAPPDATA is not set"))?;
        return Ok(PathBuf::from(local_app_data).join(".meow"));
    }

    #[cfg(not(windows))]
    {
        let home = home_dir().ok_or_else(|| anyhow::anyhow!("HOME is not set"))?;
        Ok(home.join(".meow"))
    }
}

#[cfg(not(windows))]
fn home_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn service_log_paths_use_standard_log_names() {
        let paths = RuntimePaths::for_workdir("/tmp/meow-runtime-test");

        assert_eq!(
            paths.service_log_path("camera"),
            PathBuf::from("/tmp/meow-runtime-test/log/node-camera.log")
        );
        assert_eq!(
            paths.service_log_path("node-camera"),
            PathBuf::from("/tmp/meow-runtime-test/log/node-camera.log")
        );
        assert_eq!(
            paths.service_log_path("meowcore"),
            PathBuf::from("/tmp/meow-runtime-test/log/meow-core.log")
        );
    }

    #[test]
    fn tmp_root_is_workdir_scoped() {
        let paths = RuntimePaths::for_workdir("/tmp/meow-runtime-test");
        assert_eq!(
            paths.tmp_root(),
            PathBuf::from("/tmp/meow-runtime-test/tmp")
        );
    }

    #[cfg(not(windows))]
    #[test]
    fn default_runtime_root_uses_dot_meow_under_home() {
        let root = default_runtime_root().unwrap();
        assert_eq!(
            root.file_name().and_then(|value| value.to_str()),
            Some(".meow")
        );
    }
}