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
//! Byte-identical uninstall, for a binding that opts in
//! ([`crate::hooks::binding::AgentBinding::byte_identical_restore`] — Cursor).
//!
//! Before OpenLatch first writes an agent's hook file, the file's exact bytes
//! are kept (or the fact that it did not exist); after every OpenLatch write,
//! the SHA-256 of the bytes written is kept. Uninstall puts the original back
//! **exactly** when the file on disk is still our last write, and otherwise
//! touches nothing here — the caller falls back to removing our entries by CST.
//!
//! Layout, owner-only, under `<openlatch_dir>/agent-backups/<agent>/`:
//!
//! - `<name>.pre` — the bytes before our first write, or
//! - `<name>.pre.absent` — an empty marker: the file did not exist;
//! - `<name>.last` — lowercase-hex SHA-256 of the bytes we last wrote.
//!
//! **A user edit is never blessed.** Every OpenLatch write compares the bytes
//! it read against `.last`; when they differ the developer edited the file
//! since our last write, so those pre-write bytes become the new `.pre`.
//! Uninstall can therefore never restore content older than the developer's
//! latest edit.

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

use crate::error::{OlError, ERR_HOOK_WRITE_FAILED};
use crate::hooks::hook_files::sha256_hex;

/// What [`restore_if_unchanged`] did.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestoreOutcome {
    /// The file held our last write; its pre-install bytes are back.
    RestoredExact,
    /// The file held our last write and did not exist before us; it is gone.
    DeletedCreated,
    /// The file changed since our last write (or is gone); nothing touched.
    UserEditedSince,
    /// No complete snapshot to restore from; nothing touched.
    NoSnapshot,
}

/// The three snapshot paths for `path`, owned by `agent`.
struct Snapshot {
    dir: PathBuf,
    pre: PathBuf,
    pre_absent: PathBuf,
    last: PathBuf,
}

fn snapshot(openlatch_dir: &Path, agent: &str, path: &Path) -> Snapshot {
    let dir = openlatch_dir.join("agent-backups").join(agent);
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_else(|| "hooks.json".to_string());
    Snapshot {
        pre: dir.join(format!("{name}.pre")),
        pre_absent: dir.join(format!("{name}.pre.absent")),
        last: dir.join(format!("{name}.last")),
        dir,
    }
}

fn io_err(action: &str, path: &Path, e: &std::io::Error) -> OlError {
    OlError::new(
        ERR_HOOK_WRITE_FAILED,
        format!("Cannot {action} '{}': {e}", path.display()),
    )
    .with_suggestion("Check permissions on the OpenLatch state directory.")
}

fn write(path: &Path, body: &str) -> Result<(), OlError> {
    crate::fs_secure::write_owner_only(path, body).map_err(|e| io_err("write", path, &e))
}

fn remove_if_present(path: &Path) -> Result<(), OlError> {
    match std::fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(io_err("remove", path, &e)),
    }
}

/// Keep `before` as the pre-install state: its bytes, or "absent".
fn keep_pre(s: &Snapshot, before: Option<&str>) -> Result<(), OlError> {
    crate::fs_secure::create_dir_owner_only(&s.dir).map_err(|e| io_err("create", &s.dir, &e))?;
    match before {
        Some(bytes) => {
            write(&s.pre, bytes)?;
            remove_if_present(&s.pre_absent)
        }
        None => {
            write(&s.pre_absent, "")?;
            remove_if_present(&s.pre)
        }
    }
}

/// Call BEFORE the first OpenLatch write to `path` for `agent`.
///
/// Idempotent: once a `.pre` (or `.pre.absent`) exists this is a no-op. It
/// exists so a crash between the rewrite and [`record_last_write`] still
/// leaves the original behind. Answers whether THIS call took the snapshot, so
/// a caller whose write is then refused can [`retire`] it again.
///
/// # Errors
///
/// `OL-1401` when the file cannot be read or the snapshot cannot be written.
pub fn snapshot_before_first_write(
    openlatch_dir: &Path,
    agent: &str,
    path: &Path,
) -> Result<bool, OlError> {
    let s = snapshot(openlatch_dir, agent, path);
    if s.pre.exists() || s.pre_absent.exists() {
        return Ok(false);
    }
    let before = match std::fs::read_to_string(path) {
        Ok(raw) => Some(raw),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
        Err(e) => return Err(io_err("read", path, &e)),
    };
    keep_pre(&s, before.as_deref())?;
    Ok(true)
}

