openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! `~/.openlatch/install-state.json` reader/writer.
//!
//! Surfaces the install posture of the local binary so `openlatch status`
//! and `openlatch doctor` can detect drift between what npm last
//! installed (`npm_reported_version`) and what the auto-update path
//! actually swapped in (`actual_binary_version`). The file is the only
//! durable state that survives a daemon swap — the new binary reads it
//! at startup and updates it after a successful post-restart healthz
//! probe.
//!
//! See `.local/brainstorms/auto-update/PHASE-2-manual-via-rpc.md` § 6.
//!
//! ## Architectural note
//!
//! This module is a `core/` leaf — like `config`, `error`, `privacy` —
//! and therefore must not import from other `core/` leaves (rule:
//! `architecture.md`). The cargo-install / npm / manual heuristic lives
//! here rather than in `core/update.rs` because both `update`'s apply
//! pipeline and `cli/commands/{status,doctor}` need it; consumers
//! compose the two leaves at the call site.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::error::{OlError, ERR_INVALID_CONFIG};

/// How the local binary was installed. Used to gate the auto-update
/// pipeline (`cargo install` users are pointed at the manual
/// `cargo install --force` recovery path) and to surface npm-vs-actual
/// drift in `openlatch status`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum InstallMethod {
    /// Installed via `npm install -g @openlatch/client` (the supported
    /// auto-update path). Detected by walking parent directories until
    /// a `package.json` whose `name` starts with `@openlatch/client` is
    /// found alongside the binary.
    Npm,
    /// Installed via `cargo install openlatch-client`. Detected by
    /// `current_exe()` living under `~/.cargo/bin/` (Unix) or
    /// `%CARGO_HOME%\bin\` / `%USERPROFILE%\.cargo\bin\` (Windows). The
    /// auto-update pipeline refuses this install method — `cargo install`
    /// users must run `cargo install --force --locked openlatch-client`
    /// to upgrade.
    CargoInstall,
    /// Installed by hand (curl + chmod, or copy to /usr/local/bin).
    /// Auto-update will succeed for this case but `npm_reported_version`
    /// stays absent.
    Manual,
    /// Heuristic could not classify the install. Treated like `Manual`
    /// for apply purposes; surfaced in `doctor` so the user knows the
    /// drift detector won't fire.
    #[default]
    Unknown,
}

/// Persisted on-disk schema for `~/.openlatch/install-state.json`.
///
/// Every field is optional in JSON so partial writes (older releases
/// that didn't know about a field, or a half-rolled-out daemon) parse
/// cleanly — the auto-update worker repopulates missing fields on the
/// next successful apply.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "snake_case")]
pub struct InstallState {
    /// How the binary was installed.
    pub install_method: InstallMethod,
    /// Resolved absolute path of the running binary at the time of
    /// last write. Useful when debugging "the wrong binary is on PATH".
    pub install_path: Option<PathBuf>,
    /// Version that npm last installed, read once from the platform
    /// package's `package.json`. Stays pinned across auto-updates so
    /// drift can be detected. Only populated when `install_method ==
    /// Npm`.
    pub npm_reported_version: Option<String>,
    /// Version of the binary actually running, written every time the
    /// auto-update path completes a successful swap. May exceed
    /// `npm_reported_version` between an auto-update and the next
    /// `npm install -g`.
    pub actual_binary_version: Option<String>,
    /// RFC 3339 UTC timestamp of the last successful apply.
    pub last_updated_at: Option<String>,
    /// RFC 3339 UTC timestamp of the last manifest probe (regardless of
    /// outcome). Lets `doctor` warn if the auto-update worker has gone
    /// silent.
    pub last_check_at: Option<String>,
}

impl InstallState {
    /// Path of the install-state file inside `openlatch_dir()`.
    pub fn path() -> PathBuf {
        Self::path_in(&crate::config::openlatch_dir())
    }

    /// Path of the install-state file inside `dir`. Used by tests so
    /// they can avoid the racy `OPENLATCH_DIR` env var.
    pub fn path_in(dir: &Path) -> PathBuf {
        dir.join("install-state.json")
    }

    /// Read the file if it exists, returning a fresh default otherwise.
    /// Parse errors fall back to a default value (with a warning logged)
    /// — corrupt state must never block the binary from starting.
    pub fn load_or_default() -> Self {
        Self::load_from(&Self::path())
    }

