openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! Staging of the `openlatch-hook` binary into its canonical install location.
//!
//! [`resolve_hook_binary_path`] documents `<ol_dir>/bin/openlatch-hook[.exe]`
//! as "the canonical install location populated by `openlatch init` on the
//! first run". Nothing populated it: the only staging in the tree lived inside
//! `doctor --fix`, so a machine whose running `openlatch` had no
//! `openlatch-hook` sibling got the resolver's last resort — the bare string
//! `"openlatch-hook"` — written into all 12 hook entries. When that name is not
//! on the agent's `PATH`, every hook on the machine dies with exit 127, and the
//! only signal is the agent's own error banner, because the hook fails open.
//!
//! This module is the one owner of "put the binary where the hook command will
//! look for it", shared by `init` (before it writes any hook) and
//! `doctor --fix` (repairing an install that predates that guarantee).
//!
//! [`resolve_hook_binary_path`]: super::resolve_hook_binary_path

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

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

/// Platform-correct file name of the hook binary.
pub fn hook_bin_name() -> &'static str {
    if cfg!(windows) {
        "openlatch-hook.exe"
    } else {
        "openlatch-hook"
    }
}

/// What [`stage_hook_binary`] did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StageOutcome {
    /// The canonical path already held a binary; nothing was copied.
    AlreadyStaged {
        /// The canonical path.
        target: PathBuf,
    },
    /// A source binary was copied into the canonical path.
    Staged {
        /// The canonical path, now populated.
        target: PathBuf,
        /// Where the bytes came from.
        source: PathBuf,
    },
}

impl StageOutcome {
    /// The canonical path, populated either way.
    pub fn target(&self) -> &Path {
        match self {
            StageOutcome::AlreadyStaged { target } | StageOutcome::Staged { target, .. } => target,
        }
    }
}

/// Ensure `<ol_dir>/bin/openlatch-hook[.exe]` exists, copying a locatable
/// source binary into it when it does not.
///
/// On Unix the staged copy is `chmod 0755`'d — a copy that cannot be executed
/// is the same outage as a copy that does not exist.
///
/// # Errors
///
/// Returns [`ERR_HOOK_BINARY_UNRESOLVABLE`] when no source binary can be found,
/// or when the copy itself fails. Callers decide what that means: `init` treats
/// it as fatal (an install that cannot resolve its own hook binary is not a
/// successful install), `doctor --fix` records no action and lets the post-fix
/// diagnostics surface the missing-binary check.
pub fn stage_hook_binary(ol_dir: &Path) -> Result<StageOutcome, OlError> {
    let bin_name = hook_bin_name();
    let target_dir = ol_dir.join("bin");
    let target = target_dir.join(bin_name);

    if target.exists() {
        return Ok(StageOutcome::AlreadyStaged { target });
    }

    let Some(source) = locate_hook_source(bin_name) else {
        return Err(OlError::new(
            ERR_HOOK_BINARY_UNRESOLVABLE,
            format!(
                "Cannot locate an '{bin_name}' binary to install into {}",
                target_dir.display()
            ),
        )
        .with_suggestion(format!(
            "Reinstall OpenLatch so '{bin_name}' sits next to the 'openlatch' binary, \
             or point OPENLATCH_HOOK_BIN at an existing one."
        )));
    };

    std::fs::create_dir_all(&target_dir).map_err(|e| {
        OlError::new(
            ERR_HOOK_BINARY_UNRESOLVABLE,
            format!("Cannot create '{}': {e}", target_dir.display()),
        )
        .with_suggestion("Check that you have write permission to the openlatch directory.")
    })?;

    std::fs::copy(&source, &target).map_err(|e| {
        OlError::new(
            ERR_HOOK_BINARY_UNRESOLVABLE,
            format!(
                "Cannot copy '{}' to '{}': {e}",
                source.display(),
                target.display()
            ),
        )
        .with_suggestion("Check that you have write permission to the openlatch directory.")
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
            OlError::new(
                ERR_HOOK_BINARY_UNRESOLVABLE,
                format!("Cannot make '{}' executable: {e}", target.display()),
            )
        })?;
    }

    Ok(StageOutcome::Staged { target, source })
}

