openlatch-client 0.6.1

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! One marker-guarded line in the developer's shell profiles that sources the env file
//! ([`crate::hooks::proxy_env_file`]) when it exists. Install-scoped: written on a `ProxyEnv`
//! wire by the daemon under the machine-global gate, removed by whole-product uninstall (plan
//! 04). Stop, restart and a failed preflight never touch it: the line is inert while `env.sh` is
//! absent.
//!
//! Explicit `home` / `documents` parameters so no test here reads `dirs::home_dir()`. fish is
//! out (D-27).

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

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

pub const MARKER: &str = "# openlatch-model-relay";

/// EXACT: `if [ -f '<env.sh>' ]; then . '<env.sh>'; fi  # openlatch-model-relay`
pub fn posix_line(env_sh: &Path) -> String {
    let p = env_sh.display();
    format!("if [ -f '{p}' ]; then . '{p}'; fi  {MARKER}")
}

/// EXACT: `if (Test-Path -LiteralPath '<env.ps1>') { . '<env.ps1>' }  # openlatch-model-relay`
pub fn powershell_line(env_ps1: &Path) -> String {
    let p = env_ps1.display();
    format!("if (Test-Path -LiteralPath '{p}') {{ . '{p}' }}  {MARKER}")
}

fn io_err(e: impl std::fmt::Display) -> OlError {
    OlError::new(ERR_MODEL_RELAY_IO, e.to_string())
}

/// Replace an existing marker line's span in place — preserving that line's own end-of-line
/// convention and every other byte in the file — or, with none found, ensure the file ends in
/// `\n` and append `line\n`. Returns whether the file changed.
fn ensure_line_in_file(path: &Path, line: &str) -> Result<bool, OlError> {
    let existing = std::fs::read_to_string(path).unwrap_or_default();

    let mut offset = 0usize;
    let mut found: Option<(usize, usize, &str)> = None;
    for raw_line in existing.split_inclusive('\n') {
        let content = raw_line.strip_suffix('\n').unwrap_or(raw_line);
        let content = content.strip_suffix('\r').unwrap_or(content);
        if content.ends_with(MARKER) {
            let ending = if raw_line.ends_with("\r\n") {
                "\r\n"
            } else if raw_line.ends_with('\n') {
                "\n"
            } else {
                ""
            };
            found = Some((offset, offset + raw_line.len(), ending));
            break;
        }
        offset += raw_line.len();
    }

    let new_content = match found {
        Some((start, end, ending)) => {
            if existing[start..end] == *format!("{line}{ending}") {
                return Ok(false);
            }
            format!("{}{line}{ending}{}", &existing[..start], &existing[end..])
        }
        None => {
            let mut out = existing.clone();
            if !out.is_empty() && !out.ends_with('\n') {
                out.push('\n');
            }
            out.push_str(line);
            out.push('\n');
            out
        }
    };
    if new_content == existing {
        return Ok(false);
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(io_err)?;
    }
    crate::fs_secure::write_preserving_mode(path, new_content.as_bytes()).map_err(io_err)?;
    Ok(true)
}

fn remove_marker_lines(path: &Path) -> Result<bool, OlError> {
    let existing = std::fs::read_to_string(path).map_err(io_err)?;
    let mut out = String::with_capacity(existing.len());
    let mut changed = false;
    for raw_line in existing.split_inclusive('\n') {
        let content = raw_line.strip_suffix('\n').unwrap_or(raw_line);
        let content = content.strip_suffix('\r').unwrap_or(content);
        if content.ends_with(MARKER) {
            changed = true;
            continue;
        }
        out.push_str(raw_line);
    }
    if !changed {
        return Ok(false);
    }
    crate::fs_secure::write_preserving_mode(path, out.as_bytes()).map_err(io_err)?;
    Ok(true)
}

/// The candidate POSIX rc files that exist, or — when none does and `create_posix_rc` — the
/// ONE this install creates, named from `login_shell`'s suffix.
fn posix_candidates(home: &Path, login_shell: Option<&str>, create_posix_rc: bool) -> Vec<PathBuf> {
    let candidates = [
        home.join(".zshrc"),
        home.join(".bashrc"),
        home.join(".bash_profile"),
        home.join(".profile"),
    ];
    let existing: Vec<PathBuf> = candidates.iter().filter(|p| p.exists()).cloned().collect();
    if !existing.is_empty() || !create_posix_rc {
        return existing;
    }
    // macOS's default zsh never reads .profile; creating it there would be a line nobody
    // sources. A parameter, not a `cfg!`, so the no-create branch (Windows) is tested on every
    // OS.
    let name = match login_shell {
        Some(s) if s.ends_with("zsh") => ".zshrc",
        Some(s) if s.ends_with("bash") => ".bashrc",
        _ => ".profile",
    };
    vec![home.join(name)]
}