    /// Same as [`load_or_default`] but reads from a specific path. Used
    /// by tests and by callers that need to read the file from a
    /// non-default location (e.g. a chroot or alt OPENLATCH_DIR).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        match serde_json::from_str::<Self>(&raw) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(target: "update", error = %e, path = %path.display(), "install-state.json malformed; falling back to default");
                Self::default()
            }
        }
    }

    /// Pretty-print the state as JSON and write atomically (temp +
    /// rename) so a crash mid-write never leaves the file truncated.
    pub fn save(&self) -> Result<(), OlError> {
        self.save_to(&Self::path())
    }

    /// Same as [`save`] but writes to a specific path.
    pub fn save_to(&self, path: &Path) -> Result<(), OlError> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    ERR_INVALID_CONFIG,
                    format!("create install-state dir {}: {e}", parent.display()),
                )
            })?;
        }
        let body = serde_json::to_string_pretty(self).map_err(|e| {
            OlError::new(ERR_INVALID_CONFIG, format!("serialise install-state: {e}"))
        })?;
        let tmp = path.with_extension("json.tmp");
        std::fs::write(&tmp, body).map_err(|e| {
            OlError::new(
                ERR_INVALID_CONFIG,
                format!("write install-state tmp {}: {e}", tmp.display()),
            )
        })?;
        std::fs::rename(&tmp, path).map_err(|e| {
            OlError::new(
                ERR_INVALID_CONFIG,
                format!(
                    "rename install-state {} -> {}: {e}",
                    tmp.display(),
                    path.display()
                ),
            )
        })?;
        Ok(())
    }

    /// Stamp `actual_binary_version` and `last_updated_at` after a
    /// successful swap.
    pub fn record_applied(&mut self, version: &str) {
        self.actual_binary_version = Some(version.to_string());
        self.last_updated_at = Some(now_rfc3339());
    }

    /// Stamp `last_check_at` regardless of outcome.
    pub fn record_check(&mut self) {
        self.last_check_at = Some(now_rfc3339());
    }

    /// Refresh + persist install state to reflect the binary that's
    /// actually running right now: detected install method, current
    /// `current_exe()`, and a `record_applied` + `record_check` stamp
    /// using the supplied version string. Used both by the in-process
    /// `apply_local` (just-swapped CLI) and by the daemon's
    /// post-restart healthz pickup (newly-execv'd binary).
    pub fn stamp_for_running_binary(version: &str) {
        let mut s = Self::load_or_default();
        s.install_method = detect_install_method();
        s.install_path = std::env::current_exe().ok();
        s.record_applied(version);
        s.record_check();
        if let Err(e) = s.save() {
            tracing::warn!(target: "update", error = %e.message, "failed to write install-state.json");
        }
    }
}

/// RFC 3339 UTC timestamp with millisecond precision — the same format
/// the rest of the daemon uses (envelope `time`, etc.).
pub fn now_rfc3339() -> String {
    use chrono::SecondsFormat;
    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}

/// Heuristic-detect how the running binary was installed.
///
/// Resolution order:
///
/// 1. `current_exe()` under a directory ending in `.cargo/bin` →
///    [`InstallMethod::CargoInstall`].
/// 2. A parent directory contains a `package.json` whose `name` field
///    starts with `@openlatch/client` → [`InstallMethod::Npm`].
/// 3. Anything else → [`InstallMethod::Manual`].
/// 4. `current_exe()` itself failed → [`InstallMethod::Unknown`].
pub fn detect_install_method() -> InstallMethod {
    let Ok(exe) = std::env::current_exe() else {
        return InstallMethod::Unknown;
    };
    detect_install_method_for_path(&exe)
}

/// Same as [`detect_install_method`] but takes the binary path as an
/// argument — used for unit testing without mocking `current_exe()`.
pub fn detect_install_method_for_path(exe: &Path) -> InstallMethod {
    if is_under_cargo_bin(exe) {
        return InstallMethod::CargoInstall;
    }
    if has_npm_package_json(exe) {
        return InstallMethod::Npm;
    }
    InstallMethod::Manual
}

fn is_under_cargo_bin(exe: &Path) -> bool {
    // Walk parents looking for a `bin` directory whose parent is named
    // `.cargo`. Cross-platform — works for `~/.cargo/bin/openlatch` on
    // Unix and `C:\Users\me\.cargo\bin\openlatch.exe` on Windows.
    let mut p = exe;
    while let Some(parent) = p.parent() {
        if parent.file_name().and_then(|s| s.to_str()) == Some("bin") {
            if let Some(grand) = parent.parent() {
                if grand.file_name().and_then(|s| s.to_str()) == Some(".cargo") {
                    return true;
                }
            }
        }
        p = parent;
    }
    false
}

