Skip to main content

supercode_harness/
checkpoint.rs

1//! §2 module 20 `checkpoint` (COMPOSABLE-HARNESS-DESIGN.md line 470): file
2//! checkpointing / shadow-git; D4-adjacent revert; D3 turn-diff tracking.
3//! Line 1504: "restores FILES, not context — pairs with, never replaces,
4//! the reduction sidecar."
5//!
6//! **Not to be confused with** [`crate::Agent::checkpoint`]/[`crate::Agent::rewind_to`]
7//! (an in-memory CONVERSATION-position marker, an unrelated pre-existing
8//! facility) or [`crate::session_tree`] (module 21, tree-addressable
9//! sessions). This module only ever touches files on disk.
10//!
11//! # The write-path interception seam (D-5, shared with `formatters`/P5-11)
12//! [`crate::tools::WriteObserver`] (defined in `crate::tools`, the seam's
13//! natural owner since [`crate::tools::ToolContext`] carries it) is called
14//! by `write_file`/`edit_file`/`apply_patch` around their mutation.
15//! [`CheckpointObserver`] is this module's implementation: `before_write`
16//! captures a pre-image; `after_write` is a no-op (reserved for
17//! `formatters`).
18//!
19//! # Storage — never the user's real git
20//! [`CheckpointStore`] is a content-addressed, per-project shadow store at
21//! a caller-supplied `root` (the same dependency-injection shape as
22//! [`crate::store::SessionStore::open`]) — blobs keyed by
23//! [`crate::reduce::content_hash`] under `<root>/objects/`, one JSON
24//! manifest per checkpoint under `<root>/checkpoints/<id>.json`. This
25//! module never shells out to `git` and never touches the project's own
26//! `.git` — there is no `GIT_DIR` to get wrong because none is ever used.
27//!
28//! # C8 (design line 1819) compliance
29//! A [`CheckpointId`] is an opaque `String` — the ONLY thing a caller
30//! (session record, CLI) ever threads around. It is never the file
31//! contents, and it never reaches the model's context window: restoring
32//! files is a disk operation, never a conversation-history mutation.
33
34use std::collections::HashSet;
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::Mutex;
38
39use serde::{Deserialize, Serialize};
40
41use crate::error::{Error, Result};
42use crate::tools::WriteObserver;
43
44/// Default number of checkpoints retained per project before the oldest are
45/// pruned (bounded-disk requirement) — overridable via
46/// `[capabilities.checkpoint].retain`.
47pub const DEFAULT_RETAIN: usize = 50;
48
49/// An opaque, lexically-sortable (ascending = chronological, since it's
50/// zero-padded-millis-prefixed) checkpoint identifier. This is ALL a
51/// session record ever carries for C8 — never file contents.
52pub type CheckpointId = String;
53
54fn now_ms() -> u128 {
55    std::time::SystemTime::now()
56        .duration_since(std::time::UNIX_EPOCH)
57        .map(|d| d.as_millis())
58        .unwrap_or(0)
59}
60
61/// Mint a fresh id: zero-padded millis + an 8-hex-digit salt derived from
62/// (pid, millis, a monotonic instant) — collision-free in practice without
63/// pulling in a UUID/random dependency (matches this crate's existing
64/// `shell_sentinel` precedent in `tools/builtins.rs`).
65fn mint_id() -> CheckpointId {
66    use std::hash::BuildHasher;
67    let millis = now_ms();
68    let salt = std::collections::hash_map::RandomState::new().hash_one((
69        std::process::id(),
70        millis,
71        std::time::Instant::now(),
72    ));
73    format!("{millis:020}-{:08x}", salt as u32)
74}
75
76/// One file's entry in a [`CheckpointManifest`] — the turn-diff (D3) is
77/// simply this manifest's `files` list.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct CheckpointFileEntry {
80    /// Path relative to the project root, `/`-separated, never absolute and
81    /// never containing a `..` component in what [`CheckpointObserver`]
82    /// itself writes (restore independently re-validates this — see
83    /// [`CheckpointStore::restore`] — rather than trusting it).
84    pub path: String,
85    /// blake3 hex digest ([`crate::reduce::content_hash`]) of the pre-image
86    /// content, or `None` if the file did not exist before this
87    /// checkpoint's turn began (i.e. the write that follows is a create —
88    /// restoring deletes it again).
89    pub blob: Option<String>,
90}
91
92/// A checkpoint's full manifest — one JSON file per checkpoint.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CheckpointManifest {
95    /// This checkpoint's id.
96    pub id: CheckpointId,
97    /// Unix-epoch milliseconds when this checkpoint was minted.
98    pub created_at_ms: u128,
99    /// A short, human-readable label (e.g. the turn's prompt excerpt) —
100    /// display only, never re-parsed.
101    pub label: String,
102    /// Every file this turn captured a pre-image for — the D3 turn-diff.
103    pub files: Vec<CheckpointFileEntry>,
104}
105
106/// Lightweight listing entry ([`CheckpointStore::list`]) — the manifest
107/// without the (potentially long) file list.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct CheckpointMeta {
110    /// This checkpoint's id.
111    pub id: CheckpointId,
112    /// Unix-epoch milliseconds when this checkpoint was minted.
113    pub created_at_ms: u128,
114    /// A short, human-readable label — display only.
115    pub label: String,
116    /// How many files this checkpoint's manifest lists.
117    pub file_count: usize,
118}
119
120/// The outcome of a [`CheckpointStore::restore`] call.
121#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122pub struct RestoreReport {
123    /// Files successfully restored (project-relative paths).
124    pub restored: Vec<String>,
125    /// Files refused, with the reason — e.g. an out-of-root or
126    /// protected-path target. Non-empty means the restore was PARTIAL; a
127    /// caller must surface this, never silently swallow it.
128    pub refused: Vec<(String, String)>,
129}
130
131/// A directory-backed, content-addressed shadow store rooted at `root` —
132/// see the module doc comment. Never touches anything outside `root` except
133/// (during [`Self::restore`]) the project files a manifest names, which are
134/// re-validated against `project_root` independently of how the manifest
135/// was produced.
136#[derive(Debug)]
137pub struct CheckpointStore {
138    root: PathBuf,
139}
140
141impl CheckpointStore {
142    /// Open (creating if needed) a store at `root`. `Err` if `root` can't
143    /// be created (e.g. an unwritable state dir) — callers (see
144    /// [`observer_for_config`]) treat that as "disable checkpoint, warn
145    /// once", never a crash.
146    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
147        let root = root.into();
148        std::fs::create_dir_all(root.join("objects"))?;
149        std::fs::create_dir_all(root.join("checkpoints"))?;
150        Ok(CheckpointStore { root })
151    }
152
153    fn objects_dir(&self) -> PathBuf {
154        self.root.join("objects")
155    }
156    fn checkpoints_dir(&self) -> PathBuf {
157        self.root.join("checkpoints")
158    }
159    fn manifest_path(&self, id: &str) -> PathBuf {
160        self.checkpoints_dir().join(format!("{id}.json"))
161    }
162    fn blob_path(&self, hash: &str) -> PathBuf {
163        let prefix = &hash[..hash.len().min(2)];
164        self.objects_dir().join(prefix).join(hash)
165    }
166
167    /// Reject an `id` that could escape the store root when joined into a
168    /// path — the exact same defensive shape as
169    /// `crate::store::SessionStore::validate_name`, since a `checkpoint
170    /// restore <id>`/`checkpoint diff <id>` CLI argument is untrusted user
171    /// input by the time it reaches here.
172    fn validate_id(id: &str) -> Result<()> {
173        let bad = id.is_empty()
174            || id.contains('/')
175            || id.contains('\\')
176            || id.contains('\0')
177            || id.split(['/', '\\']).any(|c| c == ".." || c == ".")
178            || Path::new(id).is_absolute()
179            || id.trim() != id;
180        if bad {
181            return Err(Error::Other(format!("invalid checkpoint id: `{id}`")));
182        }
183        Ok(())
184    }
185
186    /// Content-address `content`, writing it only if not already present
187    /// (dedup — the same content written by two different checkpoints costs
188    /// one blob). Atomic (write-to-tmp, rename) so a concurrent reader
189    /// never observes a partial blob; a lost race against another writer
190    /// racing the SAME hash is harmless (identical content either way).
191    fn write_blob(&self, content: &[u8]) -> Result<String> {
192        let hash = crate::reduce::content_hash(content);
193        let dest = self.blob_path(&hash);
194        if dest.exists() {
195            return Ok(hash);
196        }
197        let Some(parent) = dest.parent() else {
198            return Err(Error::Other("blob path has no parent".to_string()));
199        };
200        std::fs::create_dir_all(parent)?;
201        let tmp = parent.join(format!(".tmp-{}-{}", std::process::id(), mint_id()));
202        std::fs::write(&tmp, content)?;
203        match std::fs::rename(&tmp, &dest) {
204            Ok(()) => {}
205            Err(e) if dest.exists() => {
206                // Another writer won the race with identical content.
207                let _ = std::fs::remove_file(&tmp);
208                let _ = e;
209            }
210            Err(e) => return Err(e.into()),
211        }
212        Ok(hash)
213    }
214
215    fn read_blob(&self, hash: &str) -> Result<Vec<u8>> {
216        std::fs::read(self.blob_path(hash)).map_err(Into::into)
217    }
218
219    fn write_manifest(&self, m: &CheckpointManifest) -> Result<()> {
220        Self::validate_id(&m.id)?;
221        let dest = self.manifest_path(&m.id);
222        let json = serde_json::to_vec_pretty(m).map_err(|e| Error::Other(e.to_string()))?;
223        let dir = self.checkpoints_dir();
224        std::fs::create_dir_all(&dir)?;
225        let tmp = dir.join(format!(".tmp-{}-{}", std::process::id(), mint_id()));
226        std::fs::write(&tmp, &json)?;
227        std::fs::rename(&tmp, &dest)?;
228        Ok(())
229    }
230
231    /// Mint a fresh checkpoint (a new turn) with an empty file list. `label`
232    /// is display-only.
233    pub fn create_checkpoint(&self, label: &str) -> Result<CheckpointId> {
234        let id = mint_id();
235        let manifest = CheckpointManifest {
236            id: id.clone(),
237            created_at_ms: now_ms(),
238            label: label.to_string(),
239            files: Vec::new(),
240        };
241        self.write_manifest(&manifest)?;
242        Ok(id)
243    }
244
245    /// Idempotently record `rel`'s pre-image under checkpoint `id` — a
246    /// SECOND call for the same `(id, rel)` pair is a no-op (the manifest
247    /// always keeps the EARLIEST pre-image seen this turn, which is the one
248    /// a revert needs). `content: None` means the file did not exist yet.
249    pub fn record_pre_image(&self, id: &str, rel: &str, content: Option<Vec<u8>>) -> Result<()> {
250        let mut manifest = self.manifest(id)?;
251        if manifest.files.iter().any(|f| f.path == rel) {
252            return Ok(());
253        }
254        let blob = match content {
255            Some(bytes) => Some(self.write_blob(&bytes)?),
256            None => None,
257        };
258        manifest.files.push(CheckpointFileEntry {
259            path: rel.to_string(),
260            blob,
261        });
262        self.write_manifest(&manifest)
263    }
264
265    /// Read one checkpoint's full manifest.
266    pub fn manifest(&self, id: &str) -> Result<CheckpointManifest> {
267        Self::validate_id(id)?;
268        let text = std::fs::read_to_string(self.manifest_path(id))
269            .map_err(|e| Error::Other(format!("checkpoint `{id}` not found: {e}")))?;
270        serde_json::from_str(&text)
271            .map_err(|e| Error::Other(format!("checkpoint `{id}` manifest is corrupt: {e}")))
272    }
273
274    /// Every checkpoint in the store, newest first (ids are millis-prefixed
275    /// so lexical descending order IS chronological descending order). A
276    /// corrupt individual manifest is skipped (resilience — one bad file
277    /// never hides every other checkpoint), not a hard error.
278    pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
279        let dir = self.checkpoints_dir();
280        let mut metas = Vec::new();
281        if !dir.exists() {
282            return Ok(metas);
283        }
284        for entry in std::fs::read_dir(&dir)? {
285            let entry = entry?;
286            let path = entry.path();
287            if path.extension().and_then(|e| e.to_str()) != Some("json") {
288                continue;
289            }
290            let Ok(text) = std::fs::read_to_string(&path) else {
291                continue;
292            };
293            if let Ok(m) = serde_json::from_str::<CheckpointManifest>(&text) {
294                metas.push(CheckpointMeta {
295                    id: m.id,
296                    created_at_ms: m.created_at_ms,
297                    label: m.label,
298                    file_count: m.files.len(),
299                });
300            }
301        }
302        metas.sort_by(|a, b| b.id.cmp(&a.id));
303        Ok(metas)
304    }
305
306    /// D3 turn-diff: the set of project-relative paths this checkpoint's
307    /// turn touched — exactly the manifest's file list (every entry exists
308    /// BECAUSE a write-tool call captured a pre-image for it this turn).
309    pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
310        Ok(self
311            .manifest(id)?
312            .files
313            .into_iter()
314            .map(|f| f.path)
315            .collect())
316    }
317
318    /// D4-adjacent revert: restore `project_root`'s working files to
319    /// checkpoint `id`. For each manifest entry: `Some(blob)` rewrites the
320    /// file to that pre-image; `None` (didn't exist before the turn)
321    /// deletes it if present now (undoing a create). SECURITY: every
322    /// target is independently re-validated (never trusts the manifest was
323    /// produced honestly) against `project_root` containment (no symlink
324    /// escape, no `..` traversal) AND `protected_globs` (plus an
325    /// unconditional `.git/**` floor) checked against BOTH the LEXICAL
326    /// normalized path and the symlink-RESOLVED path — a lexical-only
327    /// check would miss a manifest entry like `foo/config` where `foo` is
328    /// a pre-existing symlink into `.git`: lexically it's clean, but it
329    /// resolves inside `root` (so containment alone accepts it too) and
330    /// lands on the real `.git/config`. A refused entry is recorded in
331    /// [`RestoreReport::refused`], never silently applied AND never aborts
332    /// the rest of the restore (partial-success, fully reported).
333    pub fn restore(
334        &self,
335        id: &str,
336        project_root: &Path,
337        protected_globs: &[String],
338    ) -> Result<RestoreReport> {
339        let manifest = self.manifest(id)?;
340        let mut report = RestoreReport::default();
341        for entry in &manifest.files {
342            // (1) Up-front rejection: a legitimately-captured entry (see
343            // `CheckpointFileEntry::path`'s doc comment) is always a clean
344            // relative path — `record_pre_image` never produces an absolute
345            // path or a `..` component. An entry that has one is by
346            // definition hostile or corrupt (traversal-injected, or a
347            // corrupted/copied/shared manifest — exactly this module's
348            // stated threat model) and must never reach a raw-string
349            // pattern match at all.
350            if let Some(reason) = reject_unsafe_manifest_path(&entry.path) {
351                report.refused.push((entry.path.clone(), reason));
352                continue;
353            }
354            let target = project_root.join(&entry.path);
355            // (2) Normalize-then-protect: compute the SAME lexically-
356            // normalized project-relative path `contained()` uses
357            // internally, ONCE, and check `is_protected` against THAT
358            // (not the raw manifest string) — so a `.git/**`-floor or
359            // `protected_globs` bypass via `x/../.git/config` can no
360            // longer disagree between the two checks (the root cause of
361            // the bug this replaces: `is_protected` matched the raw
362            // string while `contained` matched the normalized path).
363            let Some(normalized_rel) = normalized_project_rel(project_root, &target) else {
364                report.refused.push((
365                    entry.path.clone(),
366                    "refused: escapes the project root".to_string(),
367                ));
368                continue;
369            };
370            if is_protected(&normalized_rel, protected_globs) {
371                report
372                    .refused
373                    .push((entry.path.clone(), "refused: protected path".to_string()));
374                continue;
375            }
376            // Belt-and-suspenders: the existing symlink-safe containment
377            // check (canonicalizes the longest existing ancestor, refusing
378            // any symlink escape) still runs unconditionally.
379            if !contained(project_root, &target) {
380                report.refused.push((
381                    entry.path.clone(),
382                    "refused: escapes the project root".to_string(),
383                ));
384                continue;
385            }
386            // (3) Resolved-then-protect: `normalized_rel` above is a purely
387            // LEXICAL collapse — it never resolves symlinks — while
388            // `contained` (just above) DOES resolve symlinks when it
389            // canonicalizes the longest existing ancestor. Those two views
390            // of the path can disagree exactly when a component of `target`
391            // is a symlink: e.g. a pre-existing `foo -> .git` inside
392            // `project_root` plus a manifest entry `foo/config` lexically
393            // normalizes to `foo/config` (not protected — no literal
394            // `.git/` prefix) yet resolves to `<root>/.git/config` (still
395            // "contained" under `project_root`, so the escape check above
396            // doesn't catch it either — it never leaves the root, it just
397            // lands somewhere the lexical path didn't say). Re-run
398            // `is_protected` against the RESOLVED, symlink-followed
399            // project-relative path too, so this can't slip through: the
400            // `.git`/`protected_globs` floor now sees BOTH the lexical and
401            // the resolved view, and refuses if EITHER is protected.
402            if let Some(resolved_rel) = resolved_project_rel(project_root, &target) {
403                if is_protected(&resolved_rel, protected_globs) {
404                    report
405                        .refused
406                        .push((entry.path.clone(), "refused: protected path".to_string()));
407                    continue;
408                }
409            }
410            match &entry.blob {
411                Some(hash) => {
412                    let bytes = match self.read_blob(hash) {
413                        Ok(b) => b,
414                        Err(e) => {
415                            report
416                                .refused
417                                .push((entry.path.clone(), format!("blob unreadable: {e}")));
418                            continue;
419                        }
420                    };
421                    if let Some(parent) = target.parent() {
422                        let _ = std::fs::create_dir_all(parent);
423                    }
424                    if let Err(e) = std::fs::write(&target, &bytes) {
425                        report
426                            .refused
427                            .push((entry.path.clone(), format!("write failed: {e}")));
428                        continue;
429                    }
430                }
431                None if target.exists() => {
432                    if let Err(e) = std::fs::remove_file(&target) {
433                        report
434                            .refused
435                            .push((entry.path.clone(), format!("delete failed: {e}")));
436                        continue;
437                    }
438                }
439                None => {}
440            }
441            report.restored.push(entry.path.clone());
442        }
443        Ok(report)
444    }
445
446    /// Bounded-disk requirement: keep only the `keep` newest checkpoints,
447    /// deleting the rest, then GC any blob no longer referenced by a
448    /// surviving manifest. Returns the number of checkpoints removed.
449    pub fn prune(&self, keep: usize) -> Result<usize> {
450        let mut metas = self.list()?; // newest first
451        if metas.len() <= keep {
452            return Ok(0);
453        }
454        let stale = metas.split_off(keep);
455        let removed = stale.len();
456        for m in stale {
457            let _ = std::fs::remove_file(self.manifest_path(&m.id));
458        }
459        self.gc_unreferenced_blobs()?;
460        Ok(removed)
461    }
462
463    /// Delete every blob under `objects/` not referenced by ANY surviving
464    /// manifest — a full scan, not a refcount (simplest correct form; the
465    /// retention bound keeps this cheap in practice — see [`Self::prune`]'s
466    /// doc comment).
467    fn gc_unreferenced_blobs(&self) -> Result<()> {
468        let mut referenced: HashSet<String> = HashSet::new();
469        for meta in self.list()? {
470            if let Ok(m) = self.manifest(&meta.id) {
471                for f in m.files {
472                    if let Some(b) = f.blob {
473                        referenced.insert(b);
474                    }
475                }
476            }
477        }
478        let objects = self.objects_dir();
479        if !objects.exists() {
480            return Ok(());
481        }
482        for entry in std::fs::read_dir(&objects)? {
483            let entry = entry?;
484            if !entry.file_type()?.is_dir() {
485                continue;
486            }
487            for inner in std::fs::read_dir(entry.path())? {
488                let inner = inner?;
489                let name = inner.file_name();
490                let Some(name) = name.to_str() else {
491                    continue;
492                };
493                if name.starts_with(".tmp-") {
494                    continue; // an in-flight write, not ours to reap
495                }
496                if !referenced.contains(name) {
497                    let _ = std::fs::remove_file(inner.path());
498                }
499            }
500        }
501        Ok(())
502    }
503}
504
505// SECURITY (safe-path consolidation, CRITICAL fix): every primitive below
506// used to be defined HERE, locally — this module's own P5-9 fix for the
507// `.git`-clobber bug class. It is now `crate::safe_path`'s canonical
508// implementation instead, with checkpoint DELEGATING to it (thin wrappers,
509// same names, same signatures, same behavior) so `crate::permissions`'s
510// gate and `crate::tools`'s sandbox containment reuse this exact proven
511// logic rather than each re-implementing (and, in the permissions gate's
512// case, getting wrong) their own. See `crate::safe_path`'s module doc
513// comment for the full story and the two-views-of-a-path explanation every
514// doc comment below used to carry inline.
515//
516// Every one of this module's 23 tests (see `tests` below) still exercises
517// these names directly and is UNCHANGED — that is the proof this extraction
518// is behavior-preserving.
519
520/// Is `rel` (a project-relative, `/`-separated path) a hard floor this
521/// module refuses to snapshot INTO or restore OVER, regardless of config?
522/// `.git` (and everything under it) is unconditional — checkpoint must
523/// never touch the user's real git repo even if `permissions.protected_paths`
524/// (module 13) is off. `extra_globs` layers `Config::permissions_protected_paths`
525/// on top when the caller has one (restore only — capture-time protection
526/// is already covered because [`CheckpointObserver::before_write`] only
527/// ever fires for a write the P5-1 permission gate already approved, which
528/// already folds `protected_paths` in — see that method's own doc comment).
529/// See `crate::safe_path::is_protected` for the implementation.
530fn is_protected(rel: &str, extra_globs: &[String]) -> bool {
531    crate::safe_path::is_protected(rel, extra_globs)
532}
533
534/// Up-front rejection for a manifest-declared path that could never have
535/// come from a legitimate capture: `record_pre_image` only ever stores a
536/// clean, `/`-separated, project-relative path (see
537/// [`CheckpointFileEntry::path`]'s doc comment), so an absolute path or one
538/// containing a `..` (`ParentDir`), root, or Windows-prefix component is by
539/// definition hostile or corrupt. Returns the refusal reason, or `None` if
540/// `rel` is clean. Called BEFORE any raw-string pattern match (e.g.
541/// [`is_protected`]) so a traversal entry like `x/../.git/config` — which
542/// does not literally string-match the `.git/` floor — is refused before
543/// it can ever be compared against anything. See
544/// `crate::safe_path::reject_unsafe_rel_path` for the implementation.
545fn reject_unsafe_manifest_path(rel: &str) -> Option<String> {
546    crate::safe_path::reject_unsafe_rel_path(rel)
547}
548
549/// Lexically normalize `path` (expected to be `root.join(rel)` for some
550/// manifest-declared `rel`) via the SAME collapse [`contained`] uses, then —
551/// if the normalized form is still under `root` — return its
552/// project-relative, `/`-separated tail. Callers (currently only
553/// [`CheckpointStore::restore`]) compute this ONCE per entry and feed the
554/// single result to [`is_protected`], so the protected-path floor sees
555/// EXACTLY the same normalized path [`contained`]'s containment check
556/// computes. See `crate::safe_path::normalized_project_rel` for the
557/// implementation.
558fn normalized_project_rel(root: &Path, path: &Path) -> Option<String> {
559    crate::safe_path::normalized_project_rel(root, path)
560}
561
562/// Symlink- and traversal-safe containment check: is `path` (absolute,
563/// possibly not-yet-existing) confined under `root`? Fails closed (`false`)
564/// on any resolution error. See `crate::safe_path::contained` for the
565/// implementation.
566fn contained(root: &Path, path: &Path) -> bool {
567    crate::safe_path::contained(root, path)
568}
569
570/// Resolved (symlink-following) counterpart to [`normalized_project_rel`]:
571/// canonicalizes `path`'s longest existing ancestor (resolving any symlink
572/// along the way — see `crate::safe_path::resolve_real`) and returns the
573/// resulting absolute path's tail relative to `root`'s own canonical form, as a
574/// `/`-separated string — or `None` if resolution fails, or the resolved
575/// path lands outside `root` entirely (that case is already refused by
576/// [`contained`]; this function only needs to report a rel path when
577/// there IS one). [`CheckpointStore::restore`] feeds this to
578/// [`is_protected`] IN ADDITION TO the lexical [`normalized_project_rel`]
579/// result, closing the gap where a symlink resolves into `.git` (or a
580/// `protected_globs` match) even though its LEXICAL path never mentions
581/// `.git` at all and it never leaves `root` (so `contained` alone would
582/// accept it) — see the call site's doc comment for the exact repro this
583/// closes. See `crate::safe_path::resolved_project_rel` for the
584/// implementation.
585fn resolved_project_rel(root: &Path, path: &Path) -> Option<String> {
586    crate::safe_path::resolved_project_rel(root, path)
587}
588
589#[derive(Debug, Default)]
590struct ObserverState {
591    /// The checkpoint currently open for the in-flight turn.
592    current: Option<CheckpointId>,
593    /// Project-relative paths already captured under `current` — first
594    /// write per turn wins the pre-image; every write after the first is a
595    /// no-op capture (the manifest already holds the earliest state).
596    captured: HashSet<String>,
597}
598
599/// [`WriteObserver`] implementation backing `[capabilities.checkpoint]`.
600/// One instance per [`crate::Agent`] (installed on its [`crate::tools::ToolContext`]
601/// AND held directly so `crate::Agent::run_loop` can call
602/// [`Self::begin_turn`] once per user turn — see that method's call site's
603/// doc comment).
604#[derive(Debug)]
605pub struct CheckpointObserver {
606    store: CheckpointStore,
607    project_root: PathBuf,
608    retain: usize,
609    protected: Vec<String>,
610    state: Mutex<ObserverState>,
611    /// Set once on any I/O failure — graceful degrade (§ "git-absent /
612    /// unwritable state dir ⇒ warn + disable, never crash"), not a panic
613    /// and not a blocked tool call. `Relaxed` throughout: this is a
614    /// best-effort circuit breaker, not a correctness-critical ordering.
615    disabled: AtomicBool,
616}
617
618impl CheckpointObserver {
619    /// `protected` layers extra glob patterns (typically
620    /// `Config::permissions_protected_paths`, when module 13 is active) on
621    /// top of the unconditional `.git/**` floor — consulted by
622    /// [`Self::restore`].
623    pub fn new(
624        store: CheckpointStore,
625        project_root: PathBuf,
626        retain: usize,
627        protected: Vec<String>,
628    ) -> Self {
629        CheckpointObserver {
630            store,
631            project_root,
632            retain: retain.max(1),
633            protected,
634            state: Mutex::new(ObserverState::default()),
635            disabled: AtomicBool::new(false),
636        }
637    }
638
639    /// Read-only access to the underlying store (e.g. so a caller can
640    /// `list`/`turn_diff`/`restore` without re-deriving the root path).
641    pub fn store(&self) -> &CheckpointStore {
642        &self.store
643    }
644
645    /// List every checkpoint, newest first — see [`CheckpointStore::list`].
646    pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
647        self.store.list()
648    }
649
650    /// D3 turn-diff for one checkpoint — see [`CheckpointStore::turn_diff`].
651    pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
652        self.store.turn_diff(id)
653    }
654
655    /// D4-adjacent revert: restore this project's working files to
656    /// checkpoint `id`, honoring THIS observer's own `project_root` and
657    /// `protected` globs (the fields set at construction) — see
658    /// [`CheckpointStore::restore`] for the full security contract.
659    pub fn restore(&self, id: &str) -> Result<RestoreReport> {
660        self.store.restore(id, &self.project_root, &self.protected)
661    }
662
663    /// Whether this observer has disabled itself after an I/O failure.
664    pub fn is_disabled(&self) -> bool {
665        self.disabled.load(Ordering::Relaxed)
666    }
667
668    /// Open a fresh checkpoint for a new turn — called once at the top of
669    /// `crate::Agent::run_loop` (i.e. once per `Agent::send`/
670    /// `send_with_files`/`send_with_images` call, cc's "per-prompt
671    /// file-history-snapshot"). `label` is a short excerpt of the turn's
672    /// prompt, display-only. Also prunes past the retention bound here
673    /// (once per turn, not once per write) — see [`CheckpointStore::prune`].
674    /// Returns `None` when disabled (config-off is never routed here at
675    /// all — see [`observer_for_config`] — so `None` here specifically
676    /// means an I/O failure already tripped the breaker).
677    pub fn begin_turn(&self, label: &str) -> Option<CheckpointId> {
678        if self.disabled.load(Ordering::Relaxed) {
679            return None;
680        }
681        let short: String = label.chars().take(120).collect();
682        match self.store.create_checkpoint(&short) {
683            Ok(id) => {
684                if let Ok(mut st) = self.state.lock() {
685                    st.current = Some(id.clone());
686                    st.captured.clear();
687                }
688                if let Err(e) = self.store.prune(self.retain) {
689                    eprintln!("warning: checkpoint: prune failed: {e}");
690                }
691                Some(id)
692            }
693            Err(e) => {
694                eprintln!(
695                    "warning: checkpoint disabled for the rest of this session — \
696                     failed to open a new checkpoint: {e}"
697                );
698                self.disabled.store(true, Ordering::Relaxed);
699                None
700            }
701        }
702    }
703
704    /// The checkpoint currently open for the in-flight turn, if any.
705    pub fn current(&self) -> Option<CheckpointId> {
706        self.state.lock().ok().and_then(|s| s.current.clone())
707    }
708}
709
710#[async_trait::async_trait]
711impl WriteObserver for CheckpointObserver {
712    async fn before_write(&self, path: &Path) {
713        if self.disabled.load(Ordering::Relaxed) {
714            return;
715        }
716        if !contained(&self.project_root, path) {
717            // Outside the project root — out of this module's scope (§ "an
718            // honest gap", never a crash or a wrong snapshot).
719            return;
720        }
721        let Some(normalized) = crate::tools::normalize(path) else {
722            return;
723        };
724        let Some(root_normalized) = crate::tools::normalize(&self.project_root) else {
725            return;
726        };
727        let Ok(rel_path) = normalized.strip_prefix(&root_normalized) else {
728            return;
729        };
730        let rel = rel_path.to_string_lossy().replace('\\', "/");
731        if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
732            return;
733        }
734
735        // The ENTIRE check-read-record sequence runs under one lock, so two
736        // concurrent writes to the SAME path (e.g. a `run_tools_concurrently`
737        // batch, or a P5-6 background job racing a foreground write) can
738        // never both read-and-lose a torn pre-image: the second one to
739        // arrive here always sees `captured` already contains `rel` and
740        // skips entirely, never re-reading a post-first-write state.
741        let mut st = match self.state.lock() {
742            Ok(g) => g,
743            Err(poisoned) => poisoned.into_inner(),
744        };
745        if st.current.is_none() {
746            // A write reached this seam with no open turn (e.g. a caller
747            // driving the tool registry directly, outside `Agent::send`) —
748            // self-heal with an ad-hoc checkpoint rather than silently
749            // dropping the capture. `begin_turn` takes this same lock, so
750            // it must be called with `st` released first.
751            drop(st);
752            self.begin_turn("untracked");
753            st = match self.state.lock() {
754                Ok(g) => g,
755                Err(poisoned) => poisoned.into_inner(),
756            };
757        }
758        if st.captured.contains(&rel) {
759            return;
760        }
761        let Some(id) = st.current.clone() else {
762            return; // begin_turn's own failure already warned + disabled
763        };
764        let content = std::fs::read(path).ok(); // None => doesn't exist yet (a create)
765        match self.store.record_pre_image(&id, &rel, content) {
766            Ok(()) => {
767                st.captured.insert(rel);
768            }
769            Err(e) => {
770                eprintln!(
771                    "warning: checkpoint disabled for the rest of this session — \
772                     failed to record a snapshot: {e}"
773                );
774                self.disabled.store(true, Ordering::Relaxed);
775            }
776        }
777    }
778
779    async fn after_write(&self, _path: &Path) -> Option<String> {
780        // P5-11: `formatters`/`lsp` now occupy this hook (via
781        // `crate::tools::WriteObserverChain`, installed AFTER this observer
782        // in `crate::agent::build_tool_context`'s chain) — checkpoint itself
783        // still has nothing to do after a write completes, and returning
784        // `None` keeps the tool-result text this hook contributes
785        // byte-identical to before P5-11 whenever checkpoint is the only
786        // observer installed.
787        None
788    }
789}
790
791/// A stable per-project-directory tag — the same hash-of-canonicalized-cwd
792/// idea `crates/cli/src/main.rs::cwd_tag` uses for session naming, kept
793/// separately here (a `core`-crate concern, and `cli` depends on `core` not
794/// the reverse) so two different projects never share one shadow store even
795/// though they'd otherwise both resolve to the same `$SUPERCODE_HOME`-
796/// derived parent directory.
797fn project_tag(cwd: &Path) -> String {
798    use std::hash::{Hash, Hasher};
799    let canon = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
800    let mut h = std::collections::hash_map::DefaultHasher::new();
801    canon.hash(&mut h);
802    format!("{:016x}", h.finish())
803}
804
805/// The DEFAULT shadow-store root for `config.cwd` when
806/// [`crate::Config::checkpoint_dir`] is unset: `$SUPERCODE_HOME/checkpoints/<project_tag>`
807/// (`crate::agent::global_instructions_dir` is the same `$SUPERCODE_HOME`
808/// resolver the global instruction tier and `checkpoint` both use).
809fn default_shadow_root(cwd: &Path) -> PathBuf {
810    crate::agent::global_instructions_dir()
811        .join("checkpoints")
812        .join(project_tag(cwd))
813}
814
815/// Build the [`CheckpointObserver`] a fresh [`crate::Agent`] should install,
816/// given a resolved [`crate::Config`] — called once, from
817/// `crate::agent::build_tool_context`. `Config::checkpoint_enabled` is the
818/// ONE gate: `false` (the default) returns `None` WITHOUT touching the
819/// filesystem at all (no `CheckpointStore::open`, no directory created) —
820/// the default-off byte-identity guarantee. `true` opens (creating if
821/// needed) the shadow store at `Config::checkpoint_dir`, or
822/// `default_shadow_root` when that's unset; an I/O failure (unwritable
823/// state dir) is reported via a one-time `eprintln!` warning and returns
824/// `None` — graceful degrade, never a crash, never a blocked `Agent::new`.
825pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<CheckpointObserver>> {
826    if !config.checkpoint_enabled {
827        return None;
828    }
829    let root = config
830        .checkpoint_dir
831        .clone()
832        .unwrap_or_else(|| default_shadow_root(&config.cwd));
833    match CheckpointStore::open(&root) {
834        Ok(store) => Some(std::sync::Arc::new(CheckpointObserver::new(
835            store,
836            config.cwd.clone(),
837            config.checkpoint_retain,
838            config.permissions_protected_paths.clone(),
839        ))),
840        Err(e) => {
841            eprintln!(
842                "warning: [capabilities.checkpoint] is enabled but the shadow store at \
843                 {} could not be opened — checkpoint is disabled for this session: {e}",
844                root.display()
845            );
846            None
847        }
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    fn tmp(tag: &str) -> PathBuf {
856        let dir = std::env::temp_dir().join(format!(
857            "supercode-checkpoint-test-{tag}-{}-{}",
858            std::process::id(),
859            mint_id()
860        ));
861        std::fs::create_dir_all(&dir).unwrap();
862        dir
863    }
864
865    #[test]
866    fn open_creates_objects_and_checkpoints_dirs() {
867        let root = tmp("open");
868        let store = CheckpointStore::open(&root).unwrap();
869        assert!(root.join("objects").is_dir());
870        assert!(root.join("checkpoints").is_dir());
871        drop(store);
872        std::fs::remove_dir_all(&root).ok();
873    }
874
875    #[test]
876    fn create_list_and_manifest_round_trip() {
877        let root = tmp("list");
878        let store = CheckpointStore::open(&root).unwrap();
879        let id1 = store.create_checkpoint("first turn").unwrap();
880        std::thread::sleep(std::time::Duration::from_millis(2));
881        let id2 = store.create_checkpoint("second turn").unwrap();
882        let metas = store.list().unwrap();
883        assert_eq!(metas.len(), 2);
884        // newest first.
885        assert_eq!(metas[0].id, id2);
886        assert_eq!(metas[1].id, id1);
887        assert_eq!(metas[0].label, "second turn");
888        let m = store.manifest(&id1).unwrap();
889        assert_eq!(m.id, id1);
890        assert!(m.files.is_empty());
891        std::fs::remove_dir_all(&root).ok();
892    }
893
894    #[test]
895    fn record_pre_image_is_idempotent_keeping_the_earliest() {
896        let root = tmp("idempotent");
897        let store = CheckpointStore::open(&root).unwrap();
898        let id = store.create_checkpoint("t").unwrap();
899        store
900            .record_pre_image(&id, "a.txt", Some(b"first".to_vec()))
901            .unwrap();
902        // A second capture of the SAME path (e.g. a second edit later in
903        // the same turn) must NOT overwrite the first pre-image.
904        store
905            .record_pre_image(&id, "a.txt", Some(b"second".to_vec()))
906            .unwrap();
907        let m = store.manifest(&id).unwrap();
908        assert_eq!(m.files.len(), 1);
909        let bytes = store
910            .read_blob(m.files[0].blob.as_deref().unwrap())
911            .unwrap();
912        assert_eq!(bytes, b"first");
913        std::fs::remove_dir_all(&root).ok();
914    }
915
916    #[test]
917    fn blob_dedup_two_identical_contents_share_one_object() {
918        let root = tmp("dedup");
919        let store = CheckpointStore::open(&root).unwrap();
920        let id = store.create_checkpoint("t").unwrap();
921        store
922            .record_pre_image(&id, "a.txt", Some(b"same".to_vec()))
923            .unwrap();
924        store
925            .record_pre_image(&id, "b.txt", Some(b"same".to_vec()))
926            .unwrap();
927        let m = store.manifest(&id).unwrap();
928        assert_eq!(m.files[0].blob, m.files[1].blob);
929        // Exactly one blob file under objects/.
930        let mut count = 0;
931        for entry in walkdir(&root.join("objects")) {
932            if entry.is_file() && !entry.to_string_lossy().contains(".tmp-") {
933                count += 1;
934            }
935        }
936        assert_eq!(count, 1);
937        std::fs::remove_dir_all(&root).ok();
938    }
939
940    fn walkdir(dir: &Path) -> Vec<PathBuf> {
941        let mut out = Vec::new();
942        let Ok(rd) = std::fs::read_dir(dir) else {
943            return out;
944        };
945        for entry in rd.flatten() {
946            let p = entry.path();
947            if p.is_dir() {
948                out.extend(walkdir(&p));
949            } else {
950                out.push(p);
951            }
952        }
953        out
954    }
955
956    #[test]
957    fn restore_rewrites_modified_and_deletes_created_files() {
958        let root = tmp("restore");
959        let project = tmp("restore-project");
960        std::fs::write(project.join("existing.txt"), "modified").unwrap();
961        let store = CheckpointStore::open(&root).unwrap();
962        let id = store.create_checkpoint("t").unwrap();
963        // existing.txt existed before with "original".
964        store
965            .record_pre_image(&id, "existing.txt", Some(b"original".to_vec()))
966            .unwrap();
967        // new.txt did NOT exist before (a create this turn).
968        store.record_pre_image(&id, "new.txt", None).unwrap();
969        std::fs::write(project.join("new.txt"), "brand new").unwrap();
970
971        let report = store.restore(&id, &project, &[]).unwrap();
972        assert!(report.refused.is_empty(), "{:?}", report.refused);
973        assert_eq!(report.restored.len(), 2);
974        assert_eq!(
975            std::fs::read_to_string(project.join("existing.txt")).unwrap(),
976            "original"
977        );
978        assert!(!project.join("new.txt").exists());
979        std::fs::remove_dir_all(&root).ok();
980        std::fs::remove_dir_all(&project).ok();
981    }
982
983    #[test]
984    fn restore_refuses_a_manifest_entry_that_traverses_outside_the_project_root() {
985        // Hostile test (spec requirement): a checkpoint whose manifest
986        // (however it got there) names a `..`-traversing path must be
987        // refused, never applied — restore independently re-validates
988        // every target, it does not trust the manifest.
989        let root = tmp("hostile");
990        let project = tmp("hostile-project");
991        std::fs::create_dir_all(&project).unwrap();
992        let store = CheckpointStore::open(&root).unwrap();
993        let id = store.create_checkpoint("t").unwrap();
994        // Hand-craft a manifest with a traversal path directly (bypassing
995        // `record_pre_image`'s own normal, honest callers).
996        let mut m = store.manifest(&id).unwrap();
997        m.files.push(CheckpointFileEntry {
998            path: "../../../../../../etc/passwd-supercode-test".to_string(),
999            blob: None,
1000        });
1001        store.write_manifest(&m).unwrap();
1002
1003        let victim = project
1004            .parent()
1005            .unwrap()
1006            .parent()
1007            .unwrap()
1008            .join("etc/passwd-supercode-test");
1009        assert!(
1010            !victim.exists(),
1011            "test precondition: victim path must not already exist"
1012        );
1013
1014        let report = store.restore(&id, &project, &[]).unwrap();
1015        assert_eq!(report.restored.len(), 0);
1016        assert_eq!(report.refused.len(), 1);
1017        assert!(report.refused[0].1.contains("escapes"));
1018        assert!(
1019            !victim.exists(),
1020            "restore must never have written outside the project root"
1021        );
1022        std::fs::remove_dir_all(&root).ok();
1023        std::fs::remove_dir_all(&project).ok();
1024    }
1025
1026    #[test]
1027    fn restore_refuses_dot_git_unconditionally() {
1028        let root = tmp("gitfloor");
1029        let project = tmp("gitfloor-project");
1030        std::fs::create_dir_all(project.join(".git")).unwrap();
1031        std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1032        let store = CheckpointStore::open(&root).unwrap();
1033        let id = store.create_checkpoint("t").unwrap();
1034        let mut m = store.manifest(&id).unwrap();
1035        m.files.push(CheckpointFileEntry {
1036            path: ".git/config".to_string(),
1037            blob: None,
1038        });
1039        store.write_manifest(&m).unwrap();
1040
1041        let report = store.restore(&id, &project, &[]).unwrap();
1042        assert_eq!(report.restored.len(), 0);
1043        assert_eq!(report.refused.len(), 1);
1044        assert!(report.refused[0].1.contains("protected"));
1045        assert_eq!(
1046            std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1047            "real git config",
1048            "the real .git must be untouched"
1049        );
1050        std::fs::remove_dir_all(&root).ok();
1051        std::fs::remove_dir_all(&project).ok();
1052    }
1053
1054    #[test]
1055    fn restore_honors_extra_protected_globs() {
1056        let root = tmp("protectedglob");
1057        let project = tmp("protectedglob-project");
1058        std::fs::write(project.join(".env"), "SECRET=1").unwrap();
1059        let store = CheckpointStore::open(&root).unwrap();
1060        let id = store.create_checkpoint("t").unwrap();
1061        let mut m = store.manifest(&id).unwrap();
1062        m.files.push(CheckpointFileEntry {
1063            path: ".env".to_string(),
1064            blob: Some(store.write_blob(b"OLD=1").unwrap()),
1065        });
1066        store.write_manifest(&m).unwrap();
1067
1068        let report = store
1069            .restore(&id, &project, &[".env*".to_string()])
1070            .unwrap();
1071        assert_eq!(report.restored.len(), 0);
1072        assert_eq!(report.refused.len(), 1);
1073        assert_eq!(
1074            std::fs::read_to_string(project.join(".env")).unwrap(),
1075            "SECRET=1"
1076        );
1077        std::fs::remove_dir_all(&root).ok();
1078        std::fs::remove_dir_all(&project).ok();
1079    }
1080
1081    #[test]
1082    fn restore_refuses_traversal_into_dot_git_real_git_config_stays_untouched() {
1083        // The exact reviewer repro: a manifest entry `x/../.git/config`
1084        // does NOT string-match the raw `.git/` floor (it literally starts
1085        // with `x/`), but lexically normalizes right back into
1086        // `<root>/.git/config`. Pre-fix, `is_protected` (raw string) said
1087        // "not protected" while `contained` (normalized) said "inside
1088        // root" — so restore wrote straight into the real `.git`. Post-fix
1089        // the up-front `..`-rejection refuses this before either check
1090        // runs, and even if that were bypassed, `is_protected` now runs on
1091        // the SAME normalized path `contained` uses.
1092        let root = tmp("traversal-git");
1093        let project = tmp("traversal-git-project");
1094        std::fs::create_dir_all(project.join(".git")).unwrap();
1095        std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1096        let store = CheckpointStore::open(&root).unwrap();
1097        let id = store.create_checkpoint("t").unwrap();
1098        let mut m = store.manifest(&id).unwrap();
1099        m.files.push(CheckpointFileEntry {
1100            path: "x/../.git/config".to_string(),
1101            blob: Some(store.write_blob(b"PWNED-by-traversal").unwrap()),
1102        });
1103        store.write_manifest(&m).unwrap();
1104
1105        let report = store.restore(&id, &project, &[]).unwrap();
1106        assert_eq!(report.restored.len(), 0, "must not restore into .git");
1107        assert_eq!(report.refused.len(), 1);
1108        assert!(
1109            report.refused[0].1.contains("escapes"),
1110            "unexpected refusal reason: {}",
1111            report.refused[0].1
1112        );
1113        assert_eq!(
1114            std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1115            "real git config",
1116            "the real .git/config must be untouched by the traversal entry"
1117        );
1118        std::fs::remove_dir_all(&root).ok();
1119        std::fs::remove_dir_all(&project).ok();
1120    }
1121
1122    #[test]
1123    fn restore_refuses_traversal_bypass_of_protected_globs_dot_env_stays_untouched() {
1124        // Same bypass shape as the `.git` floor, but against
1125        // `protected_globs` this time: `x/../.env` doesn't glob-match
1126        // `.env*` as a raw string, but normalizes right back into
1127        // `<root>/.env`.
1128        let root = tmp("traversal-env");
1129        let project = tmp("traversal-env-project");
1130        std::fs::create_dir_all(&project).unwrap();
1131        std::fs::write(project.join(".env"), "SECRET=1").unwrap();
1132        let store = CheckpointStore::open(&root).unwrap();
1133        let id = store.create_checkpoint("t").unwrap();
1134        let mut m = store.manifest(&id).unwrap();
1135        m.files.push(CheckpointFileEntry {
1136            path: "x/../.env".to_string(),
1137            blob: Some(store.write_blob(b"PWNED=1").unwrap()),
1138        });
1139        store.write_manifest(&m).unwrap();
1140
1141        let report = store
1142            .restore(&id, &project, &[".env*".to_string()])
1143            .unwrap();
1144        assert_eq!(report.restored.len(), 0);
1145        assert_eq!(report.refused.len(), 1);
1146        assert_eq!(
1147            std::fs::read_to_string(project.join(".env")).unwrap(),
1148            "SECRET=1",
1149            "the real .env must be untouched by the traversal entry"
1150        );
1151        std::fs::remove_dir_all(&root).ok();
1152        std::fs::remove_dir_all(&project).ok();
1153    }
1154
1155    #[test]
1156    fn restore_refuses_an_absolute_manifest_path() {
1157        // An absolute manifest entry is never something a legitimate
1158        // capture produces (`record_pre_image` always stores a clean
1159        // relative path) — even one that happens to point AT a path
1160        // inside the project (here, the real `.git/config`) must be
1161        // refused up front, not evaluated by whatever it happens to
1162        // resolve to. (Pre-fix: `Path::join` REPLACES the base when the
1163        // joined component is absolute, so this entry's raw string never
1164        // matched the `.git/` floor and its target — being genuinely
1165        // inside root — passed `contained` too: a second, independent
1166        // bypass of the same floor.)
1167        let root = tmp("absolute");
1168        let project = tmp("absolute-project");
1169        std::fs::create_dir_all(project.join(".git")).unwrap();
1170        std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1171        let store = CheckpointStore::open(&root).unwrap();
1172        let id = store.create_checkpoint("t").unwrap();
1173        let absolute_git_config = project.join(".git").join("config");
1174        let mut m = store.manifest(&id).unwrap();
1175        m.files.push(CheckpointFileEntry {
1176            path: absolute_git_config.to_string_lossy().to_string(),
1177            blob: Some(store.write_blob(b"PWNED-by-absolute-path").unwrap()),
1178        });
1179        store.write_manifest(&m).unwrap();
1180
1181        let report = store.restore(&id, &project, &[]).unwrap();
1182        assert_eq!(report.restored.len(), 0);
1183        assert_eq!(report.refused.len(), 1);
1184        assert!(
1185            report.refused[0].1.contains("escapes"),
1186            "unexpected refusal reason: {}",
1187            report.refused[0].1
1188        );
1189        assert_eq!(
1190            std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1191            "real git config"
1192        );
1193        std::fs::remove_dir_all(&root).ok();
1194        std::fs::remove_dir_all(&project).ok();
1195    }
1196
1197    #[test]
1198    fn restore_still_restores_a_clean_relative_nested_entry() {
1199        // Proves the fix didn't over-block honest captures: a normal
1200        // capture -> restore round trip — including a nested directory, so
1201        // the normalize step's `strip_prefix` is exercised on a
1202        // multi-component path — still restores.
1203        let root = tmp("cleanroundtrip");
1204        let project = tmp("cleanroundtrip-project");
1205        std::fs::create_dir_all(project.join("src")).unwrap();
1206        std::fs::write(project.join("src").join("main.rs"), "fn main() {}").unwrap();
1207        let store = CheckpointStore::open(&root).unwrap();
1208        let id = store.create_checkpoint("t").unwrap();
1209        store
1210            .record_pre_image(&id, "src/main.rs", Some(b"fn old() {}".to_vec()))
1211            .unwrap();
1212
1213        let report = store.restore(&id, &project, &[]).unwrap();
1214        assert!(report.refused.is_empty(), "{:?}", report.refused);
1215        assert_eq!(report.restored, vec!["src/main.rs".to_string()]);
1216        assert_eq!(
1217            std::fs::read_to_string(project.join("src").join("main.rs")).unwrap(),
1218            "fn old() {}"
1219        );
1220        std::fs::remove_dir_all(&root).ok();
1221        std::fs::remove_dir_all(&project).ok();
1222    }
1223
1224    #[test]
1225    #[cfg(unix)]
1226    fn restore_refuses_symlink_traversal_into_dot_git() {
1227        // The exact reviewer repro this unit fixes: `is_protected` ran on
1228        // the LEXICAL normalized path (never resolves symlinks) while
1229        // `contained` CANONICALIZES (resolves symlinks). A pre-existing
1230        // symlink `foo -> .git` in the working tree plus a manifest entry
1231        // `foo/config`:
1232        //  - passes `reject_unsafe_manifest_path` (no `..`/absolute);
1233        //  - lexically normalizes to `foo/config` — `is_protected` says
1234        //    NOT protected (no literal `.git/` prefix);
1235        //  - `contained` canonicalizes `foo`, resolving the symlink to the
1236        //    real `<root>/.git`, which IS inside `real_root` — containment
1237        //    PASSES.
1238        // Pre-fix, restore then wrote straight into the real `.git/config`.
1239        // Post-fix, `resolved_project_rel` also runs `is_protected` on the
1240        // symlink-RESOLVED path (`.git/config`), so this is refused.
1241        let root = tmp("symlink-traversal");
1242        let project = tmp("symlink-traversal-project");
1243        std::fs::create_dir_all(project.join(".git")).unwrap();
1244        std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1245        std::os::unix::fs::symlink(project.join(".git"), project.join("foo")).unwrap();
1246
1247        let store = CheckpointStore::open(&root).unwrap();
1248        let id = store.create_checkpoint("t").unwrap();
1249        let mut m = store.manifest(&id).unwrap();
1250        m.files.push(CheckpointFileEntry {
1251            path: "foo/config".to_string(),
1252            blob: Some(store.write_blob(b"PWNED-VIA-SYMLINK").unwrap()),
1253        });
1254        store.write_manifest(&m).unwrap();
1255
1256        let report = store.restore(&id, &project, &[]).unwrap();
1257        assert_eq!(
1258            report.restored.len(),
1259            0,
1260            "must not restore through the symlink into .git"
1261        );
1262        assert_eq!(report.refused.len(), 1);
1263        assert!(
1264            report.refused[0].1.contains("protected"),
1265            "unexpected refusal reason: {}",
1266            report.refused[0].1
1267        );
1268        assert_eq!(
1269            std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1270            "real git config",
1271            "the real .git/config must be byte-identical — untouched by the symlink entry"
1272        );
1273        std::fs::remove_dir_all(&root).ok();
1274        std::fs::remove_dir_all(&project).ok();
1275    }
1276
1277    #[test]
1278    #[cfg(unix)]
1279    fn restore_refuses_symlink_traversal_into_a_protected_glob() {
1280        // Same shape as the `.git` floor above, but against a caller-
1281        // supplied `protected_globs` entry: a symlink `secrets -> real_env`
1282        // where `real_env/creds` is a file that, once resolved, matches
1283        // the `real_env/**` protected glob — even though the manifest's
1284        // lexical path (`secrets/creds`) does not.
1285        let root = tmp("symlink-glob");
1286        let project = tmp("symlink-glob-project");
1287        std::fs::create_dir_all(project.join("real_env")).unwrap();
1288        std::fs::write(project.join("real_env").join("creds"), "real secret").unwrap();
1289        std::os::unix::fs::symlink(project.join("real_env"), project.join("secrets")).unwrap();
1290
1291        let store = CheckpointStore::open(&root).unwrap();
1292        let id = store.create_checkpoint("t").unwrap();
1293        let mut m = store.manifest(&id).unwrap();
1294        m.files.push(CheckpointFileEntry {
1295            path: "secrets/creds".to_string(),
1296            blob: Some(store.write_blob(b"PWNED-VIA-SYMLINK-GLOB").unwrap()),
1297        });
1298        store.write_manifest(&m).unwrap();
1299
1300        let report = store
1301            .restore(&id, &project, &["real_env/**".to_string()])
1302            .unwrap();
1303        assert_eq!(report.restored.len(), 0);
1304        assert_eq!(report.refused.len(), 1);
1305        assert!(
1306            report.refused[0].1.contains("protected"),
1307            "unexpected refusal reason: {}",
1308            report.refused[0].1
1309        );
1310        assert_eq!(
1311            std::fs::read_to_string(project.join("real_env").join("creds")).unwrap(),
1312            "real secret",
1313            "the real protected file must be untouched by the symlink entry"
1314        );
1315        std::fs::remove_dir_all(&root).ok();
1316        std::fs::remove_dir_all(&project).ok();
1317    }
1318
1319    #[test]
1320    fn prune_keeps_only_the_newest_and_gcs_unreferenced_blobs() {
1321        let root = tmp("prune");
1322        let store = CheckpointStore::open(&root).unwrap();
1323        for i in 0..5 {
1324            let id = store.create_checkpoint(&format!("turn {i}")).unwrap();
1325            store
1326                .record_pre_image(&id, "f.txt", Some(format!("content-{i}").into_bytes()))
1327                .unwrap();
1328            std::thread::sleep(std::time::Duration::from_millis(2));
1329        }
1330        assert_eq!(store.list().unwrap().len(), 5);
1331        let removed = store.prune(2).unwrap();
1332        assert_eq!(removed, 3);
1333        let remaining = store.list().unwrap();
1334        assert_eq!(remaining.len(), 2);
1335        // The two newest survive (turn 4, turn 3).
1336        assert_eq!(remaining[0].label, "turn 4");
1337        assert_eq!(remaining[1].label, "turn 3");
1338        // Every surviving blob is still readable; nothing else lingers.
1339        for meta in &remaining {
1340            let m = store.manifest(&meta.id).unwrap();
1341            for f in &m.files {
1342                if let Some(hash) = &f.blob {
1343                    store.read_blob(hash).unwrap();
1344                }
1345            }
1346        }
1347        std::fs::remove_dir_all(&root).ok();
1348    }
1349
1350    #[test]
1351    fn turn_diff_lists_exactly_the_captured_files() {
1352        let root = tmp("diff");
1353        let store = CheckpointStore::open(&root).unwrap();
1354        let id = store.create_checkpoint("t").unwrap();
1355        store.record_pre_image(&id, "a.rs", None).unwrap();
1356        store
1357            .record_pre_image(&id, "b.rs", Some(b"x".to_vec()))
1358            .unwrap();
1359        let mut diff = store.turn_diff(&id).unwrap();
1360        diff.sort();
1361        assert_eq!(diff, vec!["a.rs".to_string(), "b.rs".to_string()]);
1362        std::fs::remove_dir_all(&root).ok();
1363    }
1364
1365    #[test]
1366    fn manifest_rejects_a_path_traversing_id() {
1367        let root = tmp("badid");
1368        let store = CheckpointStore::open(&root).unwrap();
1369        assert!(store.manifest("../../etc/passwd").is_err());
1370        assert!(store.restore("../../etc/passwd", &root, &[]).is_err());
1371        std::fs::remove_dir_all(&root).ok();
1372    }
1373
1374    #[tokio::test]
1375    async fn observer_before_write_ignores_paths_outside_the_project_root() {
1376        let root = tmp("obs-outside");
1377        let project = tmp("obs-outside-project");
1378        let outside = tmp("obs-outside-elsewhere");
1379        std::fs::write(outside.join("victim.txt"), "do not touch").unwrap();
1380        let store = CheckpointStore::open(&root).unwrap();
1381        let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1382        observer.begin_turn("t");
1383        observer.before_write(&outside.join("victim.txt")).await;
1384        // Nothing captured: the checkpoint's manifest stays empty.
1385        let id = observer.current().unwrap();
1386        let diff = observer.store().turn_diff(&id).unwrap();
1387        assert!(
1388            diff.is_empty(),
1389            "must not capture writes outside project_root"
1390        );
1391        std::fs::remove_dir_all(&root).ok();
1392        std::fs::remove_dir_all(&project).ok();
1393        std::fs::remove_dir_all(&outside).ok();
1394    }
1395
1396    #[tokio::test]
1397    async fn observer_captures_only_the_first_write_to_a_path_in_a_turn() {
1398        let root = tmp("obs-firstwrite");
1399        let project = tmp("obs-firstwrite-project");
1400        std::fs::write(project.join("f.txt"), "v1").unwrap();
1401        let store = CheckpointStore::open(&root).unwrap();
1402        let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1403        observer.begin_turn("t");
1404        observer.before_write(&project.join("f.txt")).await;
1405        std::fs::write(project.join("f.txt"), "v2").unwrap();
1406        observer.before_write(&project.join("f.txt")).await; // second write, same turn
1407        let id = observer.current().unwrap();
1408        let m = observer.store().manifest(&id).unwrap();
1409        assert_eq!(m.files.len(), 1);
1410        let bytes = observer
1411            .store()
1412            .read_blob(m.files[0].blob.as_deref().unwrap())
1413            .unwrap();
1414        assert_eq!(bytes, b"v1", "must keep the EARLIEST pre-image, not v2");
1415        std::fs::remove_dir_all(&root).ok();
1416        std::fs::remove_dir_all(&project).ok();
1417    }
1418
1419    #[tokio::test]
1420    async fn observer_begin_turn_clears_captured_set_for_a_new_turn() {
1421        let root = tmp("obs-newturn");
1422        let project = tmp("obs-newturn-project");
1423        std::fs::write(project.join("f.txt"), "v1").unwrap();
1424        let store = CheckpointStore::open(&root).unwrap();
1425        let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1426        observer.begin_turn("turn 1");
1427        observer.before_write(&project.join("f.txt")).await;
1428        std::fs::write(project.join("f.txt"), "v2").unwrap();
1429        observer.begin_turn("turn 2");
1430        observer.before_write(&project.join("f.txt")).await;
1431        let id2 = observer.current().unwrap();
1432        let m2 = observer.store().manifest(&id2).unwrap();
1433        assert_eq!(m2.files.len(), 1);
1434        let bytes = observer
1435            .store()
1436            .read_blob(m2.files[0].blob.as_deref().unwrap())
1437            .unwrap();
1438        assert_eq!(
1439            bytes, b"v2",
1440            "turn 2's checkpoint must capture v2 as ITS pre-image"
1441        );
1442        std::fs::remove_dir_all(&root).ok();
1443        std::fs::remove_dir_all(&project).ok();
1444    }
1445
1446    #[test]
1447    fn is_protected_hard_floor_covers_dot_git_regardless_of_extra_globs() {
1448        assert!(is_protected(".git", &[]));
1449        assert!(is_protected(".git/config", &[]));
1450        assert!(is_protected(".git/objects/aa/bb", &[]));
1451        assert!(!is_protected(".gitignore", &[]));
1452        assert!(!is_protected("src/main.rs", &[]));
1453    }
1454
1455    #[test]
1456    fn contained_rejects_symlink_escape_for_an_existing_target() {
1457        let project = tmp("symlink-project");
1458        let outside = tmp("symlink-outside");
1459        std::fs::write(outside.join("secret.txt"), "s").unwrap();
1460        #[cfg(unix)]
1461        {
1462            std::os::unix::fs::symlink(outside.join("secret.txt"), project.join("link.txt"))
1463                .unwrap();
1464            assert!(!contained(&project, &project.join("link.txt")));
1465        }
1466        std::fs::remove_dir_all(&project).ok();
1467        std::fs::remove_dir_all(&outside).ok();
1468    }
1469
1470    #[test]
1471    fn contained_accepts_a_brand_new_file_inside_the_root() {
1472        let project = tmp("newfile-project");
1473        assert!(contained(&project, &project.join("does_not_exist_yet.txt")));
1474        assert!(contained(&project, &project.join("nested/dir/new.txt")));
1475        std::fs::remove_dir_all(&project).ok();
1476    }
1477}