fn powershell_candidates(documents: Option<&Path>) -> Vec<PathBuf> {
    // Never created: client Windows defaults to the Restricted execution policy, and a created
    // profile.ps1 prints a red error on every PowerShell start there.
    match documents {
        None => Vec::new(),
        Some(documents) => [
            documents.join("PowerShell").join("profile.ps1"),
            documents.join("WindowsPowerShell").join("profile.ps1"),
        ]
        .into_iter()
        .filter(|p| p.exists())
        .collect(),
    }
}

pub(crate) fn ensure_line_with(
    home: &Path,
    documents: Option<&Path>,
    env_sh: &Path,
    env_ps1: &Path,
    login_shell: Option<&str>,
    create_posix_rc: bool,
) -> Result<Vec<PathBuf>, OlError> {
    let mut touched = Vec::new();
    let line = posix_line(env_sh);
    for path in posix_candidates(home, login_shell, create_posix_rc) {
        if ensure_line_in_file(&path, &line)? {
            touched.push(path);
        }
    }
    let ps1_line = powershell_line(env_ps1);
    for path in powershell_candidates(documents) {
        if ensure_line_in_file(&path, &ps1_line)? {
            touched.push(path);
        }
    }
    Ok(touched)
}

pub fn ensure_line(
    home: &Path,
    documents: Option<&Path>,
    env_sh: &Path,
    env_ps1: &Path,
) -> Result<Vec<PathBuf>, OlError> {
    ensure_line_with(
        home,
        documents,
        env_sh,
        env_ps1,
        std::env::var("SHELL").ok().as_deref(),
        !cfg!(windows),
    )
}

