openlatch-client 0.6.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Linux: `~/.config/environment.d/60-openlatch-cline.conf` for sessions started later, plus the running user
//! manager (`systemctl --user`) and D-Bus activation environment for apps launched from this session. The value
//! check is compiled on every OS; `session` only where it is the sink.

/// Pure: environment.d(5) has no quoting that covers these, so a value carrying one is refused rather than
/// written in a form the session would read differently. A `&str` is UTF-8 already; a non-UTF-8 path never
/// becomes one (its caller refuses it first).
pub(super) fn validate_value(value: &str) -> Result<(), String> {
    if value.is_empty() {
        return Err("an empty value".to_string());
    }
    match value
        .chars()
        .find(|c| matches!(c, '#' | '$' | '"' | '\'' | '\\' | '\n' | '\r'))
    {
        Some(c) => Err(format!(
            "the value holds {c:?}, which environment.d cannot quote"
        )),
        None => Ok(()),
    }
}

/// Pure: the session-wide commands `clear` runs, in order. `dbus-update-activation-environment` runs WITHOUT
/// `--systemd` (with it, `NAME=` writes an empty `NAME` back into the user manager, so a read-back finds `""`),
/// and runs first: where the D-Bus broker forwards activation-environment updates to systemd, the
/// `systemctl --user unset-environment` that follows still leaves the user manager without the name.
#[cfg_attr(any(target_os = "macos", windows), allow(dead_code))]
pub(super) fn clear_commands(name: &str) -> Vec<(&'static str, Vec<String>)> {
    vec![
        (
            "dbus-update-activation-environment",
            vec![format!("{name}=")],
        ),
        (
            "systemctl",
            vec![
                "--user".to_string(),
                "unset-environment".to_string(),
                name.to_string(),
            ],
        ),
    ]
}

/// Pure: `name`'s value in `K=V` lines; an empty value is no value (it is how a blanked name reads back).
#[cfg_attr(any(target_os = "macos", windows), allow(dead_code))]
pub(super) fn value_of(lines: Vec<(String, String)>, name: &str) -> Option<String> {
    lines
        .into_iter()
        .find(|(k, _)| k == name)
        .map(|(_, v)| v)
        .filter(|v| !v.is_empty())
}

/// The sink, on every OS that is neither macOS nor Windows.
#[cfg(not(any(target_os = "macos", windows)))]
pub(super) mod session {
    use std::path::PathBuf;

    use super::super::run_checked;
    use super::{clear_commands, validate_value, value_of};

    /// Our environment.d file. `60-` sorts after distribution defaults and before a user's own `99-` overrides.
    const CONF_FILE: &str = "60-openlatch-cline.conf";

    pub(in super::super) const MANUAL_CLEAR: &str =
        "`systemctl --user unset-environment CLINE_WRAPPER_PATH CLINE_JS_RUNTIME_PATH` and by deleting \
         `~/.config/environment.d/60-openlatch-cline.conf`";

    fn conf_path() -> Option<PathBuf> {
        dirs::config_dir().map(|d| d.join("environment.d").join(CONF_FILE))
    }

    /// The `K=V` lines of our file.
    fn read_conf() -> Vec<(String, String)> {
        let Some(path) = conf_path() else {
            return Vec::new();
        };
        std::fs::read_to_string(path)
            .map(|raw| parse_lines(&raw))
            .unwrap_or_default()
    }

    /// Our file's lines, less `name`'s.
    fn conf_without(name: &str) -> Vec<(String, String)> {
        read_conf().into_iter().filter(|(k, _)| k != name).collect()
    }

    /// Pure: `K=V` lines, comments and blanks skipped.
    fn parse_lines(raw: &str) -> Vec<(String, String)> {
        raw.lines()
            .map(str::trim)
            .filter(|l| !l.is_empty() && !l.starts_with('#'))
            .filter_map(|l| l.split_once('='))
            .map(|(k, v)| (k.trim().to_string(), v.to_string()))
            .collect()
    }