/// Call AFTER every OpenLatch write (install, heal): `before` is the text the
/// write read (`None`: the file did not exist), `written` the exact text it
/// renamed into place — never a re-read of the file.
///
/// When `before` is not our last write, the developer edited the file since,
/// so those bytes become the pre-install state. With no last write yet, the
/// snapshot [`snapshot_before_first_write`] took stands: a crash between an
/// earlier rewrite and this record left OUR bytes in the file, and refreshing
/// from them would bless them. Then `.last` records `written`.
///
/// # Errors
///
/// `OL-1401` when a snapshot file cannot be read or written.
pub fn record_last_write(
    openlatch_dir: &Path,
    agent: &str,
    path: &Path,
    before: Option<&str>,
    written: &str,
) -> Result<(), OlError> {
    let s = snapshot(openlatch_dir, agent, path);
    let last = std::fs::read_to_string(&s.last).ok();
    let edited_since = match (last.as_deref(), before) {
        (None, _) => !(s.pre.exists() || s.pre_absent.exists()),
        (Some(last), Some(before)) => last.trim() != sha256_hex(before),
        (Some(_), None) => true,
    };
    if edited_since {
        keep_pre(&s, before)?;
    }
    write(&s.last, &sha256_hex(written))
}

/// Uninstall. When the file's SHA-256 equals `.last`, put `.pre` back exactly
/// (or delete the file when it did not exist before us), then retire the
/// snapshot. Otherwise answer without touching anything — the snapshot
/// included, so the caller can remove our entries and then [`retire`] it.
///
/// # Errors
///
/// `OL-1401` when the file cannot be read, restored or deleted.
pub fn restore_if_unchanged(
    openlatch_dir: &Path,
    agent: &str,
    path: &Path,
) -> Result<RestoreOutcome, OlError> {
    let s = snapshot(openlatch_dir, agent, path);
    let Ok(last) = std::fs::read_to_string(&s.last) else {
        return Ok(RestoreOutcome::NoSnapshot);
    };
    let pre_absent = s.pre_absent.exists();
    if !pre_absent && !s.pre.exists() {
        return Ok(RestoreOutcome::NoSnapshot);
    }
    let current = match std::fs::read(path) {
        Ok(bytes) => bytes,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Ok(RestoreOutcome::UserEditedSince)
        }
        Err(e) => return Err(io_err("read", path, &e)),
    };
    if crate::hooks::hook_files::sha256_bytes(&current) != last.trim() {
        return Ok(RestoreOutcome::UserEditedSince);
    }

    let outcome = if pre_absent {
        std::fs::remove_file(path).map_err(|e| io_err("delete", path, &e))?;
        RestoreOutcome::DeletedCreated
    } else {
        let pre = std::fs::read(&s.pre).map_err(|e| io_err("read", &s.pre, &e))?;
        crate::fs_secure::write_preserving_mode(path, &pre)
            .map_err(|e| io_err("restore", path, &e))?;
        RestoreOutcome::RestoredExact
    };
    retire(openlatch_dir, agent, path)?;
    Ok(outcome)
}

