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,
}
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")),
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 edited_since = match (last.as_deref(), before) {
(None, _) => !(s.pre.exists() || s.pre_absent.exists()),
(Some(last), Some(before)) => last.trim() != sha256_hex(before),
(Some(_), None) => true,
};
if edited_since {
keep_pre(&s, before)?;
}
write(&s.last, &sha256_hex(written))
}
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 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)
}
#[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");
}
#[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, "ours");
let s = snapshot(&f.ol, AGENT, &f.file);
for path in [&s.pre, &s.last] {
let mode = std::fs::metadata(path).expect("meta").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "{}", path.display());
}
}
}