fn has_npm_package_json(exe: &Path) -> bool {
    // Walk up to ~5 levels looking for a sibling `package.json` whose
    // `name` starts with `@openlatch/client`. The npm install layout
    // typically places the binary at
    // `<prefix>/lib/node_modules/@openlatch/client-<plat>/openlatch[.exe]`,
    // so the package.json sits one directory up.
    let mut depth = 0;
    let mut cursor = exe.parent();
    while let Some(dir) = cursor {
        if depth > 5 {
            break;
        }
        let pkg = dir.join("package.json");
        if pkg.is_file() {
            if let Ok(raw) = std::fs::read_to_string(&pkg) {
                if let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) {
                    if let Some(name) = value.get("name").and_then(|v| v.as_str()) {
                        if name.starts_with("@openlatch/client") {
                            return true;
                        }
                    }
                }
            }
        }
        cursor = dir.parent();
        depth += 1;
    }
    false
}

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

    #[test]
    fn install_method_detects_cargo_bin() {
        // Unix-style path
        let exe = PathBuf::from("/home/me/.cargo/bin/openlatch");
        assert_eq!(
            detect_install_method_for_path(&exe),
            InstallMethod::CargoInstall
        );
    }

    #[test]
    fn install_method_detects_cargo_bin_windows_style() {
        // Windows-style path — only meaningful on Windows where backslashes
        // are path separators. On other OSes Path treats them as a single
        // segment, so skip.
        if cfg!(windows) {
            let exe = PathBuf::from(r"C:\Users\me\.cargo\bin\openlatch.exe");
            assert_eq!(
                detect_install_method_for_path(&exe),
                InstallMethod::CargoInstall
            );
        }
    }

    #[test]
    fn install_method_detects_npm_package() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("package.json"),
            r#"{"name":"@openlatch/client-linux-x64","version":"0.1.14"}"#,
        )
        .unwrap();
        let exe = dir.path().join(if cfg!(windows) {
            "openlatch.exe"
        } else {
            "openlatch"
        });
        std::fs::write(&exe, b"binary").unwrap();
        assert_eq!(detect_install_method_for_path(&exe), InstallMethod::Npm);
    }

    #[test]
    fn install_method_falls_back_to_manual_for_arbitrary_path() {
        let dir = tempfile::tempdir().unwrap();
        let exe = dir.path().join("openlatch");
        std::fs::write(&exe, b"binary").unwrap();
        assert_eq!(detect_install_method_for_path(&exe), InstallMethod::Manual);
    }

    #[test]
    fn install_state_round_trip_serialises_kebab_case() {
        // The on-disk install_method values must be kebab-case so an
        // operator hand-editing install-state.json doesn't accidentally
        // break the parser by writing PascalCase.
        let s = InstallState {
            install_method: InstallMethod::CargoInstall,
            ..InstallState::default()
        };
        let json = serde_json::to_string(&s).unwrap();
        assert!(json.contains("\"cargo-install\""), "got {json}");
        let back: InstallState = serde_json::from_str(&json).unwrap();
        assert_eq!(back.install_method, InstallMethod::CargoInstall);
    }

    #[test]
    fn install_state_record_applied_stamps_version_and_timestamp() {
        let mut s = InstallState::default();
        s.record_applied("0.1.16");
        assert_eq!(s.actual_binary_version.as_deref(), Some("0.1.16"));
        let ts = s.last_updated_at.expect("timestamp must be set");
        // RFC 3339 UTC ends in `Z` per `to_rfc3339_opts(_, true)`.
        assert!(ts.ends_with('Z'), "got {ts}");
    }

    #[test]
    fn install_state_load_from_returns_default_when_file_absent() {
        let dir = tempfile::tempdir().unwrap();
        let s = InstallState::load_from(&InstallState::path_in(dir.path()));
        assert!(matches!(s.install_method, InstallMethod::Unknown));
        assert!(s.actual_binary_version.is_none());
    }

    #[test]
    fn install_state_save_then_load_round_trip() {
        // Use the directory-aware helpers to avoid global env-var state
        // and the parallel-test races it brings.
        let dir = tempfile::tempdir().unwrap();
        let path = InstallState::path_in(dir.path());
        let mut s = InstallState {
            install_method: InstallMethod::Npm,
            npm_reported_version: Some("0.1.14".into()),
            ..InstallState::default()
        };
        s.record_applied("0.1.16");
        s.save_to(&path).unwrap();
        let back = InstallState::load_from(&path);
        assert_eq!(back.install_method, InstallMethod::Npm);
        assert_eq!(back.npm_reported_version.as_deref(), Some("0.1.14"));
        assert_eq!(back.actual_binary_version.as_deref(), Some("0.1.16"));
    }
}