openlatch-client 0.6.15

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! The user `Path` (`HKCU\Environment\Path`): where the Windows launcher shims' folder is put first
//! (`hooks::cli_shims_install`), and the half of the search path a long-running daemon cannot see
//! change — its own `PATH` froze when it started, so a CLI installed later is found only through a
//! fresh read of this value ([`effective_path`]).
//!
//! Written as `REG_EXPAND_SZ` so the developer's `%USERPROFILE%`-style entries keep expanding, and
//! only by the daemon's wiring pass and `uninstall`, both behind the machine-supervision gate. Every
//! edit is a pure string function here; the OS write sits behind [`PathSink`] so tests record it.

use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};

/// The value's name under `HKCU\Environment`.
pub const NAME: &str = "Path";

/// The user `Path`, read and written whole. `read` answers `Ok(None)` when the value is unset.
pub trait PathSink {
    fn read(&self) -> Result<Option<String>, String>;
    fn write(&self, value: &str) -> Result<(), String>;
}

/// The real `HKCU\Environment\Path`, followed by a `WM_SETTINGCHANGE` broadcast on write.
#[cfg(windows)]
pub struct RegistryPath;

#[cfg(windows)]
impl PathSink for RegistryPath {
    fn read(&self) -> Result<Option<String>, String> {
        crate::core::login_env::registry::read(NAME)
    }
    fn write(&self, value: &str) -> Result<(), String> {
        crate::core::login_env::registry::apply_expand(NAME, value)
    }
}

/// This OS's user `Path`, when it has one (Windows only).
pub fn os_sink() -> Option<Box<dyn PathSink>> {
    #[cfg(windows)]
    {
        Some(Box::new(RegistryPath))
    }
    #[cfg(not(windows))]
    {
        None
    }
}

/// `%NAME%` references replaced through `lookup`; an unknown or unterminated one stays verbatim,
/// as Windows leaves it.
fn expand_with(raw: &str, lookup: &dyn Fn(&str) -> Option<String>) -> String {
    let mut out = String::with_capacity(raw.len());
    let mut rest = raw;
    while let Some(start) = rest.find('%') {
        out.push_str(&rest[..start]);
        let after = &rest[start + 1..];
        match after.find('%') {
            Some(end) if end > 0 => match lookup(&after[..end]) {
                Some(value) => {
                    out.push_str(&value);
                    rest = &after[end + 1..];
                }
                None => {
                    out.push('%');
                    rest = after;
                }
            },
            _ => {
                out.push('%');
                rest = after;
            }
        }
    }
    out.push_str(rest);
    out
}

fn env_lookup(name: &str) -> Option<String> {
    std::env::var(name).ok()
}

/// An entry as Windows compares it: expanded, trimmed, one separator, no trailing separator,
/// case-folded.
fn normalize(entry: &str, lookup: &dyn Fn(&str) -> Option<String>) -> String {
    expand_with(entry.trim(), lookup)
        .replace('/', "\\")
        .trim_end_matches('\\')
        .to_lowercase()
}

fn same(a: &str, b: &str, lookup: &dyn Fn(&str) -> Option<String>) -> bool {
    let a = normalize(a, lookup);
    !a.is_empty() && a == normalize(b, lookup)
}

/// Whether `entry` is the first non-empty entry of `path`.
pub fn is_first(path: &str, entry: &str) -> bool {
    path.split(';')
        .find(|e| !e.trim().is_empty())
        .is_some_and(|first| same(first, entry, &env_lookup))
}

/// Whether `path` names `entry` anywhere.
pub fn contains(path: &str, entry: &str) -> bool {
    path.split(';').any(|e| same(e, entry, &env_lookup))
}

/// `path` without any spelling of `entry`; every other entry is kept verbatim, in order.
pub fn remove_entry(path: &str, entry: &str) -> String {
    path.split(';')
        .filter(|e| !same(e, entry, &env_lookup))
        .collect::<Vec<_>>()
        .join(";")
}

