openlatch-client 0.6.18

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 pending-switch marker: `state/pending-switch.json` (PLT-7 C-8).
//!
//! `openlatch system auth switch` writes this marker **before** it stores the
//! new organization's key, and the daemon deletes it once it has applied the
//! switch. Between the two, the marker is the one durable fact that makes the
//! switch complete whatever happens to the CLI or the daemon: whichever process
//! next loads a credential reads it and follows [`PendingSwitch::rule`].
//!
//! The file is owner-only and written atomically (temp file, `sync_all`,
//! rename, parent sync), and a write error **propagates**: a marker that did not
//! land must stop the switch before the key is stored. It is read by opening,
//! reading and closing at once — no handle is ever held, so Windows can always
//! rename or delete it — and a delete retries briefly on a sharing violation.
//!
//! REJECTED — the best-effort `write_offset` pattern the replay cursors use: it
//! swallows rename errors, which is exactly the failure this marker must report.

use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;

use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

/// Relative to the OpenLatch directory.
const MARKER_RELATIVE: &str = "state/pending-switch.json";

/// How long a marker whose key was never stored is kept before it is deleted
/// as abandoned (C-8: "marker 1 h or older").
pub const MARKER_TTL: Duration = Duration::from_secs(60 * 60);

/// Delete attempts on a Windows sharing violation, and the pause between them.
const DELETE_ATTEMPTS: u32 = 3;
const DELETE_RETRY_PAUSE: Duration = Duration::from_millis(50);

/// The marker body, frozen by C-8.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingSwitch {
    /// The organization the machine is moving to.
    pub organization_id: String,
    /// Lower-case hex SHA-256 of the new organization's key.
    pub key_sha256: String,
    /// Unix seconds at which the CLI wrote the marker.
    pub created_at: u64,
}

/// What the daemon does with a marker when it loads a credential (C-8, "The
/// marker rule").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkerAction {
    /// The loaded credential is the marker's key: apply the switch before the
    /// credential is used for any upload, replay or poll.
    Apply,
    /// Another credential, marker younger than [`MARKER_TTL`]: keep the old
    /// credential and leave the marker alone — a switch may be in progress.
    Keep,
    /// Another credential, marker [`MARKER_TTL`] old or older: delete it as
    /// abandoned.
    Expire,
}

impl PendingSwitch {
    /// A marker for `key`, stamped `now` (Unix seconds).
    pub fn new(organization_id: &str, key: &SecretString, now: u64) -> Self {
        Self {
            organization_id: organization_id.to_string(),
            key_sha256: key_sha256(key),
            created_at: now,
        }
    }

    /// The C-8 marker rule for a credential loaded at `now` (Unix seconds).
    /// `None` — no credential loaded — is never the marker's key.
    pub fn rule(&self, loaded_key: Option<&SecretString>, now: u64) -> MarkerAction {
        if loaded_key.is_some_and(|key| self.matches(key)) {
            return MarkerAction::Apply;
        }
        if now.saturating_sub(self.created_at) >= MARKER_TTL.as_secs() {
            MarkerAction::Expire
        } else {
            MarkerAction::Keep
        }
    }

    /// Whether `key` is the key this marker was written for.
    pub fn matches(&self, key: &SecretString) -> bool {
        key_sha256(key).eq_ignore_ascii_case(&self.key_sha256)
    }
}

/// Lower-case hex SHA-256 of a key.
pub fn key_sha256(key: &SecretString) -> String {
    let digest = Sha256::digest(key.expose_secret().as_bytes());
    digest.iter().map(|b| format!("{b:02x}")).collect()
}

/// Where the marker lives under `openlatch_dir`.
pub fn marker_path(openlatch_dir: &Path) -> PathBuf {
    openlatch_dir.join(MARKER_RELATIVE)
}

/// Write the marker atomically, owner-only. Every error propagates.
pub fn write(openlatch_dir: &Path, marker: &PendingSwitch) -> io::Result<()> {
    let path = marker_path(openlatch_dir);
    if let Some(parent) = path.parent() {
        crate::core::fs_secure::create_dir_owner_only(parent)?;
    }
    let body = serde_json::to_string(marker).map_err(io::Error::other)?;
    crate::core::fs_secure::write_owner_only(&path, &body)
}