/// Remove every line ending in [`MARKER`] from the same candidate set (existing files only).
/// Never deletes a file, even one [`ensure_line`] created. Idempotent.
///
/// The same candidate helpers `ensure_line_with` uses: `posix_candidates(home, None, false)`
/// takes the `create_posix_rc = false` branch unconditionally, which returns exactly the existing
/// files among the four rc paths — `login_shell` is never consulted on that branch, so `None`
/// here changes nothing — and never creates one; `powershell_candidates` already filters to
/// existing files. Neither ever invents a path, so no `.exists()` check is needed here.
pub fn remove_line(home: &Path, documents: Option<&Path>) -> Result<Vec<PathBuf>, OlError> {
    let mut touched = Vec::new();
    for path in posix_candidates(home, None, false)
        .into_iter()
        .chain(powershell_candidates(documents))
    {
        if remove_marker_lines(&path)? {
            touched.push(path);
        }
    }
    Ok(touched)
}

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

    #[test]
    fn posix_line_is_exact() {
        let env_sh = Path::new("/OL/model-relay/env.sh");
        assert_eq!(
            posix_line(env_sh),
            "if [ -f '/OL/model-relay/env.sh' ]; then . '/OL/model-relay/env.sh'; fi  \
             # openlatch-model-relay"
        );
    }

    #[test]
    fn powershell_line_is_exact() {
        let env_ps1 = Path::new("C:\\OL\\model-relay\\env.ps1");
        assert_eq!(
            powershell_line(env_ps1),
            "if (Test-Path -LiteralPath 'C:\\OL\\model-relay\\env.ps1') { . \
             'C:\\OL\\model-relay\\env.ps1' }  # openlatch-model-relay"
        );
    }

    #[test]
    fn the_line_is_added_once_to_existing_files_only() {
        let dir = tempfile::tempdir().expect("tempdir");
        let home = dir.path();
        let zshrc_seed = "export PATH=/usr/local/bin:$PATH\n";
        let bashrc_seed = "alias ll='ls -la'\n";
        std::fs::write(home.join(".zshrc"), zshrc_seed).expect("seed zshrc");
        std::fs::write(home.join(".bashrc"), bashrc_seed).expect("seed bashrc");
        let env_sh = home.join("ol").join("env.sh");
        let env_ps1 = home.join("ol").join("env.ps1");

        let touched = ensure_line_with(home, None, &env_sh, &env_ps1, None, true).expect("ensure");
        assert_eq!(touched, vec![home.join(".zshrc"), home.join(".bashrc")]);
        assert!(
            !home.join(".profile").exists(),
            ".bash_profile/.profile must not be created when .zshrc and .bashrc exist"
        );
        assert!(!home.join(".bash_profile").exists());

        let zshrc_after = std::fs::read_to_string(home.join(".zshrc")).unwrap();
        assert_eq!(
            zshrc_after,
            format!("{zshrc_seed}{}\n", posix_line(&env_sh)),
            "the customer's line is byte-identical, ours is appended after it"
        );
        let bashrc_after = std::fs::read_to_string(home.join(".bashrc")).unwrap();
        assert_eq!(
            bashrc_after,
            format!("{bashrc_seed}{}\n", posix_line(&env_sh))
        );

        let touched2 =
            ensure_line_with(home, None, &env_sh, &env_ps1, None, true).expect("second call");
        assert!(touched2.is_empty(), "a second call must change nothing");
        assert_eq!(
            std::fs::read_to_string(home.join(".zshrc")).unwrap(),
            zshrc_after
        );
        assert_eq!(
            std::fs::read_to_string(home.join(".bashrc")).unwrap(),
            bashrc_after
        );
    }

    #[test]
    fn the_login_shells_rc_is_created_when_none_exists() {
        let dir = tempfile::tempdir().expect("tempdir");
        let home = dir.path();
        let env_sh = home.join("ol").join("env.sh");
        let env_ps1 = home.join("ol").join("env.ps1");

        let touched =
            ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/zsh"), true).expect("zsh");
        assert_eq!(touched, vec![home.join(".zshrc")]);
        std::fs::remove_file(home.join(".zshrc")).unwrap();

        let touched =
            ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/bash"), true).expect("bash");
        assert_eq!(touched, vec![home.join(".bashrc")]);
        std::fs::remove_file(home.join(".bashrc")).unwrap();

        let touched =
            ensure_line_with(home, None, &env_sh, &env_ps1, None, true).expect("no login shell");
        assert_eq!(touched, vec![home.join(".profile")]);
        std::fs::remove_file(home.join(".profile")).unwrap();

        // No-create row: `create_posix_rc = false` and no rc file exists → nothing is created.
        let touched = ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/zsh"), false)
            .expect("no create");
        assert!(touched.is_empty(), "the result must hold no POSIX path");
        for name in [".zshrc", ".bashrc", ".bash_profile", ".profile"] {
            assert!(!home.join(name).exists(), "{name} must not be created");
        }

        // An existing `.bashrc`, `create_posix_rc = false`: it gains the line, nothing is
        // created.
        std::fs::write(home.join(".bashrc"), "").unwrap();
        let touched = ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/zsh"), false)
            .expect("existing bashrc, no create");
        assert_eq!(touched, vec![home.join(".bashrc")]);
        assert!(!home.join(".zshrc").exists());
        assert!(!home.join(".profile").exists());
    }

    #[test]
    fn the_line_is_removed_cleanly() {
        let dir = tempfile::tempdir().expect("tempdir");
        let home = dir.path();
        let seed = "export PATH=/usr/local/bin:$PATH\n";
        std::fs::write(home.join(".zshrc"), seed).expect("seed");
        let env_sh = home.join("ol").join("env.sh");
        let env_ps1 = home.join("ol").join("env.ps1");
        ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/zsh"), true).expect("ensure");
        let zshrc = home.join(".zshrc");
        assert_eq!(
            std::fs::read_to_string(&zshrc).unwrap(),
            format!("{seed}{}\n", posix_line(&env_sh))
        );

        let touched = remove_line(home, None).expect("remove");
        assert_eq!(touched, vec![zshrc.clone()]);
        assert!(zshrc.exists(), "the file itself is never deleted");
        assert_eq!(
            std::fs::read_to_string(&zshrc).unwrap(),
            seed,
            "the remainder is byte-identical to the original"
        );

        assert!(remove_line(home, None).expect("remove again").is_empty());
    }

    #[test]
    fn powershell_profiles_are_edited_only_when_they_exist() {
        let dir = tempfile::tempdir().expect("tempdir");
        let home = dir.path();
        let documents = dir.path().join("Documents");
        let ps_dir = documents.join("PowerShell");
        std::fs::create_dir_all(&ps_dir).expect("dir");
        let profile = ps_dir.join("profile.ps1");
        std::fs::write(&profile, "").expect("seed");
        let env_sh = home.join("ol").join("env.sh");
        let env_ps1 = home.join("ol").join("env.ps1");

        let touched = ensure_line_with(home, Some(&documents), &env_sh, &env_ps1, None, false)
            .expect("ensure");
        assert_eq!(touched, vec![profile.clone()]);
        assert!(std::fs::read_to_string(&profile).unwrap().contains(MARKER));
        assert!(
            !documents.join("WindowsPowerShell").exists(),
            "an absent profile must never be created"
        );
    }

    #[test]
    #[cfg(unix)]
    fn the_sourcing_line_is_inert_without_the_env_file() {
        let dir = tempfile::tempdir().expect("tempdir");
        let home = dir.path();
        let profile = home.join(".zshrc");
        // Never created: the whole point is that the sourced line is inert while it is absent.
        let env_sh = home.join("ol").join("env.sh");
        std::fs::write(&profile, format!("{}\n", posix_line(&env_sh))).expect("seed");

        let out = std::process::Command::new("sh")
            .arg("-c")
            .arg(format!(". '{}'; echo ok", profile.display()))
            .output()
            .expect("run sh");
        assert!(
            out.status.success(),
            "stderr: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        assert_eq!(String::from_utf8_lossy(&out.stdout), "ok\n");
    }
}