use std::path::{Path, PathBuf};
use crate::error::{OlError, ERR_HOOK_WRITE_FAILED};
use crate::hooks::hook_files::sha256_hex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestoreOutcome {
RestoredExact,
DeletedCreated,
UserEditedSince,
NoSnapshot,
}
struct Snapshot {
dir: PathBuf,
pre: PathBuf,
pre_absent: PathBuf,
last: PathBuf,
last_theirs: 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")),
last_theirs: dir.join(format!("{name}.last.theirs")),
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)),
}
}
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)
}
}
}
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)
}
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 last_theirs = std::fs::read_to_string(&s.last_theirs).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)
&& !matches!(
(last_theirs.as_deref(), theirs_digest(before, agent)),
(Some(last), Some(now)) if last.trim() == now
)
}
(Some(_), None) => true,
};
if edited_since {
let theirs = before.map(|text| without_ours(text, agent));
keep_pre(&s, theirs.as_deref())?;
}
write(&s.last, &sha256_hex(written))?;
match theirs_digest(written, agent) {
Some(digest) => write(&s.last_theirs, &digest),
None => remove_if_present(&s.last_theirs),
}
}
fn without_ours(text: &str, agent: &str) -> String {
let Ok(root) =
jsonc_parser::cst::CstRootNode::parse(text, &jsonc_parser::ParseOptions::default())
else {
return text.to_string();
};
match crate::hooks::jsonc::remove_owned_entries_cst_by(&root, &|node| {
crate::hooks::entry_shape::is_sidecar_owned_node(node, agent)
}) {
Ok(()) => root.to_string(),
Err(_) => text.to_string(),
}
}
fn theirs_digest(text: &str, agent: &str) -> Option<String> {
let mut value = crate::hooks::jsonc::parse_settings_value(text).ok()?;
if let Some(hooks) = value
.get_mut("hooks")
.and_then(serde_json::Value::as_object_mut)
{
for entries in hooks.values_mut() {
if let Some(entries) = entries.as_array_mut() {
entries.retain(|e| !crate::hooks::entry_shape::is_sidecar_owned_entry(e, agent));
}
}
hooks.retain(|_, entries| entries.as_array().is_none_or(|a| !a.is_empty()));
}
let canonical = serde_json_canonicalizer::to_string(&value).ok()?;
Some(sha256_hex(&canonical))
}
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(¤t) != 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)
}
pub fn installed_at(
openlatch_dir: &Path,
agent: &str,
path: &Path,
) -> Option<std::time::SystemTime> {
let s = snapshot(openlatch_dir, agent, path);
let modified = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
modified(&s.pre).or_else(|| modified(&s.pre_absent))
}
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)?;
remove_if_present(&s.last_theirs)
}
#[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,
}
}
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");
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");
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
);
retire(&f.ol, AGENT, &f.file).expect("retire");
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");
}
#[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");
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");
}
#[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");
}
#[test]
fn a_change_to_our_entries_alone_is_not_a_user_edit() {
let f = fixture();
let ours = |event: &str| {
format!(
r#"{{"command":"\"/x/openlatch-hook\" --agent {AGENT} --event {event} --openlatch-dir \"/o\"","timeout":10}}"#
)
};
let original = "{\"hooks\":{\"stop\":[{\"command\":\"echo mine\"}]},\"version\":1}\n";
std::fs::write(&f.file, original).expect("seed");
let installed = format!(
r#"{{"version":1,"hooks":{{"stop":[{{"command":"echo mine"}},{}],"sessionEnd":[{}]}}}}"#,
ours("stop"),
ours("session_end")
);
our_write(&f, &installed);
let tampered = format!(
r#"{{"hooks":{{"sessionEnd":[],"stop":[{{"command":"echo mine"}},{}]}},"version":1}}"#,
ours("stop")
);
std::fs::write(&f.file, &tampered).expect("tamper");
our_write(&f, &installed);
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);
}
#[test]
fn a_refreshed_snapshot_drops_our_entries() {
let f = fixture();
let ours = |event: &str| {
format!(
r#"{{"command":"\"/x/openlatch-hook\" --agent {AGENT} --event {event} --openlatch-dir \"/o\"","timeout":10}}"#
)
};
std::fs::write(&f.file, "{\"version\":1}").expect("seed");
let installed = format!(
r#"{{"version":1,"hooks":{{"stop":[{}],"sessionEnd":[{}]}}}}"#,
ours("stop"),
ours("session_end")
);
our_write(&f, &installed);
let edited = format!(
"{{\n // mine\n \"version\": 1,\n \"hooks\": {{\n \"stop\": [\n {{ \"command\": \"echo mine\" }},\n {}\n ],\n \"sessionEnd\": [{}]\n }}\n}}\n",
ours("stop"),
ours("session_end")
);
std::fs::write(&f.file, &edited).expect("user edit");
our_write(&f, &installed);
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"),
"{\n // mine\n \"version\": 1,\n \"hooks\": {\n \"stop\": [\n { \"command\": \"echo mine\" }\n ]\n }\n}\n"
);
}
#[test]
fn an_unparseable_edit_is_kept_raw() {
let f = fixture();
std::fs::write(&f.file, "v0").expect("seed");
our_write(&f, "ours-1");
std::fs::write(&f.file, "{ not json openlatch-hook").expect("user edit");
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"),
"{ not json openlatch-hook"
);
}
#[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, "{}");
let s = snapshot(&f.ol, AGENT, &f.file);
for path in [&s.pre, &s.last, &s.last_theirs] {
let mode = std::fs::metadata(path).expect("meta").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "{}", path.display());
}
}
}