Skip to main content

mermaid_runtime/
checkpoint.rs

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    // Atomic write: a crash mid-write must not leave a half-written manifest —
107    // restore depends on it parsing cleanly.
108    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    // Confine the checkpoint id to the checkpoints dir: reject `..`/absolute
142    // traversal that would read a manifest from anywhere on disk.
143    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    // The confinement root must be a trusted, sane project directory — never a
151    // value the (tamperable) manifest can redirect to `/` or a system dir.
152    let project_root = resolve_restore_root(id, &manifest)?;
153    for file in &manifest.files {
154        // The manifest is on-disk state a tampered or shared checkpoint could
155        // have rewritten. Confine every restore target to the recorded project
156        // root — rejecting absolute paths, `..` escapes, AND symlinks planted
157        // inside the root — before any copy/create/delete. Anything that
158        // doesn't resolve inside the root is skipped, not run.
159        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            // The snapshot source is also a manifest-supplied string; confine it
176            // to this checkpoint's own directory so a crafted `snapshot_relpath`
177            // (`../../etc/passwd`) can't read an arbitrary file as the copy
178            // source.
179            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
233/// Resolve the trusted project root a checkpoint may restore into. Prefer the
234/// DB-recorded `project_path` (written at create time) and require the manifest
235/// to agree with it, so a manifest-only tamper is rejected. Either way the root
236/// must be an absolute directory with at least one normal component — a bare
237/// filesystem root (`/`, `C:\`) confines nothing, since every absolute path
238/// `starts_with` it (the original escape primitive).
239fn 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
341/// Disable repo-provided git hooks on every shadow-git invocation. The shadow
342/// worktree lives under the data dir, but the project's own hooks (and a
343/// tampered shadow repo) must never run as a side effect of taking a
344/// checkpoint. A nonexistent hooks dir ⇒ git runs no hooks (works on Windows
345/// Git too). Mirrors the `core.hooksPath=/dev/null` hardening in `plugin.rs`.
346const 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        // Build a real checkpoint, then tamper its on-disk manifest to add
437        // entries whose paths escape the project root (one `..`-relative, one
438        // absolute), and confirm restore refuses to touch the outside target.
439        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        // A file OUTSIDE the project root that a tampered manifest tries to delete.
448        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        // existed=false ⇒ restore would try to remove the resolved target.
460        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        // #3: a manifest whose `project_path` is rewritten to `/` (so lexical
491        // containment passes for ANY absolute path) must be rejected — the
492        // root can't be redirected to a filesystem root or disagree with the
493        // DB-recorded path.
494        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}