/// `entry` first, then `path` without it. Idempotent; an entry later in the list is moved back to
/// the front, and every other entry is kept verbatim.
pub fn prepend_entry(path: &str, entry: &str) -> String {
    let rest = remove_entry(path, entry);
    if rest.trim_matches(';').is_empty() {
        entry.to_string()
    } else {
        format!("{entry};{}", rest.trim_start_matches(';'))
    }
}

/// Put `entry` first in the user `Path`. `Ok(true)` when the value was written.
pub fn ensure_first(sink: &dyn PathSink, entry: &Path) -> Result<bool, String> {
    let entry = entry.display().to_string();
    let current = sink.read()?.unwrap_or_default();
    if is_first(&current, &entry) {
        return Ok(false);
    }
    sink.write(&prepend_entry(&current, &entry))?;
    Ok(true)
}

/// Take `entry` out of the user `Path`. `Ok(true)` when the value was written.
pub fn remove(sink: &dyn PathSink, entry: &Path) -> Result<bool, String> {
    let entry = entry.display().to_string();
    let Some(current) = sink.read()? else {
        return Ok(false);
    };
    if !contains(&current, &entry) {
        return Ok(false);
    }
    sink.write(&remove_entry(&current, &entry))?;
    Ok(true)
}

/// Whether `entry` is first in this OS's user `Path`, read fresh. `false` off Windows.
pub fn os_is_first(entry: &Path) -> bool {
    os_sink()
        .and_then(|s| s.read().ok().flatten())
        .is_some_and(|p| is_first(&p, &entry.display().to_string()))
}

/// Pure [`effective_path`]: `process` then the expanded `user` entries, de-duplicated as Windows
/// compares them, with `skip` (the shims folder) left out.
pub fn effective_path_from(process: &OsStr, user: Option<&str>, skip: &Path) -> OsString {
    let skip = skip.display().to_string();
    let mut seen: Vec<String> = Vec::new();
    let mut out: Vec<PathBuf> = Vec::new();
    let user_dirs = user
        .into_iter()
        .flat_map(|u| u.split(';'))
        .map(|e| PathBuf::from(expand_with(e.trim(), &env_lookup)));
    for dir in std::env::split_paths(process).chain(user_dirs) {
        let text = dir.display().to_string();
        let key = normalize(&text, &env_lookup);
        if key.is_empty() || same(&text, &skip, &env_lookup) || seen.contains(&key) {
            continue;
        }
        seen.push(key);
        out.push(dir);
    }
    std::env::join_paths(out).unwrap_or_default()
}

