openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
pub mod diff;
pub mod hmac;
pub mod key;
pub mod marker;

use std::path::Path;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::error::{OlError, ERR_STATE_FILE_CORRUPT, ERR_STATE_FILE_WRITE_FAILED};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookStateFile {
    pub schema_version: u32,
    pub generated_at: DateTime<Utc>,
    pub hmac_key_id: String,
    pub entries: Vec<StateEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateEntry {
    pub id: String,
    pub agent: String,
    pub settings_path_hash: String,
    pub hook_event: String,
    pub expected_entry_hmac: String,
    pub daemon_port_at_install: u16,
    pub daemon_token_fp: String,
    pub v: u8,
}

const STATE_FILE_NAME: &str = "hook-state.json";
const CURRENT_SCHEMA_VERSION: u32 = 1;

impl HookStateFile {
    pub fn new(hmac_key_id: String) -> Self {
        Self {
            schema_version: CURRENT_SCHEMA_VERSION,
            generated_at: Utc::now(),
            hmac_key_id,
            entries: Vec::new(),
        }
    }

    pub fn load(openlatch_dir: &Path) -> Result<Option<Self>, OlError> {
        let path = openlatch_dir.join(STATE_FILE_NAME);
        if !path.exists() {
            return Ok(None);
        }

        let content = std::fs::read_to_string(&path).map_err(|e| {
            OlError::new(
                ERR_STATE_FILE_CORRUPT,
                format!("Cannot read hook state file: {e}"),
            )
        })?;

        let state: Self = serde_json::from_str(&content).map_err(|e| {
            OlError::new(
                ERR_STATE_FILE_CORRUPT,
                format!("Cannot parse hook state file: {e}"),
            )
            .with_suggestion("Delete ~/.openlatch/hook-state.json and re-run `openlatch init`.")
        })?;

        if state.schema_version > CURRENT_SCHEMA_VERSION {
            return Err(OlError::new(
                ERR_STATE_FILE_CORRUPT,
                format!(
                    "Hook state file has schema_version {} (expected <= {CURRENT_SCHEMA_VERSION})",
                    state.schema_version
                ),
            )
            .with_suggestion("Upgrade openlatch to the latest version."));
        }

        Ok(Some(state))
    }

    pub fn save(&mut self, openlatch_dir: &Path) -> Result<(), OlError> {
        let path = openlatch_dir.join(STATE_FILE_NAME);

        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    ERR_STATE_FILE_WRITE_FAILED,
                    format!("Cannot create state file directory: {e}"),
                )
            })?;
        }

        self.generated_at = Utc::now();

        let content = serde_json::to_string_pretty(self).map_err(|e| {
            OlError::new(
                ERR_STATE_FILE_WRITE_FAILED,
                format!("Cannot serialize hook state: {e}"),
            )
        })?;

        let tmp_path = path.with_extension("json.tmp");
        std::fs::write(&tmp_path, &content).map_err(|e| {
            OlError::new(
                ERR_STATE_FILE_WRITE_FAILED,
                format!("Cannot write state file: {e}"),
            )
        })?;
        std::fs::rename(&tmp_path, &path).map_err(|e| {
            OlError::new(
                ERR_STATE_FILE_WRITE_FAILED,
                format!("Cannot rename state file: {e}"),
            )
        })?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let perms = std::fs::Permissions::from_mode(0o600);
            let _ = std::fs::set_permissions(&path, perms);
        }

        Ok(())
    }

    pub fn find_entry(&self, entry_id: &str) -> Option<&StateEntry> {
        self.entries.iter().find(|e| e.id == entry_id)
    }

    pub fn upsert_entry(&mut self, entry: StateEntry) {
        if let Some(existing) = self.entries.iter_mut().find(|e| e.id == entry.id) {
            *existing = entry;
        } else {
            self.entries.push(entry);
        }
    }
}

pub fn hash_settings_path(path: &Path) -> String {
    use sha2::{Digest, Sha256};
    let path_str = path.to_string_lossy();
    let hash = Sha256::digest(path_str.as_bytes());
    format!(
        "sha256:{}",
        crate::core::hook_state::key::hex::encode(&hash)
    )
}

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

    #[test]
    fn state_file_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let mut state = HookStateFile::new("kid-01".into());
        state.entries.push(StateEntry {
            id: "test-entry-id".into(),
            agent: "claude-code".into(),
            settings_path_hash: "sha256:abc123".into(),
            hook_event: "PreToolUse".into(),
            expected_entry_hmac: "hmac-value".into(),
            daemon_port_at_install: 7443,
            daemon_token_fp: "fp-value".into(),
            v: 1,
        });
        state.save(dir.path()).unwrap();

        let loaded = HookStateFile::load(dir.path()).unwrap().unwrap();
        assert_eq!(loaded.schema_version, 1);
        assert_eq!(loaded.hmac_key_id, "kid-01");
        assert_eq!(loaded.entries.len(), 1);
        assert_eq!(loaded.entries[0].id, "test-entry-id");
    }

    #[test]
    fn load_returns_none_when_missing() {
        let dir = tempfile::tempdir().unwrap();
        assert!(HookStateFile::load(dir.path()).unwrap().is_none());
    }

    #[test]
    fn rejects_future_schema_version() {
        let dir = tempfile::tempdir().unwrap();
        let content = r#"{"schema_version": 99, "generated_at": "2026-04-16T12:00:00Z", "hmac_key_id": "kid-01", "entries": []}"#;
        std::fs::write(dir.path().join(STATE_FILE_NAME), content).unwrap();
        let err = HookStateFile::load(dir.path()).unwrap_err();
        assert_eq!(err.code, "OL-1901");
    }

    #[test]
    fn upsert_replaces_existing() {
        let mut state = HookStateFile::new("kid-01".into());
        state.upsert_entry(StateEntry {
            id: "entry-1".into(),
            agent: "claude-code".into(),
            settings_path_hash: "sha256:abc".into(),
            hook_event: "PreToolUse".into(),
            expected_entry_hmac: "old-hmac".into(),
            daemon_port_at_install: 7443,
            daemon_token_fp: "fp".into(),
            v: 1,
        });
        state.upsert_entry(StateEntry {
            id: "entry-1".into(),
            agent: "claude-code".into(),
            settings_path_hash: "sha256:abc".into(),
            hook_event: "PreToolUse".into(),
            expected_entry_hmac: "new-hmac".into(),
            daemon_port_at_install: 7443,
            daemon_token_fp: "fp".into(),
            v: 1,
        });
        assert_eq!(state.entries.len(), 1);
        assert_eq!(state.entries[0].expected_entry_hmac, "new-hmac");
    }

    #[test]
    fn hash_settings_path_is_deterministic() {
        let p = Path::new("/home/user/.claude/settings.json");
        let h1 = hash_settings_path(p);
        let h2 = hash_settings_path(p);
        assert_eq!(h1, h2);
        assert!(h1.starts_with("sha256:"));
    }

    #[test]
    fn hash_settings_path_never_contains_literal_path() {
        let p = Path::new("/home/user/.claude/settings.json");
        let h = hash_settings_path(p);
        assert!(!h.contains(".claude"));
        assert!(!h.contains("settings.json"));
    }

    #[test]
    #[cfg(unix)]
    fn state_file_mode_0600() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let mut state = HookStateFile::new("kid-01".into());
        state.save(dir.path()).unwrap();
        let meta = std::fs::metadata(dir.path().join(STATE_FILE_NAME)).unwrap();
        assert_eq!(meta.permissions().mode() & 0o777, 0o600);
    }
}