/// Read the marker: `Ok(None)` when there is none. The file is opened, read and
/// closed in one call, so no handle outlives it. A body that does not parse is
/// an `InvalidData` error — the caller decides whether that is abandoned.
pub fn read(openlatch_dir: &Path) -> io::Result<Option<PendingSwitch>> {
    let path = marker_path(openlatch_dir);
    let bytes = match std::fs::read(&path) {
        Ok(bytes) => bytes,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(e),
    };
    serde_json::from_slice(&bytes)
        .map(Some)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

/// Delete the marker. A missing marker is success. On a Windows sharing
/// violation the delete is retried [`DELETE_ATTEMPTS`] times, 50 ms apart.
pub fn delete(openlatch_dir: &Path) -> io::Result<()> {
    delete_path(&marker_path(openlatch_dir))
}

fn delete_path(path: &Path) -> io::Result<()> {
    let mut attempt = 0;
    loop {
        attempt += 1;
        match std::fs::remove_file(path) {
            Ok(()) => return Ok(()),
            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
            Err(e) if is_sharing_violation(&e) && attempt < DELETE_ATTEMPTS => {
                std::thread::sleep(DELETE_RETRY_PAUSE);
            }
            Err(e) => return Err(e),
        }
    }
}

/// `ERROR_SHARING_VIOLATION` (32) or `ERROR_LOCK_VIOLATION` (33) on Windows,
/// which surface as `PermissionDenied` while another process has the file open.
/// Never true elsewhere: a Unix unlink does not care who has the file open.
fn is_sharing_violation(e: &io::Error) -> bool {
    cfg!(windows) && matches!(e.raw_os_error(), Some(32) | Some(33))
}

/// Unix seconds now — the marker's clock.
pub use crate::core::cloud::now_unix_secs;

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

    fn key(s: &str) -> SecretString {
        SecretString::from(s.to_string())
    }

    const NOW: u64 = 1_800_000_000;

    #[test]
    fn pending_switch_rule_applies_when_the_loaded_key_is_the_marker_key() {
        let marker = PendingSwitch::new("org-b", &key("openlatch_new"), NOW);
        assert_eq!(
            marker.rule(Some(&key("openlatch_new")), NOW + 7200),
            MarkerAction::Apply,
            "a matching key applies however old the marker is"
        );
    }

    #[test]
    fn pending_switch_rule_keeps_a_young_marker_for_another_key() {
        let marker = PendingSwitch::new("org-b", &key("openlatch_new"), NOW);
        assert_eq!(
            marker.rule(Some(&key("openlatch_old")), NOW),
            MarkerAction::Keep
        );
        assert_eq!(
            marker.rule(Some(&key("openlatch_old")), NOW + MARKER_TTL.as_secs() - 1),
            MarkerAction::Keep
        );
        assert_eq!(marker.rule(None, NOW + 10), MarkerAction::Keep);
    }

    #[test]
    fn pending_switch_rule_expires_a_marker_an_hour_old() {
        let marker = PendingSwitch::new("org-b", &key("openlatch_new"), NOW);
        assert_eq!(
            marker.rule(Some(&key("openlatch_old")), NOW + MARKER_TTL.as_secs()),
            MarkerAction::Expire
        );
        assert_eq!(marker.rule(None, NOW + 10_000), MarkerAction::Expire);
    }

    #[test]
    fn pending_switch_rule_never_panics_on_a_clock_before_the_marker() {
        let marker = PendingSwitch::new("org-b", &key("k"), NOW);
        assert_eq!(marker.rule(None, NOW - 100), MarkerAction::Keep);
    }

    #[test]
    fn pending_switch_key_sha256_is_lower_hex_of_the_key_bytes() {
        // sha256("abc")
        assert_eq!(
            key_sha256(&key("abc")),
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }

    #[test]
    fn pending_switch_write_read_delete_round_trip() {
        let tmp = tempfile::tempdir().unwrap();
        assert_eq!(read(tmp.path()).unwrap(), None);
        let marker = PendingSwitch::new("org-b", &key("openlatch_new"), NOW);
        write(tmp.path(), &marker).unwrap();
        assert_eq!(read(tmp.path()).unwrap(), Some(marker.clone()));
        // No temp file left beside the marker.
        let names: Vec<_> = std::fs::read_dir(marker_path(tmp.path()).parent().unwrap())
            .unwrap()
            .map(|e| e.unwrap().file_name())
            .collect();
        assert_eq!(names, vec![std::ffi::OsString::from("pending-switch.json")]);
        delete(tmp.path()).unwrap();
        assert_eq!(read(tmp.path()).unwrap(), None);
        // Deleting what is not there is success.
        delete(tmp.path()).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn pending_switch_marker_is_owner_only() {
        use std::os::unix::fs::PermissionsExt;
        let tmp = tempfile::tempdir().unwrap();
        write(tmp.path(), &PendingSwitch::new("o", &key("k"), NOW)).unwrap();
        let mode = std::fs::metadata(marker_path(tmp.path()))
            .unwrap()
            .permissions()
            .mode();
        assert_eq!(mode & 0o777, 0o600);
    }

    #[test]
    fn pending_switch_write_error_propagates() {
        let tmp = tempfile::tempdir().unwrap();
        // `state` is a FILE, so the marker's parent cannot be created.
        std::fs::write(tmp.path().join("state"), b"not a directory").unwrap();
        let err = write(tmp.path(), &PendingSwitch::new("o", &key("k"), NOW));
        assert!(err.is_err(), "a marker that did not land must be an error");
    }

    #[test]
    fn pending_switch_unparsable_marker_is_invalid_data() {
        let tmp = tempfile::tempdir().unwrap();
        let path = marker_path(tmp.path());
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, b"{not json").unwrap();
        let err = read(tmp.path()).unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
    }

    #[test]
    fn pending_switch_delete_retry_gives_up_on_a_persistent_non_sharing_error() {
        // A directory in the marker's place cannot be `remove_file`d on any
        // platform; the error is not a sharing violation, so no retry spin.
        let tmp = tempfile::tempdir().unwrap();
        let path = marker_path(tmp.path());
        std::fs::create_dir_all(&path).unwrap();
        assert!(delete(tmp.path()).is_err());
    }

    #[test]
    fn pending_switch_sharing_violation_is_windows_only() {
        let e = io::Error::from_raw_os_error(32);
        assert_eq!(is_sharing_violation(&e), cfg!(windows));
    }
}