/// The search path detection walks: this process's `PATH`, plus — on Windows — the user `Path`
/// read fresh from the registry, so a CLI installed after the daemon started is still found.
/// The launcher shims' folder is never part of it: a shim is not an installed CLI.
pub fn effective_path() -> OsString {
    let process = std::env::var_os("PATH").unwrap_or_default();
    let user = os_sink().and_then(|s| s.read().ok().flatten());
    let skip = crate::hooks::cli_shims_install::shims_dir(&crate::config::openlatch_dir());
    effective_path_from(&process, user.as_deref(), &skip)
}

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

    #[derive(Default)]
    struct Recording {
        value: Mutex<Option<String>>,
        writes: Mutex<Vec<String>>,
    }

    impl PathSink for Recording {
        fn read(&self) -> Result<Option<String>, String> {
            Ok(self.value.lock().unwrap().clone())
        }
        fn write(&self, value: &str) -> Result<(), String> {
            *self.value.lock().unwrap() = Some(value.to_string());
            self.writes.lock().unwrap().push(value.to_string());
            Ok(())
        }
    }

    fn no_vars(_: &str) -> Option<String> {
        None
    }

    #[test]
    fn expansion_replaces_known_names_and_keeps_the_rest() {
        let lookup = |n: &str| (n == "HOME_X").then(|| "C:\\Users\\a".to_string());
        assert_eq!(expand_with("%HOME_X%\\bin", &lookup), "C:\\Users\\a\\bin");
        assert_eq!(expand_with("%NOPE%\\bin", &lookup), "%NOPE%\\bin");
        assert_eq!(expand_with("50%", &lookup), "50%");
        assert_eq!(expand_with("%%", &no_vars), "%%");
    }

    #[test]
    fn entries_compare_case_insensitively_without_trailing_separator() {
        assert!(same("C:\\OL\\shims\\", "c:/ol/SHIMS", &no_vars));
        assert!(!same("", "", &no_vars));
        assert!(!same("C:\\a", "C:\\ab", &no_vars));
    }

    #[test]
    fn prepend_puts_the_entry_first_once() {
        assert_eq!(prepend_entry("", "C:\\s"), "C:\\s");
        assert_eq!(prepend_entry("C:\\a;C:\\b", "C:\\s"), "C:\\s;C:\\a;C:\\b");
        assert_eq!(
            prepend_entry("C:\\s;C:\\a", "C:\\s"),
            "C:\\s;C:\\a",
            "idempotent"
        );
        assert_eq!(
            prepend_entry("C:\\a;c:\\S\\;C:\\b", "C:\\s"),
            "C:\\s;C:\\a;C:\\b",
            "a later spelling moves back to the front"
        );
    }

    #[test]
    fn other_entries_are_kept_verbatim() {
        let path = "%USERPROFILE%\\AppData\\Local\\Microsoft\\WindowsApps;C:\\tools";
        assert_eq!(
            prepend_entry(path, "C:\\s"),
            format!("C:\\s;{path}"),
            "%VAR% entries are not expanded on write"
        );
        assert_eq!(remove_entry(&format!("C:\\s;{path}"), "C:\\s"), path);
    }

    #[test]
    fn is_first_skips_empty_entries() {
        assert!(is_first(";C:\\s;C:\\a", "C:\\s"));
        assert!(!is_first("C:\\a;C:\\s", "C:\\s"));
        assert!(!is_first("", "C:\\s"));
    }

    #[test]
    fn ensure_first_writes_only_when_needed() {
        let sink = Recording::default();
        assert!(ensure_first(&sink, Path::new("C:\\s")).unwrap());
        assert!(!ensure_first(&sink, Path::new("C:\\s")).unwrap());
        assert_eq!(sink.writes.lock().unwrap().len(), 1);

        *sink.value.lock().unwrap() = Some("C:\\installer;C:\\s".into());
        assert!(ensure_first(&sink, Path::new("C:\\s")).unwrap());
        assert_eq!(sink.read().unwrap().as_deref(), Some("C:\\s;C:\\installer"));
    }

    #[test]
    fn remove_drops_the_entry_and_nothing_else() {
        let sink = Recording::default();
        assert!(!remove(&sink, Path::new("C:\\s")).unwrap(), "unset value");
        *sink.value.lock().unwrap() = Some("C:\\a".into());
        assert!(!remove(&sink, Path::new("C:\\s")).unwrap(), "absent entry");
        *sink.value.lock().unwrap() = Some("C:\\s;C:\\a".into());
        assert!(remove(&sink, Path::new("C:\\s")).unwrap());
        assert_eq!(sink.read().unwrap().as_deref(), Some("C:\\a"));
    }

    #[test]
    fn effective_path_merges_dedups_and_skips_the_shims_folder() {
        let tmp = tempfile::tempdir().unwrap();
        let (a, b, shims) = (
            tmp.path().join("a"),
            tmp.path().join("b"),
            tmp.path().join("shims"),
        );
        let process = std::env::join_paths([&shims, &a]).unwrap();
        let user = format!("{};{}", a.display(), b.display());
        let merged: Vec<PathBuf> =
            std::env::split_paths(&effective_path_from(&process, Some(&user), &shims)).collect();
        assert_eq!(merged, vec![a, b]);
    }
}