use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::pathguard::{contain_within, contain_within_canonical};
use crate::{NewApproval, NewCheckpoint, RuntimeStore, data_dir};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckpointFile {
pub path: String,
pub existed: bool,
pub snapshot_relpath: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckpointManifest {
pub id: String,
#[serde(default)]
pub task_id: Option<String>,
pub project_path: String,
pub files: Vec<CheckpointFile>,
pub pending_action: Option<serde_json::Value>,
#[serde(default)]
pub shadow_git_repo: Option<String>,
#[serde(default)]
pub shadow_git_commit: Option<String>,
pub created_at: String,
}
pub fn create_checkpoint(
project_path: &Path,
paths: &[PathBuf],
pending_action: Option<serde_json::Value>,
) -> Result<CheckpointManifest> {
create_checkpoint_for_task(project_path, paths, pending_action, None)
}
pub fn create_checkpoint_for_task(
project_path: &Path,
paths: &[PathBuf],
pending_action: Option<serde_json::Value>,
task_id: Option<String>,
) -> Result<CheckpointManifest> {
let id = crate::storage::fresh_id("checkpoint");
let root = data_dir()?.join("checkpoints").join(&id);
let files_dir = root.join("files");
std::fs::create_dir_all(&files_dir)
.with_context(|| format!("failed to create checkpoint dir {}", files_dir.display()))?;
let project_root = std::fs::canonicalize(project_path).unwrap_or_else(|_| project_path.into());
let mut files = Vec::new();
for path in paths {
let candidate = if path.is_absolute() {
path.clone()
} else {
project_path.join(path)
};
let normalized = std::fs::canonicalize(&candidate).unwrap_or(candidate.clone());
let display = normalized
.strip_prefix(&project_root)
.unwrap_or(&normalized)
.display()
.to_string();
if normalized.exists() && normalized.is_file() {
let safe_rel = sanitize_relpath(&display);
let dest = files_dir.join(&safe_rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&normalized, &dest).with_context(|| {
format!(
"failed to copy checkpoint file {} -> {}",
normalized.display(),
dest.display()
)
})?;
files.push(CheckpointFile {
path: display,
existed: true,
snapshot_relpath: Some(format!("files/{}", safe_rel)),
});
} else {
files.push(CheckpointFile {
path: display,
existed: false,
snapshot_relpath: None,
});
}
}
let shadow_git = snapshot_shadow_git(&project_root, &files, &id).ok();
let manifest = CheckpointManifest {
id: id.clone(),
task_id: task_id.clone(),
project_path: project_path.display().to_string(),
files,
pending_action,
shadow_git_repo: shadow_git.as_ref().map(|snapshot| snapshot.repo.clone()),
shadow_git_commit: shadow_git.as_ref().map(|snapshot| snapshot.commit.clone()),
created_at: chrono::Utc::now().to_rfc3339(),
};
let manifest_path = root.join("manifest.json");
crate::write_atomic(&manifest_path, &serde_json::to_vec_pretty(&manifest)?)?;
if let Ok(store) = RuntimeStore::open_default() {
if let Err(error) = store.checkpoints().create(NewCheckpoint {
id: Some(id.clone()),
task_id,
project_path: manifest.project_path.clone(),
snapshot_path: root.display().to_string(),
changed_files_json: serde_json::to_string(&manifest.files)?,
pending_action_json: manifest
.pending_action
.as_ref()
.map(serde_json::to_string)
.transpose()?,
approval_id: None,
}) {
let _ = std::fs::remove_dir_all(&root);
return Err(error)
.with_context(|| format!("failed to record checkpoint {id} in the runtime DB"));
}
}
let _ = crate::run_plugin_hooks(
"checkpoint",
&serde_json::json!({
"id": manifest.id.clone(),
"task_id": manifest.task_id.clone(),
"project_path": manifest.project_path.clone(),
"files": manifest.files.clone(),
"created_at": manifest.created_at.clone(),
}),
);
Ok(manifest)
}
pub fn restore_checkpoint(id: &str) -> Result<CheckpointManifest> {
let checkpoints_dir = data_dir()?.join("checkpoints");
let ckpt_dir = contain_within(&checkpoints_dir, id)
.with_context(|| format!("invalid checkpoint id: {id:?}"))?;
let manifest_path = ckpt_dir.join("manifest.json");
let raw = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let manifest: CheckpointManifest = serde_json::from_str(&raw)?;
let project_root = resolve_restore_root(id, &manifest)?;
let mut writes: Vec<RestoreOp> = Vec::new();
let mut deletes: Vec<RestoreOp> = Vec::new();
for file in &manifest.files {
let target = match contain_within_canonical(&project_root, &file.path) {
Ok(target) => target,
Err(err) => {
tracing::warn!(
path = %file.path,
error = %err,
"skipping checkpoint entry that escapes the project root"
);
continue;
},
};
if file.existed {
let rel = file
.snapshot_relpath
.as_ref()
.context("checkpoint file missing snapshot_relpath")?;
let source = match contain_within(&ckpt_dir, rel) {
Ok(source) => source,
Err(err) => {
tracing::warn!(
relpath = %rel,
error = %err,
"skipping checkpoint entry with an escaping snapshot_relpath"
);
continue;
},
};
writes.push(RestoreOp::Write { target, source });
} else {
deletes.push(RestoreOp::Delete { target });
}
}
let staging = project_root.join(format!(
".mermaid-restore.{}",
crate::storage::fresh_id("restore")
));
std::fs::create_dir_all(&staging)
.with_context(|| format!("failed to create restore staging dir {}", staging.display()))?;
let mut applied: Vec<PriorState> = Vec::new();
if let Err(err) = apply_restore(&writes, &deletes, &staging, &mut applied) {
rollback_restore(&applied);
let _ = std::fs::remove_dir(&staging);
return Err(err.context(
"checkpoint restore failed; changes already applied were rolled back (best-effort)",
));
}
let _ = std::fs::remove_dir_all(&staging);
if let Some(action) = manifest.pending_action.as_ref()
&& action.get("tool").is_some()
&& let Ok(store) = RuntimeStore::open_default()
{
let proposed_action = action
.get("tool")
.and_then(|value| value.as_str())
.unwrap_or("restored action")
.to_string();
let pending_action_json = serde_json::to_string(action).ok();
if let Ok(approval) = store.approvals().create(NewApproval {
task_id: manifest.task_id.clone(),
proposed_action: format!("restore replay: {}", proposed_action),
risk_classification: "restored_action".to_string(),
policy_decision: "ask".to_string(),
args_summary: pending_action_json.clone(),
checkpoint_id: Some(manifest.id.clone()),
pending_action_json,
}) {
let _ = store.checkpoints().set_approval(&manifest.id, &approval.id);
}
}
Ok(manifest)
}
enum RestoreOp {
Write { target: PathBuf, source: PathBuf },
Delete { target: PathBuf },
}
struct PriorState {
target: PathBuf,
staged: Option<PathBuf>,
}
fn stage_prior(target: &Path, staging: &Path, counter: &mut usize) -> Result<Option<PathBuf>> {
if !target.exists() {
return Ok(None);
}
let dest = staging.join(counter.to_string());
*counter += 1;
std::fs::rename(target, &dest)
.with_context(|| format!("failed to stage prior state of {}", target.display()))?;
Ok(Some(dest))
}
fn remove_path(path: &Path) {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => {
let _ = std::fs::remove_dir_all(path);
},
Ok(_) => {
let _ = std::fs::remove_file(path);
},
Err(_) => {},
}
}
fn apply_restore(
writes: &[RestoreOp],
deletes: &[RestoreOp],
staging: &Path,
applied: &mut Vec<PriorState>,
) -> Result<()> {
let mut counter = 0usize;
for op in writes {
if let RestoreOp::Write { target, source } = op {
let bytes = std::fs::read(source).with_context(|| {
format!("failed to read checkpoint snapshot {}", source.display())
})?;
let staged = stage_prior(target, staging, &mut counter)?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
crate::write_atomic(target, &bytes).with_context(|| {
format!("failed to restore checkpoint file {}", target.display())
})?;
applied.push(PriorState {
target: target.clone(),
staged,
});
}
}
for op in deletes {
if let RestoreOp::Delete { target } = op
&& target.exists()
{
let staged = stage_prior(target, staging, &mut counter)?;
applied.push(PriorState {
target: target.clone(),
staged,
});
}
}
Ok(())
}
fn rollback_restore(applied: &[PriorState]) {
for prior in applied.iter().rev() {
remove_path(&prior.target);
if let Some(staged) = &prior.staged {
if let Some(parent) = prior.target.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::rename(staged, &prior.target);
}
}
}
fn resolve_restore_root(id: &str, manifest: &CheckpointManifest) -> Result<PathBuf> {
let recorded = RuntimeStore::open_default()
.ok()
.and_then(|store| store.checkpoints().get(id).ok().flatten())
.map(|rec| rec.project_path);
let root_str = match recorded {
Some(db_path) => {
anyhow::ensure!(
db_path == manifest.project_path,
"checkpoint project_path does not match the recorded root (tampered manifest?)"
);
db_path
},
None => manifest.project_path.clone(),
};
let root = PathBuf::from(&root_str);
anyhow::ensure!(
root.is_absolute() && root.components().any(|c| matches!(c, Component::Normal(_))),
"unsafe checkpoint project root: {}",
root.display()
);
Ok(root)
}
fn sanitize_relpath(path: &str) -> String {
path.split(std::path::MAIN_SEPARATOR)
.flat_map(|part| part.split('/'))
.filter(|part| !part.is_empty() && *part != "." && *part != "..")
.collect::<Vec<_>>()
.join("__")
}
struct ShadowGitSnapshot {
repo: String,
commit: String,
}
fn snapshot_shadow_git(
project_root: &Path,
files: &[CheckpointFile],
checkpoint_id: &str,
) -> Result<ShadowGitSnapshot> {
let repo_root = data_dir()?
.join("shadow-git")
.join(project_hash(project_root));
let worktree = repo_root.join("worktree");
std::fs::create_dir_all(&worktree)?;
if !worktree.join(".git").exists() {
run_git(&worktree, ["init"])?;
}
run_git(&worktree, ["config", "user.name", "Mermaid Checkpoints"])?;
run_git(
&worktree,
["config", "user.email", "mermaid-checkpoints@localhost"],
)?;
for file in files {
let rel = Path::new(&file.path);
if rel.is_absolute() || rel.components().any(|c| c == Component::ParentDir) {
continue;
}
let shadow_path = worktree.join(rel);
let project_path = project_root.join(rel);
if file.existed && project_path.is_file() {
if let Some(parent) = shadow_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&project_path, &shadow_path).with_context(|| {
format!(
"failed to update shadow checkpoint {} -> {}",
project_path.display(),
shadow_path.display()
)
})?;
} else if shadow_path.exists() {
if shadow_path.is_dir() {
std::fs::remove_dir_all(&shadow_path)?;
} else {
std::fs::remove_file(&shadow_path)?;
}
}
}
run_git(&worktree, ["add", "-A"])?;
let status = Command::new("git")
.args(HOOKS_OFF)
.arg("diff")
.arg("--cached")
.arg("--quiet")
.current_dir(&worktree)
.status()?;
if !status.success() {
run_git_with_env(
&worktree,
["commit", "-m", &format!("checkpoint {checkpoint_id}")],
)?;
}
let commit =
git_output(&worktree, ["rev-parse", "HEAD"]).unwrap_or_else(|_| "uncommitted".to_string());
Ok(ShadowGitSnapshot {
repo: worktree.display().to_string(),
commit,
})
}
const HOOKS_OFF: [&str; 2] = ["-c", "core.hooksPath=/dev/null"];
fn run_git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<()> {
run_git_with_env(cwd, args)
}
fn run_git_with_env<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<()> {
let status = Command::new("git")
.args(HOOKS_OFF)
.args(args)
.current_dir(cwd)
.env("GIT_AUTHOR_NAME", "Mermaid Checkpoints")
.env("GIT_AUTHOR_EMAIL", "mermaid-checkpoints@localhost")
.env("GIT_COMMITTER_NAME", "Mermaid Checkpoints")
.env("GIT_COMMITTER_EMAIL", "mermaid-checkpoints@localhost")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()?;
anyhow::ensure!(
status.success(),
"shadow git command failed in {}",
cwd.display()
);
Ok(())
}
fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<String> {
let output = Command::new("git")
.args(HOOKS_OFF)
.args(args)
.current_dir(cwd)
.output()?;
anyhow::ensure!(output.status.success(), "shadow git command failed");
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn project_hash(path: &Path) -> String {
let mut hasher = Sha256::new();
hasher.update(path.display().to_string().as_bytes());
crate::hex_lower(&hasher.finalize())
}
pub fn gc_old_checkpoint_dirs(retention_days: i64) -> Result<usize> {
let dir = data_dir()?.join("checkpoints");
let Ok(entries) = std::fs::read_dir(&dir) else {
return Ok(0);
};
let cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(
retention_days.max(0) as u64 * 86_400,
))
.unwrap_or(std::time::UNIX_EPOCH);
let store = RuntimeStore::open_default().ok();
let mut removed = 0;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let too_old = entry
.metadata()
.and_then(|m| m.modified())
.map(|mtime| mtime < cutoff)
.unwrap_or(false);
if too_old && std::fs::remove_dir_all(&path).is_ok() {
removed += 1;
if let Some(store) = store.as_ref()
&& let Some(id) = path.file_name().and_then(|name| name.to_str())
&& let Err(error) = store.checkpoints().delete(id)
{
tracing::warn!(
id,
error = %error,
"failed to delete DB row for a GC'd checkpoint dir"
);
}
}
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use crate::*;
#[test]
fn checkpoint_restore_round_trips_file_and_created_file() {
let root = std::env::temp_dir().join("mermaid_checkpoint_test");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), "before").unwrap();
let manifest = create_checkpoint(
&root,
&[root.join("a.txt"), root.join("new.txt")],
Some(serde_json::json!({"tool": "write_file"})),
)
.unwrap();
std::fs::write(root.join("a.txt"), "after").unwrap();
std::fs::write(root.join("new.txt"), "created").unwrap();
let restored = restore_checkpoint(&manifest.id).unwrap();
assert_eq!(restored.id, manifest.id);
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).unwrap(),
"before"
);
assert!(!root.join("new.txt").exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn restore_rejects_paths_escaping_project_root() {
let pid = std::process::id();
let root = std::env::temp_dir().join(format!("mermaid_ckpt_escape_{pid}"));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), "before").unwrap();
let manifest = create_checkpoint(&root, &[root.join("a.txt")], None).unwrap();
let outside = std::env::temp_dir().join(format!("mermaid_ckpt_outside_{pid}.txt"));
std::fs::write(&outside, "do not delete").unwrap();
let outside_name = outside.file_name().unwrap().to_string_lossy().to_string();
let manifest_path = data_dir()
.unwrap()
.join("checkpoints")
.join(&manifest.id)
.join("manifest.json");
let mut tampered: CheckpointManifest =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
tampered.files.push(CheckpointFile {
path: format!("../{outside_name}"),
existed: false,
snapshot_relpath: None,
});
tampered.files.push(CheckpointFile {
path: outside.display().to_string(),
existed: false,
snapshot_relpath: None,
});
std::fs::write(
&manifest_path,
serde_json::to_vec_pretty(&tampered).unwrap(),
)
.unwrap();
let _ = restore_checkpoint(&manifest.id).unwrap();
assert!(
outside.exists(),
"restore must not delete a file outside the project root"
);
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "do not delete");
let _ = std::fs::remove_file(&outside);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn restore_rejects_tampered_project_root() {
let pid = std::process::id();
let root = std::env::temp_dir().join(format!("mermaid_ckpt_root_{pid}"));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.txt"), "before").unwrap();
let manifest = create_checkpoint(&root, &[root.join("a.txt")], None).unwrap();
let outside = std::env::temp_dir().join(format!("mermaid_ckpt_root_outside_{pid}.txt"));
std::fs::write(&outside, "do not delete").unwrap();
let manifest_path = data_dir()
.unwrap()
.join("checkpoints")
.join(&manifest.id)
.join("manifest.json");
let mut tampered: CheckpointManifest =
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
tampered.project_path = "/".to_string();
tampered.files.push(CheckpointFile {
path: outside.display().to_string(),
existed: false,
snapshot_relpath: None,
});
std::fs::write(
&manifest_path,
serde_json::to_vec_pretty(&tampered).unwrap(),
)
.unwrap();
assert!(
restore_checkpoint(&manifest.id).is_err(),
"restore must reject a tampered project_path"
);
assert!(outside.exists(), "restore must not delete an outside file");
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "do not delete");
let _ = std::fs::remove_file(&outside);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn mid_restore_failure_restores_nonempty_prior_directory() {
use super::{PriorState, RestoreOp, apply_restore, rollback_restore};
let pid = std::process::id();
let root = std::env::temp_dir().join(format!("mermaid_ckpt_dirroll_{pid}"));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let victim = root.join("victim");
std::fs::create_dir_all(victim.join("sub")).unwrap();
std::fs::write(victim.join("inner.txt"), "precious").unwrap();
std::fs::write(victim.join("sub").join("deep.txt"), "deep").unwrap();
let src = root.join("snapshot.bin");
std::fs::write(&src, "new-content").unwrap();
let staging = root.join(".staging");
std::fs::create_dir_all(&staging).unwrap();
let writes = vec![
RestoreOp::Write {
target: victim.clone(),
source: src.clone(),
},
RestoreOp::Write {
target: root.join("other.txt"),
source: root.join("does-not-exist.bin"),
},
];
let deletes: Vec<RestoreOp> = Vec::new();
let mut applied: Vec<PriorState> = Vec::new();
let result = apply_restore(&writes, &deletes, &staging, &mut applied);
assert!(
result.is_err(),
"a missing snapshot source must fail the restore"
);
rollback_restore(&applied);
assert!(victim.is_dir(), "prior directory subtree must be restored");
assert_eq!(
std::fs::read_to_string(victim.join("inner.txt")).unwrap(),
"precious"
);
assert_eq!(
std::fs::read_to_string(victim.join("sub").join("deep.txt")).unwrap(),
"deep"
);
assert!(!root.join("other.txt").exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn shadow_git_ignores_absolute_paths_and_cannot_truncate_real_files() {
let tmp = std::env::temp_dir().join(format!(
"mermaid_shadow_abs_{}",
crate::storage::fresh_id("t")
));
let project_root = tmp.join("project");
std::fs::create_dir_all(&project_root).unwrap();
let sentinel = tmp.join("outside.txt");
std::fs::write(&sentinel, "PRECIOUS").unwrap();
let files = vec![CheckpointFile {
path: sentinel.display().to_string(), existed: true,
snapshot_relpath: None,
}];
let _ = super::snapshot_shadow_git(&project_root, &files, "test-cp");
assert_eq!(
std::fs::read_to_string(&sentinel).unwrap(),
"PRECIOUS",
"shadow-git sync must not truncate a real out-of-tree file",
);
let _ = std::fs::remove_dir_all(&tmp);
}
}