use anyhow::{bail, Context, Result};
use std::io::Write;
use std::path::Path;
use super::plan::{CursorConfigPlan, FileAction, FilePlan};
#[cfg(test)]
use std::sync::Mutex;
#[cfg(test)]
static FAIL_RENAME_FOR_PATHS: Mutex<Vec<std::path::PathBuf>> = Mutex::new(Vec::new());
#[cfg(test)]
pub(crate) fn fail_next_rename_for_path_for_test(path: &Path) {
FAIL_RENAME_FOR_PATHS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(path.to_path_buf());
}
#[cfg(test)]
pub(crate) fn clear_failpoints_for_test() {
FAIL_RENAME_FOR_PATHS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clear();
}
fn rename_failpoint(path: &Path) -> Result<()> {
#[cfg(test)]
{
let mut paths = FAIL_RENAME_FOR_PATHS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(position) = paths.iter().position(|candidate| candidate == path) {
paths.remove(position);
bail!(
"injected cursor staged rename failure for {}",
path.display()
);
}
}
let _ = path;
Ok(())
}
fn read_current(path: &Path) -> Result<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).with_context(|| format!("read {}", path.display())),
}
}
fn write_secure(path: &Path, bytes: &[u8]) -> Result<()> {
#[cfg(not(unix))]
{
let _ = (path, bytes);
bail!("cursor secure staged writer is only approved for Unix platforms (code=platform_unsupported)");
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let parent = path
.parent()
.with_context(|| format!("{} has no parent directory", path.display()))?;
std::fs::create_dir_all(parent)
.with_context(|| format!("create directory {}", parent.display()))?;
let temp_path = parent.join(format!(
".remem-cursor-{}-{}.tmp",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
let result = (|| -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp_path)
.with_context(|| format!("create secure temp for {}", path.display()))?;
file.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))
.with_context(|| {
format!("set owner-only permissions on temp for {}", path.display())
})?;
file.write_all(bytes)
.with_context(|| format!("write staged bytes for {}", path.display()))?;
file.sync_all()
.with_context(|| format!("sync staged bytes for {}", path.display()))?;
drop(file);
rename_failpoint(path)?;
std::fs::rename(&temp_path, path)
.with_context(|| format!("rename staged file into {}", path.display()))?;
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&temp_path);
}
result
}
}
fn final_compare(path: &Path, expected: Option<&[u8]>) -> Result<()> {
let current = read_current(path)?;
if current.as_deref() != expected {
bail!(
"{} changed since it was validated; aborting and preserving the external version (code=concurrent_edit)",
path.display()
);
}
Ok(())
}
struct CommittedTarget<'a> {
plan: &'a FilePlan,
}
pub(crate) fn apply_plan(plan: &CursorConfigPlan) -> Result<()> {
let targets: Vec<&FilePlan> = [&plan.hooks, &plan.mcp, &plan.runtime_config]
.into_iter()
.filter(|target| target.action != FileAction::NoOp && target.new_bytes.is_some())
.collect();
let mut committed: Vec<CommittedTarget> = Vec::new();
for target in &targets {
let apply_result = apply_single(target);
match apply_result {
Ok(()) => committed.push(CommittedTarget { plan: target }),
Err(error) => {
return Err(rollback_committed(&committed, error));
}
}
}
Ok(())
}
fn apply_single(target: &FilePlan) -> Result<()> {
let path = &target.snapshot.path;
let planned = target
.new_bytes
.as_deref()
.expect("apply_single only receives mutating targets");
final_compare(path, target.snapshot.bytes.as_deref())?;
write_secure(path, planned)?;
let read_back = read_current(path)?;
if read_back.as_deref() != Some(planned) {
bail!(
"{} drifted immediately after replace (read-back mismatch); reporting partial_state (code=read_back_drift)",
path.display()
);
}
Ok(())
}
fn rollback_committed(committed: &[CommittedTarget<'_>], original: anyhow::Error) -> anyhow::Error {
let mut failures: Vec<String> = Vec::new();
for target in committed.iter().rev() {
if let Err(error) = restore_snapshot(target.plan) {
failures.push(format!(
"{}: {error:#}",
target.plan.snapshot.path.display()
));
}
}
if failures.is_empty() {
original.context(
"cursor staged apply failed; all previously committed targets were restored via compensating rollback",
)
} else {
original.context(format!(
"partial_state: cursor compensating rollback failed for [{}]; run `remem doctor` and repair the listed paths before retrying install/uninstall",
failures.join("; ")
))
}
}
fn restore_snapshot(plan: &FilePlan) -> Result<()> {
let path = &plan.snapshot.path;
let planned = plan
.new_bytes
.as_deref()
.expect("only mutating targets are committed");
let current = read_current(path)?;
if current.as_deref() != Some(planned) {
bail!(
"current content no longer matches this transaction's bytes; preserving the external version (code=concurrent_edit)"
);
}
match plan.snapshot.bytes.as_deref() {
Some(previous) => write_secure(path, previous)?,
None => {
std::fs::remove_file(path)
.with_context(|| format!("remove {} during rollback", path.display()))?;
}
}
let after = read_current(path)?;
if after.as_deref() != plan.snapshot.bytes.as_deref() {
bail!(
"rollback read-back does not match the original snapshot (code=rollback_verify_failed)"
);
}
Ok(())
}