/// Drop the snapshot for `path`, so the next install snapshots the file as it
/// is then. Called once uninstall is done with it, whichever way it went —
/// otherwise a later uninstall would restore bytes older than the developer's
/// edits.
///
/// # Errors
///
/// `OL-1401` when a snapshot file exists and cannot be removed.
pub fn retire(openlatch_dir: &Path, agent: &str, path: &Path) -> Result<(), OlError> {
    let s = snapshot(openlatch_dir, agent, path);
    remove_if_present(&s.pre)?;
    remove_if_present(&s.pre_absent)?;
    remove_if_present(&s.last)
}

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

    const AGENT: &str = "cursor";

    struct Fixture {
        _root: tempfile::TempDir,
        ol: PathBuf,
        file: PathBuf,
    }

    fn fixture() -> Fixture {
        let root = tempfile::tempdir().expect("temp dir");
        let ol = root.path().join("openlatch");
        let file = root.path().join("cursor").join("hooks.json");
        std::fs::create_dir_all(file.parent().expect("parent")).expect("agent dir");
        Fixture {
            ol,
            file,
            _root: root,
        }
    }

    /// An OpenLatch write, as `install_hooks` performs it: snapshot, write,
    /// record the bytes written.
    fn our_write(f: &Fixture, text: &str) {
        snapshot_before_first_write(&f.ol, AGENT, &f.file).expect("snapshot");
        let before = std::fs::read_to_string(&f.file).ok();
        std::fs::write(&f.file, text).expect("write");
        record_last_write(&f.ol, AGENT, &f.file, before.as_deref(), text).expect("record");
    }

    #[test]
    fn an_untouched_file_restores_byte_for_byte() {
        let f = fixture();
        let original = "{\"hooks\":{\"stop\":[{\"command\":\"echo mine\"}]},\"version\":1}\n";
        std::fs::write(&f.file, original).expect("seed");
        our_write(&f, "ours");

        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("restore"),
            RestoreOutcome::RestoredExact
        );
        assert_eq!(std::fs::read_to_string(&f.file).expect("read"), original);
        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("again"),
            RestoreOutcome::NoSnapshot,
            "the snapshot is retired with the restore"
        );
    }

    #[test]
    fn a_file_we_created_is_deleted() {
        let f = fixture();
        our_write(&f, "ours");
        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("restore"),
            RestoreOutcome::DeletedCreated
        );
        assert!(!f.file.exists());
    }

    #[test]
    fn a_crash_before_the_record_keeps_the_original() {
        let f = fixture();
        std::fs::write(&f.file, "theirs").expect("seed");
        snapshot_before_first_write(&f.ol, AGENT, &f.file).expect("snapshot");
        // The rewrite put "ours v1" in place and the process died before
        // `record_last_write`: no `.last`. The next install reads our own bytes.
        std::fs::write(&f.file, "ours v1").expect("crashed write");
        assert!(!snapshot_before_first_write(&f.ol, AGENT, &f.file).expect("snapshot"));
        our_write(&f, "ours v2");

        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("restore"),
            RestoreOutcome::RestoredExact
        );
        assert_eq!(std::fs::read_to_string(&f.file).expect("read"), "theirs");
    }

    #[test]
    fn last_write_hash_is_of_the_bytes_written() {
        let f = fixture();
        std::fs::write(&f.file, "theirs").expect("seed");
        snapshot_before_first_write(&f.ol, AGENT, &f.file).expect("snapshot");
        // The rewrite wrote "ours"; before the hash was recorded, another writer
        // replaced it. Recording from what we wrote — not from a re-read — is
        // what keeps that writer's bytes from being blessed as ours.
        std::fs::write(&f.file, "someone else").expect("interleaved write");
        record_last_write(&f.ol, AGENT, &f.file, Some("theirs"), "ours").expect("record");

        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("restore"),
            RestoreOutcome::UserEditedSince
        );
        assert_eq!(
            std::fs::read_to_string(&f.file).expect("read"),
            "someone else",
            "nothing is touched when the file is not our last write"
        );
    }

    #[test]
    fn user_edit_uninstall_retires_the_snapshot() {
        let f = fixture();
        std::fs::write(&f.file, "v0").expect("seed");
        our_write(&f, "ours");
        std::fs::write(&f.file, "edited").expect("user edit");

        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("restore"),
            RestoreOutcome::UserEditedSince
        );
        // The caller removes our entries by CST, then retires the pair.
        retire(&f.ol, AGENT, &f.file).expect("retire");

        // A reinstall snapshots the file as it is NOW, not as it was at v0.
        our_write(&f, "ours again");
        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("restore"),
            RestoreOutcome::RestoredExact
        );
        assert_eq!(std::fs::read_to_string(&f.file).expect("read"), "edited");
    }

    /// A later OpenLatch write over a user edit refreshes `.pre` from the
    /// bytes it read, so uninstall restores the edit, never v0.
    #[test]
    fn a_later_write_never_blesses_a_user_edit() {
        let f = fixture();
        std::fs::write(&f.file, "v0").expect("seed");
        our_write(&f, "ours-1");
        std::fs::write(&f.file, "edited").expect("user edit");
        our_write(&f, "ours-2"); // a heal, or a re-run of init

        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("restore"),
            RestoreOutcome::RestoredExact
        );
        assert_eq!(std::fs::read_to_string(&f.file).expect("read"), "edited");
    }

    /// Our own rewrite over our own last write keeps the original `.pre`.
    #[test]
    fn a_rewrite_over_our_own_write_keeps_the_original() {
        let f = fixture();
        std::fs::write(&f.file, "v0").expect("seed");
        our_write(&f, "ours-1");
        our_write(&f, "ours-2");

        assert_eq!(
            restore_if_unchanged(&f.ol, AGENT, &f.file).expect("restore"),
            RestoreOutcome::RestoredExact
        );
        assert_eq!(std::fs::read_to_string(&f.file).expect("read"), "v0");
    }

    #[cfg(unix)]
    #[test]
    fn snapshot_files_are_owner_only() {
        use std::os::unix::fs::PermissionsExt;
        let f = fixture();
        std::fs::write(&f.file, "v0").expect("seed");
        our_write(&f, "ours");
        let s = snapshot(&f.ol, AGENT, &f.file);
        for path in [&s.pre, &s.last] {
            let mode = std::fs::metadata(path).expect("meta").permissions().mode() & 0o777;
            assert_eq!(mode, 0o600, "{}", path.display());
        }
    }
}