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);
}
}