1use std::path::{Component, Path, PathBuf};
2use std::process::{Command, Stdio};
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::pathguard::{contain_within, contain_within_canonical};
9use crate::{NewApproval, NewCheckpoint, RuntimeStore, data_dir};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct CheckpointFile {
13 pub path: String,
14 pub existed: bool,
15 pub snapshot_relpath: Option<String>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct CheckpointManifest {
20 pub id: String,
21 #[serde(default)]
22 pub task_id: Option<String>,
23 pub project_path: String,
24 pub files: Vec<CheckpointFile>,
25 pub pending_action: Option<serde_json::Value>,
26 #[serde(default)]
27 pub shadow_git_repo: Option<String>,
28 #[serde(default)]
29 pub shadow_git_commit: Option<String>,
30 pub created_at: String,
31}
32
33pub fn create_checkpoint(
34 project_path: &Path,
35 paths: &[PathBuf],
36 pending_action: Option<serde_json::Value>,
37) -> Result<CheckpointManifest> {
38 create_checkpoint_for_task(project_path, paths, pending_action, None)
39}
40
41pub fn create_checkpoint_for_task(
42 project_path: &Path,
43 paths: &[PathBuf],
44 pending_action: Option<serde_json::Value>,
45 task_id: Option<String>,
46) -> Result<CheckpointManifest> {
47 let id = fresh_checkpoint_id();
48 let root = data_dir()?.join("checkpoints").join(&id);
49 let files_dir = root.join("files");
50 std::fs::create_dir_all(&files_dir)
51 .with_context(|| format!("failed to create checkpoint dir {}", files_dir.display()))?;
52
53 let project_root = std::fs::canonicalize(project_path).unwrap_or_else(|_| project_path.into());
54 let mut files = Vec::new();
55 for path in paths {
56 let candidate = if path.is_absolute() {
57 path.clone()
58 } else {
59 project_path.join(path)
60 };
61 let normalized = std::fs::canonicalize(&candidate).unwrap_or(candidate.clone());
62 let display = normalized
63 .strip_prefix(&project_root)
64 .unwrap_or(&normalized)
65 .display()
66 .to_string();
67 if normalized.exists() && normalized.is_file() {
68 let safe_rel = sanitize_relpath(&display);
69 let dest = files_dir.join(&safe_rel);
70 if let Some(parent) = dest.parent() {
71 std::fs::create_dir_all(parent)?;
72 }
73 std::fs::copy(&normalized, &dest).with_context(|| {
74 format!(
75 "failed to copy checkpoint file {} -> {}",
76 normalized.display(),
77 dest.display()
78 )
79 })?;
80 files.push(CheckpointFile {
81 path: display,
82 existed: true,
83 snapshot_relpath: Some(format!("files/{}", safe_rel)),
84 });
85 } else {
86 files.push(CheckpointFile {
87 path: display,
88 existed: false,
89 snapshot_relpath: None,
90 });
91 }
92 }
93
94 let shadow_git = snapshot_shadow_git(&project_root, &files, &id).ok();
95 let manifest = CheckpointManifest {
96 id: id.clone(),
97 task_id: task_id.clone(),
98 project_path: project_path.display().to_string(),
99 files,
100 pending_action,
101 shadow_git_repo: shadow_git.as_ref().map(|snapshot| snapshot.repo.clone()),
102 shadow_git_commit: shadow_git.as_ref().map(|snapshot| snapshot.commit.clone()),
103 created_at: chrono::Utc::now().to_rfc3339(),
104 };
105 let manifest_path = root.join("manifest.json");
106 crate::write_atomic(&manifest_path, &serde_json::to_vec_pretty(&manifest)?)?;
109
110 if let Ok(store) = RuntimeStore::open_default() {
111 let _ = store.checkpoints().create(NewCheckpoint {
112 id: Some(id),
113 task_id,
114 project_path: manifest.project_path.clone(),
115 snapshot_path: root.display().to_string(),
116 changed_files_json: serde_json::to_string(&manifest.files)?,
117 pending_action_json: manifest
118 .pending_action
119 .as_ref()
120 .map(serde_json::to_string)
121 .transpose()?,
122 approval_id: None,
123 });
124 }
125
126 let _ = crate::run_plugin_hooks(
127 "checkpoint",
128 &serde_json::json!({
129 "id": manifest.id.clone(),
130 "task_id": manifest.task_id.clone(),
131 "project_path": manifest.project_path.clone(),
132 "files": manifest.files.clone(),
133 "created_at": manifest.created_at.clone(),
134 }),
135 );
136
137 Ok(manifest)
138}
139
140pub fn restore_checkpoint(id: &str) -> Result<CheckpointManifest> {
141 let checkpoints_dir = data_dir()?.join("checkpoints");
144 let ckpt_dir = contain_within(&checkpoints_dir, id)
145 .with_context(|| format!("invalid checkpoint id: {id:?}"))?;
146 let manifest_path = ckpt_dir.join("manifest.json");
147 let raw = std::fs::read_to_string(&manifest_path)
148 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
149 let manifest: CheckpointManifest = serde_json::from_str(&raw)?;
150 let project_root = resolve_restore_root(id, &manifest)?;
153 for file in &manifest.files {
154 let target = match contain_within_canonical(&project_root, &file.path) {
160 Ok(target) => target,
161 Err(err) => {
162 tracing::warn!(
163 path = %file.path,
164 error = %err,
165 "skipping checkpoint entry that escapes the project root"
166 );
167 continue;
168 },
169 };
170 if file.existed {
171 let rel = file
172 .snapshot_relpath
173 .as_ref()
174 .context("checkpoint file missing snapshot_relpath")?;
175 let source = match contain_within(&ckpt_dir, rel) {
180 Ok(source) => source,
181 Err(err) => {
182 tracing::warn!(
183 relpath = %rel,
184 error = %err,
185 "skipping checkpoint entry with an escaping snapshot_relpath"
186 );
187 continue;
188 },
189 };
190 if let Some(parent) = target.parent() {
191 std::fs::create_dir_all(parent)?;
192 }
193 std::fs::copy(&source, &target).with_context(|| {
194 format!(
195 "failed to restore checkpoint file {} -> {}",
196 source.display(),
197 target.display()
198 )
199 })?;
200 } else if target.exists() {
201 if target.is_file() {
202 std::fs::remove_file(&target)?;
203 } else if target.is_dir() {
204 std::fs::remove_dir_all(&target)?;
205 }
206 }
207 }
208 if let Some(action) = manifest.pending_action.as_ref()
209 && action.get("tool").is_some()
210 && let Ok(store) = RuntimeStore::open_default()
211 {
212 let proposed_action = action
213 .get("tool")
214 .and_then(|value| value.as_str())
215 .unwrap_or("restored action")
216 .to_string();
217 let pending_action_json = serde_json::to_string(action).ok();
218 if let Ok(approval) = store.approvals().create(NewApproval {
219 task_id: manifest.task_id.clone(),
220 proposed_action: format!("restore replay: {}", proposed_action),
221 risk_classification: "restored_action".to_string(),
222 policy_decision: "ask".to_string(),
223 args_summary: pending_action_json.clone(),
224 checkpoint_id: Some(manifest.id.clone()),
225 pending_action_json,
226 }) {
227 let _ = store.checkpoints().set_approval(&manifest.id, &approval.id);
228 }
229 }
230 Ok(manifest)
231}
232
233fn resolve_restore_root(id: &str, manifest: &CheckpointManifest) -> Result<PathBuf> {
240 let recorded = RuntimeStore::open_default()
241 .ok()
242 .and_then(|store| store.checkpoints().get(id).ok().flatten())
243 .map(|rec| rec.project_path);
244 let root_str = match recorded {
245 Some(db_path) => {
246 anyhow::ensure!(
247 db_path == manifest.project_path,
248 "checkpoint project_path does not match the recorded root (tampered manifest?)"
249 );
250 db_path
251 },
252 None => manifest.project_path.clone(),
253 };
254 let root = PathBuf::from(&root_str);
255 anyhow::ensure!(
256 root.is_absolute() && root.components().any(|c| matches!(c, Component::Normal(_))),
257 "unsafe checkpoint project root: {}",
258 root.display()
259 );
260 Ok(root)
261}
262
263fn sanitize_relpath(path: &str) -> String {
264 path.split(std::path::MAIN_SEPARATOR)
265 .flat_map(|part| part.split('/'))
266 .filter(|part| !part.is_empty() && *part != "." && *part != "..")
267 .collect::<Vec<_>>()
268 .join("__")
269}
270
271struct ShadowGitSnapshot {
272 repo: String,
273 commit: String,
274}
275
276fn snapshot_shadow_git(
277 project_root: &Path,
278 files: &[CheckpointFile],
279 checkpoint_id: &str,
280) -> Result<ShadowGitSnapshot> {
281 let repo_root = data_dir()?
282 .join("shadow-git")
283 .join(project_hash(project_root));
284 let worktree = repo_root.join("worktree");
285 std::fs::create_dir_all(&worktree)?;
286 if !worktree.join(".git").exists() {
287 run_git(&worktree, ["init"])?;
288 }
289
290 run_git(&worktree, ["config", "user.name", "Mermaid Checkpoints"])?;
291 run_git(
292 &worktree,
293 ["config", "user.email", "mermaid-checkpoints@localhost"],
294 )?;
295
296 for file in files {
297 let shadow_path = worktree.join(&file.path);
298 let project_path = project_root.join(&file.path);
299 if file.existed && project_path.is_file() {
300 if let Some(parent) = shadow_path.parent() {
301 std::fs::create_dir_all(parent)?;
302 }
303 std::fs::copy(&project_path, &shadow_path).with_context(|| {
304 format!(
305 "failed to update shadow checkpoint {} -> {}",
306 project_path.display(),
307 shadow_path.display()
308 )
309 })?;
310 } else if shadow_path.exists() {
311 if shadow_path.is_dir() {
312 std::fs::remove_dir_all(&shadow_path)?;
313 } else {
314 std::fs::remove_file(&shadow_path)?;
315 }
316 }
317 }
318
319 run_git(&worktree, ["add", "-A"])?;
320 let status = Command::new("git")
321 .args(HOOKS_OFF)
322 .arg("diff")
323 .arg("--cached")
324 .arg("--quiet")
325 .current_dir(&worktree)
326 .status()?;
327 if !status.success() {
328 run_git_with_env(
329 &worktree,
330 ["commit", "-m", &format!("checkpoint {checkpoint_id}")],
331 )?;
332 }
333 let commit =
334 git_output(&worktree, ["rev-parse", "HEAD"]).unwrap_or_else(|_| "uncommitted".to_string());
335 Ok(ShadowGitSnapshot {
336 repo: worktree.display().to_string(),
337 commit,
338 })
339}
340
341const HOOKS_OFF: [&str; 2] = ["-c", "core.hooksPath=/dev/null"];
347
348fn run_git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<()> {
349 run_git_with_env(cwd, args)
350}
351
352fn run_git_with_env<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<()> {
353 let status = Command::new("git")
354 .args(HOOKS_OFF)
355 .args(args)
356 .current_dir(cwd)
357 .env("GIT_AUTHOR_NAME", "Mermaid Checkpoints")
358 .env("GIT_AUTHOR_EMAIL", "mermaid-checkpoints@localhost")
359 .env("GIT_COMMITTER_NAME", "Mermaid Checkpoints")
360 .env("GIT_COMMITTER_EMAIL", "mermaid-checkpoints@localhost")
361 .stdout(Stdio::null())
362 .stderr(Stdio::null())
363 .status()?;
364 anyhow::ensure!(
365 status.success(),
366 "shadow git command failed in {}",
367 cwd.display()
368 );
369 Ok(())
370}
371
372fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<String> {
373 let output = Command::new("git")
374 .args(HOOKS_OFF)
375 .args(args)
376 .current_dir(cwd)
377 .output()?;
378 anyhow::ensure!(output.status.success(), "shadow git command failed");
379 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
380}
381
382fn project_hash(path: &Path) -> String {
383 let mut hasher = Sha256::new();
384 hasher.update(path.display().to_string().as_bytes());
385 hex_lower(&hasher.finalize())
386}
387
388fn hex_lower(bytes: &[u8]) -> String {
389 const HEX: &[u8; 16] = b"0123456789abcdef";
390 let mut out = String::with_capacity(bytes.len() * 2);
391 for byte in bytes {
392 out.push(HEX[(byte >> 4) as usize] as char);
393 out.push(HEX[(byte & 0x0f) as usize] as char);
394 }
395 out
396}
397
398fn fresh_checkpoint_id() -> String {
399 let nanos = std::time::SystemTime::now()
400 .duration_since(std::time::UNIX_EPOCH)
401 .map(|d| d.as_nanos())
402 .unwrap_or_default();
403 format!("checkpoint-{nanos:x}")
404}
405
406#[cfg(test)]
407mod tests {
408 use crate::*;
409
410 #[test]
411 fn checkpoint_restore_round_trips_file_and_created_file() {
412 let root = std::env::temp_dir().join("mermaid_checkpoint_test");
413 let _ = std::fs::remove_dir_all(&root);
414 std::fs::create_dir_all(&root).unwrap();
415 std::fs::write(root.join("a.txt"), "before").unwrap();
416 let manifest = create_checkpoint(
417 &root,
418 &[root.join("a.txt"), root.join("new.txt")],
419 Some(serde_json::json!({"tool": "write_file"})),
420 )
421 .unwrap();
422 std::fs::write(root.join("a.txt"), "after").unwrap();
423 std::fs::write(root.join("new.txt"), "created").unwrap();
424 let restored = restore_checkpoint(&manifest.id).unwrap();
425 assert_eq!(restored.id, manifest.id);
426 assert_eq!(
427 std::fs::read_to_string(root.join("a.txt")).unwrap(),
428 "before"
429 );
430 assert!(!root.join("new.txt").exists());
431 let _ = std::fs::remove_dir_all(&root);
432 }
433
434 #[test]
435 fn restore_rejects_paths_escaping_project_root() {
436 let pid = std::process::id();
440 let root = std::env::temp_dir().join(format!("mermaid_ckpt_escape_{pid}"));
441 let _ = std::fs::remove_dir_all(&root);
442 std::fs::create_dir_all(&root).unwrap();
443 std::fs::write(root.join("a.txt"), "before").unwrap();
444
445 let manifest = create_checkpoint(&root, &[root.join("a.txt")], None).unwrap();
446
447 let outside = std::env::temp_dir().join(format!("mermaid_ckpt_outside_{pid}.txt"));
449 std::fs::write(&outside, "do not delete").unwrap();
450 let outside_name = outside.file_name().unwrap().to_string_lossy().to_string();
451
452 let manifest_path = data_dir()
453 .unwrap()
454 .join("checkpoints")
455 .join(&manifest.id)
456 .join("manifest.json");
457 let mut tampered: CheckpointManifest =
458 serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
459 tampered.files.push(CheckpointFile {
461 path: format!("../{outside_name}"),
462 existed: false,
463 snapshot_relpath: None,
464 });
465 tampered.files.push(CheckpointFile {
466 path: outside.display().to_string(),
467 existed: false,
468 snapshot_relpath: None,
469 });
470 std::fs::write(
471 &manifest_path,
472 serde_json::to_vec_pretty(&tampered).unwrap(),
473 )
474 .unwrap();
475
476 let _ = restore_checkpoint(&manifest.id).unwrap();
477
478 assert!(
479 outside.exists(),
480 "restore must not delete a file outside the project root"
481 );
482 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "do not delete");
483
484 let _ = std::fs::remove_file(&outside);
485 let _ = std::fs::remove_dir_all(&root);
486 }
487
488 #[test]
489 fn restore_rejects_tampered_project_root() {
490 let pid = std::process::id();
495 let root = std::env::temp_dir().join(format!("mermaid_ckpt_root_{pid}"));
496 let _ = std::fs::remove_dir_all(&root);
497 std::fs::create_dir_all(&root).unwrap();
498 std::fs::write(root.join("a.txt"), "before").unwrap();
499 let manifest = create_checkpoint(&root, &[root.join("a.txt")], None).unwrap();
500
501 let outside = std::env::temp_dir().join(format!("mermaid_ckpt_root_outside_{pid}.txt"));
502 std::fs::write(&outside, "do not delete").unwrap();
503
504 let manifest_path = data_dir()
505 .unwrap()
506 .join("checkpoints")
507 .join(&manifest.id)
508 .join("manifest.json");
509 let mut tampered: CheckpointManifest =
510 serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
511 tampered.project_path = "/".to_string();
512 tampered.files.push(CheckpointFile {
513 path: outside.display().to_string(),
514 existed: false,
515 snapshot_relpath: None,
516 });
517 std::fs::write(
518 &manifest_path,
519 serde_json::to_vec_pretty(&tampered).unwrap(),
520 )
521 .unwrap();
522
523 assert!(
524 restore_checkpoint(&manifest.id).is_err(),
525 "restore must reject a tampered project_path"
526 );
527 assert!(outside.exists(), "restore must not delete an outside file");
528 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "do not delete");
529
530 let _ = std::fs::remove_file(&outside);
531 let _ = std::fs::remove_dir_all(&root);
532 }
533}