/// Find a source binary to copy into the canonical location.
///
/// Deliberately does NOT call [`resolve_hook_binary_path`]: that helper falls
/// back to the canonical `<ol_dir>/bin/...` path itself — the very path we are
/// trying to populate — and then to a bare name that is not a file at all. This
/// walks the same precedence chain minus those two steps, and every candidate
/// must exist on disk.
///
/// [`resolve_hook_binary_path`]: super::resolve_hook_binary_path
fn locate_hook_source(bin_name: &str) -> Option<PathBuf> {
    if let Ok(override_path) = std::env::var("OPENLATCH_HOOK_BIN") {
        if !override_path.is_empty() {
            let p = PathBuf::from(override_path);
            if p.is_file() {
                return Some(p);
            }
        }
    }
    let current_exe = std::env::current_exe().ok()?;
    let candidate = current_exe.parent()?.join(bin_name);
    candidate.is_file().then_some(candidate)
}

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

    /// `cargo test` runs every test in ONE process, so two tests mutating
    /// `OPENLATCH_HOOK_BIN` clobber each other. Same pattern as
    /// `cli::commands::doctor_fix`.
    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    /// Run `f` with `OPENLATCH_HOOK_BIN` set to `value`, restoring whatever was
    /// there before.
    fn with_hook_bin_env<T>(value: &str, f: impl FnOnce() -> T) -> T {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let prev = std::env::var("OPENLATCH_HOOK_BIN").ok();
        std::env::set_var("OPENLATCH_HOOK_BIN", value);
        let out = f();
        match prev {
            Some(p) => std::env::set_var("OPENLATCH_HOOK_BIN", p),
            None => std::env::remove_var("OPENLATCH_HOOK_BIN"),
        }
        out
    }

    /// Whether the exe-sibling branch can match in this test run. The test
    /// binary often sits in the same `target/` directory as a real
    /// `openlatch-hook` artifact, in which case staging correctly succeeds
    /// from it and the "no source" path is unreachable.
    fn sibling_hook_exists() -> bool {
        std::env::current_exe()
            .ok()
            .and_then(|e| e.parent().map(|d| d.join(hook_bin_name())))
            .is_some_and(|p| p.is_file())
    }

    /// A populated canonical path is left exactly as it is — staging must be
    /// idempotent so `doctor --fix` can run repeatedly without churn.
    #[test]
    fn already_staged_is_a_noop() {
        let tmp = TempDir::new().unwrap();
        let bin_dir = tmp.path().join("bin");
        std::fs::create_dir_all(&bin_dir).unwrap();
        let target = bin_dir.join(hook_bin_name());
        std::fs::write(&target, b"existing").unwrap();

        let outcome = stage_hook_binary(tmp.path()).unwrap();

        assert_eq!(outcome, StageOutcome::AlreadyStaged { target });
        assert_eq!(std::fs::read(outcome.target()).unwrap(), b"existing");
    }

    /// The whole point of #165: when nothing can be staged we say so, instead
    /// of letting the caller write a hook command that cannot resolve.
    ///
    /// The env override is pointed at a path that does not exist so the env
    /// branch is exercised and rejected. Whether the exe-sibling branch can
    /// then match depends on the layout of `target/`, so the assertion forks
    /// on that rather than pretending the environment is controlled.
    #[test]
    fn missing_source_is_an_error_not_a_silent_skip() {
        let tmp = TempDir::new().unwrap();
        let missing = tmp.path().join("nowhere").join(hook_bin_name());
        let result = with_hook_bin_env(&missing.to_string_lossy(), || {
            (stage_hook_binary(tmp.path()), sibling_hook_exists())
        });

        match result {
            (Err(e), false) => {
                assert_eq!(e.code, ERR_HOOK_BINARY_UNRESOLVABLE);
                assert!(!tmp.path().join("bin").join(hook_bin_name()).exists());
            }
            (Ok(outcome), true) => {
                // Staged from the sibling — still never outside the tempdir.
                assert!(outcome.target().starts_with(tmp.path()));
            }
            (other, sibling) => {
                panic!("unexpected staging result {other:?} with sibling_hook_exists={sibling}")
            }
        }
    }

    /// `OPENLATCH_HOOK_BIN` is the documented escape hatch for installs whose
    /// layout we do not control; the staged copy must be executable.
    #[test]
    fn stages_from_the_env_override() {
        let tmp = TempDir::new().unwrap();
        let source = tmp.path().join("source-hook");
        std::fs::write(&source, b"#!/bin/sh\nexit 0\n").unwrap();

        let outcome = with_hook_bin_env(&source.to_string_lossy(), || {
            stage_hook_binary(tmp.path()).unwrap()
        });

        let target = tmp.path().join("bin").join(hook_bin_name());
        assert_eq!(outcome, StageOutcome::Staged { target, source });
        assert!(outcome.target().is_file());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(outcome.target())
                .unwrap()
                .permissions()
                .mode();
            assert_eq!(mode & 0o111, 0o111, "staged copy must be executable");
        }
    }
}