    /// Rewrites our file with `lines`; no line left → the file is removed.
    fn write_conf(lines: &[(String, String)]) -> Result<(), String> {
        let path = conf_path().ok_or("no user configuration directory")?;
        if lines.is_empty() {
            return match std::fs::remove_file(&path) {
                Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.to_string()),
                _ => Ok(()),
            };
        }
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
        }
        let mut body = String::from(
            "# Written by OpenLatch for the Cline plugin. Removed by `openlatch uninstall`.\n",
        );
        for (k, v) in lines {
            body.push_str(&format!("{k}={v}\n"));
        }
        crate::fs_secure::write_readable(&path, &body).map_err(|e| e.to_string())
    }

    /// Best effort: the session-wide copies. The file is what makes it persistent, and it alone decides the result.
    fn best_effort(program: &str, args: &[&str], what: &str) {
        if let Err(e) = run_checked(program, args) {
            tracing::debug!(error = %e, "Cline login environment: {what}");
        }
    }

    pub(in super::super) fn read(name: &str) -> Result<Option<String>, String> {
        if let Ok(out) = run_checked("systemctl", &["--user", "show-environment"]) {
            if let Some(v) = value_of(parse_lines(&out), name) {
                return Ok(Some(v));
            }
            // The user manager answered without it: a value only in our file is still pending a new session, and
            // it is still ours to report (and to clear).
        }
        Ok(value_of(read_conf(), name))
    }

    pub(in super::super) fn apply(name: &str, value: &str) -> Result<(), String> {
        validate_value(value)?;
        let mut lines = conf_without(name);
        lines.push((name.to_string(), value.to_string()));
        write_conf(&lines)?;
        let pair = format!("{name}={value}");
        best_effort(
            "systemctl",
            &["--user", "set-environment", &pair],
            "session copy not updated",
        );
        best_effort(
            "dbus-update-activation-environment",
            &["--systemd", &pair],
            "session copy not updated",
        );
        Ok(())
    }

    pub(in super::super) fn clear(name: &str) -> Result<(), String> {
        write_conf(&conf_without(name))?;
        for (program, args) in clear_commands(name) {
            let args: Vec<&str> = args.iter().map(String::as_str).collect();
            best_effort(program, &args, "session copy not cleared");
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::{clear_commands, validate_value, value_of};

    /// Pure: the D-Bus copy is blanked without `--systemd` (which would write `NAME=""` into the user manager),
    /// then the user manager's copy is unset, last, so nothing writes the name back after it.
    #[test]
    fn linux_clear_blanks_dbus_then_unsets_the_user_manager() {
        let commands = clear_commands("CLINE_WRAPPER_PATH");
        assert_eq!(
            commands,
            vec![
                (
                    "dbus-update-activation-environment",
                    vec!["CLINE_WRAPPER_PATH=".to_string()]
                ),
                (
                    "systemctl",
                    vec![
                        "--user".to_string(),
                        "unset-environment".to_string(),
                        "CLINE_WRAPPER_PATH".to_string()
                    ]
                ),
            ]
        );
        assert!(
            commands
                .iter()
                .all(|(_, args)| !args.iter().any(|a| a == "--systemd")),
            "clear must not write an empty value into the user manager"
        );
    }

    /// Pure: a blanked name reads as unset.
    #[test]
    fn linux_empty_value_reads_as_none() {
        let lines = |v: &str| {
            vec![
                ("A".to_string(), "x".to_string()),
                ("N".to_string(), v.to_string()),
            ]
        };
        assert_eq!(value_of(lines(""), "N"), None);
        assert_eq!(value_of(lines("/p"), "N"), Some("/p".to_string()));
        assert_eq!(value_of(lines("/p"), "M"), None);
    }

    /// Pure, so it runs on every OS: what environment.d(5) cannot quote is refused.
    #[test]
    fn linux_refuses_unquotable_values() {
        for bad in [
            "/a#b", "/a$b", "/a\"b", "/a'b", "/a\\b", "/a\nb", "/a\rb", "",
        ] {
            assert!(validate_value(bad).is_err(), "{bad:?} was accepted");
        }
        for good in [
            "/Users/dev/.openlatch/bin/cline-js-runtime-vscode",
            "/home/dev/my openlatch/cline-plugin-bootstrap/wrapper",
        ] {
            assert_eq!(validate_value(good), Ok(()), "{good:?}");
        }
    }
}