use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{OlError, ERR_STATE_FILE_CORRUPT, ERR_STATE_FILE_WRITE_FAILED};
use crate::hooks::codex_cli::Grant;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantRecord {
pub trusted_hash: String,
pub client_version: String,
pub granted_at: String,
}
type Records = BTreeMap<String, GrantRecord>;
fn record_path(openlatch_dir: &Path) -> PathBuf {
openlatch_dir.join("codex-trust-grants.json")
}
fn load(openlatch_dir: &Path) -> Result<Option<Records>, OlError> {
let path = record_path(openlatch_dir);
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(OlError::new(
ERR_STATE_FILE_CORRUPT,
format!("cannot read {}: {e}", path.display()),
))
}
};
serde_json::from_str(&raw).map(Some).map_err(|e| {
OlError::new(
ERR_STATE_FILE_CORRUPT,
format!("{} is not valid JSON: {e}", path.display()),
)
})
}
const LOCK_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(30);
fn mutate<T>(openlatch_dir: &Path, f: impl FnOnce(&mut Records) -> T) -> Result<T, OlError> {
let path = record_path(openlatch_dir);
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 the OpenLatch directory: {e}"),
)
})?;
}
let lock = path.with_extension("json.lock");
crate::fs_secure::with_lockfile(&lock, LOCK_STALE_AFTER, || {
let mut records = load(openlatch_dir)?.unwrap_or_default();
let before = records.clone();
let out = f(&mut records);
if records != before {
let content = serde_json::to_string_pretty(&records).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot serialize the Codex trust record: {e}"),
)
})?;
crate::fs_secure::write_preserving_mode(&path, content.as_bytes()).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot write the Codex trust record: {e}"),
)
})?;
}
Ok(out)
})
.map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot lock the Codex trust record: {e}"),
)
})?
}
pub fn record(openlatch_dir: &Path, grants: &[Grant]) -> Result<(), OlError> {
if grants.is_empty() {
return Ok(());
}
let granted_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
mutate(openlatch_dir, |records| {
for grant in grants {
records.insert(
grant.key.clone(),
GrantRecord {
trusted_hash: grant.trusted_hash.clone(),
client_version: env!("OPENLATCH_VERSION").to_string(),
granted_at: granted_at.clone(),
},
);
}
})
}
pub fn lookup(openlatch_dir: &Path, key: &str) -> Option<GrantRecord> {
load(openlatch_dir).ok().flatten()?.remove(key)
}
pub fn take_for(openlatch_dir: &Path, hooks_json: &Path) -> Result<Vec<Grant>, OlError> {
mutate(openlatch_dir, |records| {
let keys: Vec<String> = records
.keys()
.filter(|key| crate::hooks::codex_cli::key_names_file(key, hooks_json))
.cloned()
.collect();
keys.into_iter()
.filter_map(|key| {
let record = records.remove(&key)?;
Some(Grant {
key,
trusted_hash: record.trusted_hash,
})
})
.collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn grant(key: &str, hash: &str) -> Grant {
Grant {
key: key.to_string(),
trusted_hash: hash.to_string(),
}
}
#[test]
fn grants_round_trip_scoped_to_one_codex_dir() {
let ol = tempfile::tempdir().expect("state dir");
let codex = tempfile::tempdir().expect("codex dir");
let other = tempfile::tempdir().expect("another codex dir");
let ours = codex.path().join("hooks.json");
let theirs = other.path().join("hooks.json");
let key = |file: &Path, rest: &str| format!("{}:{rest}", file.display());
record(
ol.path(),
&[
grant(&key(&ours, "pre_tool_use:1:0"), "sha256:a"),
grant(&key(&theirs, "pre_tool_use:0:0"), "sha256:b"),
],
)
.expect("record");
let found = lookup(ol.path(), &key(&ours, "pre_tool_use:1:0")).expect("recorded");
assert_eq!(found.trusted_hash, "sha256:a");
assert_eq!(found.client_version, env!("OPENLATCH_VERSION"));
assert!(!found.granted_at.is_empty());
assert_eq!(
take_for(ol.path(), &ours).expect("take"),
[grant(&key(&ours, "pre_tool_use:1:0"), "sha256:a")]
);
assert!(lookup(ol.path(), &key(&ours, "pre_tool_use:1:0")).is_none());
assert!(
lookup(ol.path(), &key(&theirs, "pre_tool_use:0:0")).is_some(),
"another directory's grant is not taken"
);
}
#[test]
fn an_unreadable_record_is_not_overwritten() {
let ol = tempfile::tempdir().expect("state dir");
std::fs::write(record_path(ol.path()), "not json").expect("seed");
assert!(record(ol.path(), &[grant("k:pre_tool_use:0:0", "h")]).is_err());
assert_eq!(
std::fs::read_to_string(record_path(ol.path())).expect("read"),
"not json"
);
assert!(lookup(ol.path(), "k:pre_tool_use:0:0").is_none());
}
}