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    /// BP-7 (catalog §4a "Turn diff tracking": "cumulative file-diff of
319    /// the turn"): the turn's cumulative UNIFIED DIFF, not just the file
320    /// list [`Self::turn_diff`] returns.
321    ///
322    /// Each manifest entry is a pre-image (`None` = the file did not exist
323    /// before the turn); the post-image is whatever is on disk under
324    /// `project_root` NOW (missing = the turn deleted it). So this is the
325    /// net effect of the whole turn — a file written three times in one
326    /// turn shows one diff from where it started to where it ended, which
327    /// is exactly what "cumulative" means and what cc's `/diff` and cx's
328    /// `turn_diff_tracker` show.
329    ///
330    /// Non-UTF-8 content on either side is reported as
331    /// `Binary files a/<path> and b/<path> differ` rather than being
332    /// diffed byte-wise into garbage; a file whose content is unchanged
333    /// contributes nothing. Entries are emitted in sorted path order so
334    /// the output is stable across runs.
335    pub fn turn_patch(&self, id: &str, project_root: &Path) -> Result<String> {
336        let mut entries = self.manifest(id)?.files;
337        entries.sort_by(|a, b| a.path.cmp(&b.path));
338        let mut out = String::new();
339        for entry in entries {
340            let before: Option<Vec<u8>> = match &entry.blob {
341                Some(blob) => Some(self.read_blob(blob)?),
342                None => None,
343            };
344            let after = std::fs::read(project_root.join(&entry.path)).ok();
345            if before == after {
346                continue;
347            }
348            let (Some(before_text), Some(after_text)) = (
349                decode_side(before.as_deref()),
350                decode_side(after.as_deref()),
351            ) else {
352                out.push_str(&format!(
353                    "Binary files a/{0} and b/{0} differ
354",
355                    entry.path
356                ));
357                continue;
358            };
359            let patch = diffy::create_patch(&before_text, &after_text);
360            out.push_str(&format!(
361                "--- a/{0}
362+++ b/{0}
363",
364                entry.path
365            ));
366            // `diffy` emits its own `---`/`+++` header lines with no file
367            // names; the project-relative pair above replaces them, so the
368            // output reads like an ordinary `git diff`.
369            for line in patch.to_string().lines().skip(2) {
370                out.push_str(line);
371                out.push('\n');
372            }
373        }
374        Ok(out)
375    }
376
377    /// D4-adjacent revert: restore `project_root`'s working files to
378    /// checkpoint `id`. For each manifest entry: `Some(blob)` rewrites the
379    /// file to that pre-image; `None` (didn't exist before the turn)
380    /// deletes it if present now (undoing a create). SECURITY: every
381    /// target is independently re-validated (never trusts the manifest was
382    /// produced honestly) against `project_root` containment (no symlink
383    /// escape, no `..` traversal) AND `protected_globs` (plus an
384    /// unconditional `.git/**` floor) checked against BOTH the LEXICAL
385    /// normalized path and the symlink-RESOLVED path — a lexical-only
386    /// check would miss a manifest entry like `foo/config` where `foo` is
387    /// a pre-existing symlink into `.git`: lexically it's clean, but it
388    /// resolves inside `root` (so containment alone accepts it too) and
389    /// lands on the real `.git/config`. A refused entry is recorded in
390    /// [`RestoreReport::refused`], never silently applied AND never aborts
391    /// the rest of the restore (partial-success, fully reported).
392    pub fn restore(
393        &self,
394        id: &str,
395        project_root: &Path,
396        protected_globs: &[String],
397    ) -> Result<RestoreReport> {
398        let manifest = self.manifest(id)?;
399        let mut report = RestoreReport::default();
400        for entry in &manifest.files {
401            // (1) Up-front rejection: a legitimately-captured entry (see
402            // `CheckpointFileEntry::path`'s doc comment) is always a clean
403            // relative path — `record_pre_image` never produces an absolute
404            // path or a `..` component. An entry that has one is by
405            // definition hostile or corrupt (traversal-injected, or a
406            // corrupted/copied/shared manifest — exactly this module's
407            // stated threat model) and must never reach a raw-string
408            // pattern match at all.
409            if let Some(reason) = reject_unsafe_manifest_path(&entry.path) {
410                report.refused.push((entry.path.clone(), reason));
411                continue;
412            }
413            let target = project_root.join(&entry.path);
414            // (2) Normalize-then-protect: compute the SAME lexically-
415            // normalized project-relative path `contained()` uses
416            // internally, ONCE, and check `is_protected` against THAT
417            // (not the raw manifest string) — so a `.git/**`-floor or
418            // `protected_globs` bypass via `x/../.git/config` can no
419            // longer disagree between the two checks (the root cause of
420            // the bug this replaces: `is_protected` matched the raw
421            // string while `contained` matched the normalized path).
422            let Some(normalized_rel) = normalized_project_rel(project_root, &target) else {
423                report.refused.push((
424                    entry.path.clone(),
425                    "refused: escapes the project root".to_string(),
426                ));
427                continue;
428            };
429            if is_protected(&normalized_rel, protected_globs) {
430                report
431                    .refused
432                    .push((entry.path.clone(), "refused: protected path".to_string()));
433                continue;
434            }
435            // Belt-and-suspenders: the existing symlink-safe containment
436            // check (canonicalizes the longest existing ancestor, refusing
437            // any symlink escape) still runs unconditionally.
438            if !contained(project_root, &target) {
439                report.refused.push((
440                    entry.path.clone(),
441                    "refused: escapes the project root".to_string(),
442                ));
443                continue;
444            }
445            // (3) Resolved-then-protect: `normalized_rel` above is a purely
446            // LEXICAL collapse — it never resolves symlinks — while
447            // `contained` (just above) DOES resolve symlinks when it
448            // canonicalizes the longest existing ancestor. Those two views
449            // of the path can disagree exactly when a component of `target`
450            // is a symlink: e.g. a pre-existing `foo -> .git` inside
451            // `project_root` plus a manifest entry `foo/config` lexically
452            // normalizes to `foo/config` (not protected — no literal
453            // `.git/` prefix) yet resolves to `<root>/.git/config` (still
454            // "contained" under `project_root`, so the escape check above
455            // doesn't catch it either — it never leaves the root, it just
456            // lands somewhere the lexical path didn't say). Re-run
457            // `is_protected` against the RESOLVED, symlink-followed
458            // project-relative path too, so this can't slip through: the
459            // `.git`/`protected_globs` floor now sees BOTH the lexical and
460            // the resolved view, and refuses if EITHER is protected.
461            if let Some(resolved_rel) = resolved_project_rel(project_root, &target) {
462                if is_protected(&resolved_rel, protected_globs) {
463                    report
464                        .refused
465                        .push((entry.path.clone(), "refused: protected path".to_string()));
466                    continue;
467                }
468            }
469            match &entry.blob {
470                Some(hash) => {
471                    let bytes = match self.read_blob(hash) {
472                        Ok(b) => b,
473                        Err(e) => {
474                            report
475                                .refused
476                                .push((entry.path.clone(), format!("blob unreadable: {e}")));
477                            continue;
478                        }
479                    };
480                    if let Some(parent) = target.parent() {
481                        let _ = std::fs::create_dir_all(parent);
482                    }
483                    if let Err(e) = std::fs::write(&target, &bytes) {
484                        report
485                            .refused
486                            .push((entry.path.clone(), format!("write failed: {e}")));
487                        continue;
488                    }
489                }
490                None if target.exists() => {
491                    if let Err(e) = std::fs::remove_file(&target) {
492                        report
493                            .refused
494                            .push((entry.path.clone(), format!("delete failed: {e}")));
495                        continue;
496                    }
497                }
498                None => {}
499            }
500            report.restored.push(entry.path.clone());
501        }
502        Ok(report)
503    }
504
505    /// Bounded-disk requirement: keep only the `keep` newest checkpoints,
506    /// deleting the rest, then GC any blob no longer referenced by a
507    /// surviving manifest. Returns the number of checkpoints removed.
508    pub fn prune(&self, keep: usize) -> Result<usize> {
509        let mut metas = self.list()?; // newest first
510        if metas.len() <= keep {
511            return Ok(0);
512        }
513        let stale = metas.split_off(keep);
514        let removed = stale.len();
515        for m in stale {
516            let _ = std::fs::remove_file(self.manifest_path(&m.id));
517        }
518        self.gc_unreferenced_blobs()?;
519        Ok(removed)
520    }
521
522    /// Delete every blob under `objects/` not referenced by ANY surviving
523    /// manifest — a full scan, not a refcount (simplest correct form; the
524    /// retention bound keeps this cheap in practice — see [`Self::prune`]'s
525    /// doc comment).
526    fn gc_unreferenced_blobs(&self) -> Result<()> {
527        let mut referenced: HashSet<String> = HashSet::new();
528        for meta in self.list()? {
529            if let Ok(m) = self.manifest(&meta.id) {
530                for f in m.files {
531                    if let Some(b) = f.blob {
532                        referenced.insert(b);
533                    }
534                }
535            }
536        }
537        let objects = self.objects_dir();
538        if !objects.exists() {
539            return Ok(());
540        }
541        for entry in std::fs::read_dir(&objects)? {
542            let entry = entry?;
543            if !entry.file_type()?.is_dir() {
544                continue;
545            }
546            for inner in std::fs::read_dir(entry.path())? {
547                let inner = inner?;
548                let name = inner.file_name();
549                let Some(name) = name.to_str() else {
550                    continue;
551                };
552                if name.starts_with(".tmp-") {
553                    continue; // an in-flight write, not ours to reap
554                }
555                if !referenced.contains(name) {
556                    let _ = std::fs::remove_file(inner.path());
557                }
558            }
559        }
560        Ok(())
561    }
562}
563
564// SECURITY (safe-path consolidation, CRITICAL fix): every primitive below
565// used to be defined HERE, locally — this module's own P5-9 fix for the
566// `.git`-clobber bug class. It is now `crate::safe_path`'s canonical
567// implementation instead, with checkpoint DELEGATING to it (thin wrappers,
568// same names, same signatures, same behavior) so `crate::permissions`'s
569// gate and `crate::tools`'s sandbox containment reuse this exact proven
570// logic rather than each re-implementing (and, in the permissions gate's
571// case, getting wrong) their own. See `crate::safe_path`'s module doc
572// comment for the full story and the two-views-of-a-path explanation every
573// doc comment below used to carry inline.
574//
575// Every one of this module's 23 tests (see `tests` below) still exercises
576// these names directly and is UNCHANGED — that is the proof this extraction
577// is behavior-preserving.
578
579/// Is `rel` (a project-relative, `/`-separated path) a hard floor this
580/// module refuses to snapshot INTO or restore OVER, regardless of config?
581/// `.git` (and everything under it) is unconditional — checkpoint must
582/// never touch the user's real git repo even if `permissions.protected_paths`
583/// (module 13) is off. `extra_globs` layers `Config::permissions_protected_paths`
584/// on top when the caller has one (restore only — capture-time protection
585/// is already covered because [`CheckpointObserver::before_write`] only
586/// ever fires for a write the P5-1 permission gate already approved, which
587/// already folds `protected_paths` in — see that method's own doc comment).
588/// See `crate::safe_path::is_protected` for the implementation.
589fn is_protected(rel: &str, extra_globs: &[String]) -> bool {
590    crate::safe_path::is_protected(rel, extra_globs)
591}
592
593/// Up-front rejection for a manifest-declared path that could never have
594/// come from a legitimate capture: `record_pre_image` only ever stores a
595/// clean, `/`-separated, project-relative path (see
596/// [`CheckpointFileEntry::path`]'s doc comment), so an absolute path or one
597/// containing a `..` (`ParentDir`), root, or Windows-prefix component is by
598/// definition hostile or corrupt. Returns the refusal reason, or `None` if
599/// `rel` is clean. Called BEFORE any raw-string pattern match (e.g.
600/// [`is_protected`]) so a traversal entry like `x/../.git/config` — which
601/// does not literally string-match the `.git/` floor — is refused before
602/// it can ever be compared against anything. See
603/// `crate::safe_path::reject_unsafe_rel_path` for the implementation.
604fn reject_unsafe_manifest_path(rel: &str) -> Option<String> {
605    crate::safe_path::reject_unsafe_rel_path(rel)
606}
607
608/// Lexically normalize `path` (expected to be `root.join(rel)` for some
609/// manifest-declared `rel`) via the SAME collapse [`contained`] uses, then —
610/// if the normalized form is still under `root` — return its
611/// project-relative, `/`-separated tail. Callers (currently only
612/// [`CheckpointStore::restore`]) compute this ONCE per entry and feed the
613/// single result to [`is_protected`], so the protected-path floor sees
614/// EXACTLY the same normalized path [`contained`]'s containment check
615/// computes. See `crate::safe_path::normalized_project_rel` for the
616/// implementation.
617fn normalized_project_rel(root: &Path, path: &Path) -> Option<String> {
618    crate::safe_path::normalized_project_rel(root, path)
619}
620
621/// Symlink- and traversal-safe containment check: is `path` (absolute,
622/// possibly not-yet-existing) confined under `root`? Fails closed (`false`)
623/// on any resolution error. See `crate::safe_path::contained` for the
624/// implementation.
625fn contained(root: &Path, path: &Path) -> bool {
626    crate::safe_path::contained(root, path)
627}
628
629/// Resolved (symlink-following) counterpart to [`normalized_project_rel`]:
630/// canonicalizes `path`'s longest existing ancestor (resolving any symlink
631/// along the way — see `crate::safe_path::resolve_real`) and returns the
632/// resulting absolute path's tail relative to `root`'s own canonical form, as a
633/// `/`-separated string — or `None` if resolution fails, or the resolved
634/// path lands outside `root` entirely (that case is already refused by
635/// [`contained`]; this function only needs to report a rel path when
636/// there IS one). [`CheckpointStore::restore`] feeds this to
637/// [`is_protected`] IN ADDITION TO the lexical [`normalized_project_rel`]
638/// result, closing the gap where a symlink resolves into `.git` (or a
639/// `protected_globs` match) even though its LEXICAL path never mentions
640/// `.git` at all and it never leaves `root` (so `contained` alone would
641/// accept it) — see the call site's doc comment for the exact repro this
642/// closes. See `crate::safe_path::resolved_project_rel` for the
643/// implementation.
644fn resolved_project_rel(root: &Path, path: &Path) -> Option<String> {
645    crate::safe_path::resolved_project_rel(root, path)
646}
647
648#[derive(Debug, Default)]
649struct ObserverState {
650    /// The checkpoint currently open for the in-flight turn.
651    current: Option<CheckpointId>,
652    /// Project-relative paths already captured under `current` — first
653    /// write per turn wins the pre-image; every write after the first is a
654    /// no-op capture (the manifest already holds the earliest state).
655    captured: HashSet<String>,
656}
657
658/// [`WriteObserver`] implementation backing `[capabilities.checkpoint]`.
659/// One instance per [`crate::Agent`] (installed on its [`crate::tools::ToolContext`]
660/// AND held directly so `crate::Agent::run_loop` can call
661/// [`Self::begin_turn`] once per user turn — see that method's call site's
662/// doc comment).
663#[derive(Debug)]
664pub struct CheckpointObserver {
665    store: CheckpointStore,
666    project_root: PathBuf,
667    retain: usize,
668    protected: Vec<String>,
669    /// BP-7 (§3.1 `capabilities.checkpoint.restore`): see
670    /// [`Self::restore_enabled`].
671    restore_enabled: bool,
672    state: Mutex<ObserverState>,
673    /// Set once on any I/O failure — graceful degrade (§ "git-absent /
674    /// unwritable state dir ⇒ warn + disable, never crash"), not a panic
675    /// and not a blocked tool call. `Relaxed` throughout: this is a
676    /// best-effort circuit breaker, not a correctness-critical ordering.
677    disabled: AtomicBool,
678}
679
680impl CheckpointObserver {
681    /// `protected` layers extra glob patterns (typically
682    /// `Config::permissions_protected_paths`, when module 13 is active) on
683    /// top of the unconditional `.git/**` floor — consulted by
684    /// [`Self::restore`].
685    pub fn new(
686        store: CheckpointStore,
687        project_root: PathBuf,
688        retain: usize,
689        protected: Vec<String>,
690    ) -> Self {
691        Self::with_restore(store, project_root, retain, protected, true)
692    }
693
694    /// BP-7: [`Self::new`] with the restore half explicitly gated — see
695    /// [`Self::restore_enabled`].
696    pub fn with_restore(
697        store: CheckpointStore,
698        project_root: PathBuf,
699        retain: usize,
700        protected: Vec<String>,
701        restore_enabled: bool,
702    ) -> Self {
703        CheckpointObserver {
704            store,
705            project_root,
706            retain: retain.max(1),
707            protected,
708            restore_enabled,
709            state: Mutex::new(ObserverState::default()),
710            disabled: AtomicBool::new(false),
711        }
712    }
713
714    /// Read-only access to the underlying store (e.g. so a caller can
715    /// `list`/`turn_diff`/`restore` without re-deriving the root path).
716    pub fn store(&self) -> &CheckpointStore {
717        &self.store
718    }
719
720    /// List every checkpoint, newest first — see [`CheckpointStore::list`].
721    pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
722        self.store.list()
723    }
724
725    /// D3 turn-diff for one checkpoint — see [`CheckpointStore::turn_diff`].
726    pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
727        self.store.turn_diff(id)
728    }
729
730    /// BP-7: the turn's cumulative unified diff against THIS observer's own
731    /// project root — see [`CheckpointStore::turn_patch`].
732    pub fn turn_patch(&self, id: &str) -> Result<String> {
733        self.store.turn_patch(id, &self.project_root)
734    }
735
736    /// BP-7 (§3.1 `capabilities.checkpoint.restore`): whether this
737    /// observer may put files back. `false` is the turn-diff-only posture
738    /// cx-parity takes — Codex tracks each turn's diff but has no code
739    /// restore at all (its `ghost_snapshot` is a stripped legacy no-op), so
740    /// a preset that transcribes Codex must be able to arm the tracking
741    /// half without the restoring half.
742    pub fn restore_enabled(&self) -> bool {
743        self.restore_enabled
744    }
745
746    /// D4-adjacent revert: restore this project's working files to
747    /// checkpoint `id`, honoring THIS observer's own `project_root` and
748    /// `protected` globs (the fields set at construction) — see
749    /// [`CheckpointStore::restore`] for the full security contract.
750    pub fn restore(&self, id: &str) -> Result<RestoreReport> {
751        if !self.restore_enabled {
752            return Err(Error::Other(
753                "checkpoint restore is off for this harness                  (`[capabilities.checkpoint] restore = false`) — this preset tracks each                  turn's diff but has no code-restore surface"
754                    .to_string(),
755            ));
756        }
757        self.store.restore(id, &self.project_root, &self.protected)
758    }
759
760    /// Whether this observer has disabled itself after an I/O failure.
761    pub fn is_disabled(&self) -> bool {
762        self.disabled.load(Ordering::Relaxed)
763    }
764
765    /// Open a fresh checkpoint for a new turn — called once at the top of
766    /// `crate::Agent::run_loop` (i.e. once per `Agent::send`/
767    /// `send_with_files`/`send_with_images` call, cc's "per-prompt
768    /// file-history-snapshot"). `label` is a short excerpt of the turn's
769    /// prompt, display-only. Also prunes past the retention bound here
770    /// (once per turn, not once per write) — see [`CheckpointStore::prune`].
771    /// Returns `None` when disabled (config-off is never routed here at
772    /// all — see [`observer_for_config`] — so `None` here specifically
773    /// means an I/O failure already tripped the breaker).
774    pub fn begin_turn(&self, label: &str) -> Option<CheckpointId> {
775        if self.disabled.load(Ordering::Relaxed) {
776            return None;
777        }
778        let short: String = label.chars().take(120).collect();
779        match self.store.create_checkpoint(&short) {
780            Ok(id) => {
781                if let Ok(mut st) = self.state.lock() {
782                    st.current = Some(id.clone());
783                    st.captured.clear();
784                }
785                if let Err(e) = self.store.prune(self.retain) {
786                    eprintln!("warning: checkpoint: prune failed: {e}");
787                }
788                Some(id)
789            }
790            Err(e) => {
791                eprintln!(
792                    "warning: checkpoint disabled for the rest of this session — \
793                     failed to open a new checkpoint: {e}"
794                );
795                self.disabled.store(true, Ordering::Relaxed);
796                None
797            }
798        }
799    }
800
801    /// The checkpoint currently open for the in-flight turn, if any.
802    pub fn current(&self) -> Option<CheckpointId> {
803        self.state.lock().ok().and_then(|s| s.current.clone())
804    }
805}
806
807#[async_trait::async_trait]
808impl WriteObserver for CheckpointObserver {
809    async fn before_write(&self, path: &Path) {
810        if self.disabled.load(Ordering::Relaxed) {
811            return;
812        }
813        if !contained(&self.project_root, path) {
814            // Outside the project root — out of this module's scope (§ "an
815            // honest gap", never a crash or a wrong snapshot).
816            return;
817        }
818        let Some(normalized) = crate::tools::normalize(path) else {
819            return;
820        };
821        let Some(root_normalized) = crate::tools::normalize(&self.project_root) else {
822            return;
823        };
824        let Ok(rel_path) = normalized.strip_prefix(&root_normalized) else {
825            return;
826        };
827        let rel = rel_path.to_string_lossy().replace('\\', "/");
828        if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
829            return;
830        }
831
832        // The ENTIRE check-read-record sequence runs under one lock, so two
833        // concurrent writes to the SAME path (e.g. a `run_tools_concurrently`
834        // batch, or a P5-6 background job racing a foreground write) can
835        // never both read-and-lose a torn pre-image: the second one to
836        // arrive here always sees `captured` already contains `rel` and
837        // skips entirely, never re-reading a post-first-write state.
838        let mut st = match self.state.lock() {
839            Ok(g) => g,
840            Err(poisoned) => poisoned.into_inner(),
841        };
842        if st.current.is_none() {
843            // A write reached this seam with no open turn (e.g. a caller
844            // driving the tool registry directly, outside `Agent::send`) —
845            // self-heal with an ad-hoc checkpoint rather than silently
846            // dropping the capture. `begin_turn` takes this same lock, so
847            // it must be called with `st` released first.
848            drop(st);
849            self.begin_turn("untracked");
850            st = match self.state.lock() {
851                Ok(g) => g,
852                Err(poisoned) => poisoned.into_inner(),
853            };
854        }
855        if st.captured.contains(&rel) {
856            return;
857        }
858        let Some(id) = st.current.clone() else {
859            return; // begin_turn's own failure already warned + disabled
860        };
861        let content = std::fs::read(path).ok(); // None => doesn't exist yet (a create)
862        match self.store.record_pre_image(&id, &rel, content) {
863            Ok(()) => {
864                st.captured.insert(rel);
865            }
866            Err(e) => {
867                eprintln!(
868                    "warning: checkpoint disabled for the rest of this session — \
869                     failed to record a snapshot: {e}"
870                );
871                self.disabled.store(true, Ordering::Relaxed);
872            }
873        }
874    }
875
876    async fn after_write(&self, _path: &Path) -> Option<String> {
877        // P5-11: `formatters`/`lsp` now occupy this hook (via
878        // `crate::tools::WriteObserverChain`, installed AFTER this observer
879        // in `crate::agent::build_tool_context`'s chain) — checkpoint itself
880        // still has nothing to do after a write completes, and returning
881        // `None` keeps the tool-result text this hook contributes
882        // byte-identical to before P5-11 whenever checkpoint is the only
883        // observer installed.
884        None
885    }
886}
887
888/// A stable per-project-directory tag — the same hash-of-canonicalized-cwd
889/// idea `crates/cli/src/main.rs::cwd_tag` uses for session naming, kept
890/// separately here (a `core`-crate concern, and `cli` depends on `core` not
891/// the reverse) so two different projects never share one shadow store even
892/// though they'd otherwise both resolve to the same `$SUPERCODE_HOME`-
893/// derived parent directory.
894///
895/// BP-10: `pub(crate)` because the persisted approval cache
896/// (`crate::permissions::approval`) keys its own per-project store the same
897/// way — the SAME tag function, not a second hash with the same idea, so
898/// one project's checkpoints and its remembered approvals can never
899/// disagree about which project they belong to.
900pub(crate) fn project_tag(cwd: &Path) -> String {
901    use std::hash::{Hash, Hasher};
902    let canon = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
903    let mut h = std::collections::hash_map::DefaultHasher::new();
904    canon.hash(&mut h);
905    format!("{:016x}", h.finish())
906}
907
908/// The DEFAULT shadow-store root for `config.cwd` when
909/// [`crate::Config::checkpoint_dir`] is unset: `$SUPERCODE_HOME/checkpoints/<project_tag>`
910/// (`crate::agent::global_instructions_dir` is the same `$SUPERCODE_HOME`
911/// resolver the global instruction tier and `checkpoint` both use).
912fn default_shadow_root(cwd: &Path) -> PathBuf {
913    crate::agent::global_instructions_dir()
914        .join("checkpoints")
915        .join(project_tag(cwd))
916}
917
918/// Build the [`CheckpointObserver`] a fresh [`crate::Agent`] should install,
919/// given a resolved [`crate::Config`] — called once, from
920/// `crate::agent::build_tool_context`. `Config::checkpoint_enabled` is the
921/// ONE gate: `false` (the default) returns `None` WITHOUT touching the
922/// filesystem at all (no `CheckpointStore::open`, no directory created) —
923/// the default-off byte-identity guarantee. `true` opens (creating if
924/// needed) the shadow store at `Config::checkpoint_dir`, or
925/// `default_shadow_root` when that's unset; an I/O failure (unwritable
926/// state dir) is reported via a one-time `eprintln!` warning and returns
927/// `None` — graceful degrade, never a crash, never a blocked `Agent::new`.
928/// BP-7: one side of a [`CheckpointStore::turn_patch`] entry as text.
929/// `None` (absent file) decodes to the empty string — a create diffs
930/// against nothing and a delete diffs to nothing, which is what a unified
931/// diff of those cases should show. Non-UTF-8 bytes return `None`, which
932/// the caller renders as a "binary files differ" line rather than diffing
933/// them into garbage.
934fn decode_side(bytes: Option<&[u8]>) -> Option<String> {
935    match bytes {
936        None => Some(String::new()),
937        Some(b) => String::from_utf8(b.to_vec()).ok(),
938    }
939}
940
941pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<CheckpointObserver>> {
942    if !config.checkpoint_enabled {
943        return None;
944    }
945    let root = config
946        .checkpoint_dir
947        .clone()
948        .unwrap_or_else(|| default_shadow_root(&config.cwd));
949    match CheckpointStore::open(&root) {
950        Ok(store) => Some(std::sync::Arc::new(CheckpointObserver::with_restore(
951            store,
952            config.cwd.clone(),
953            config.checkpoint_retain,
954            config.permissions_protected_paths.clone(),
955            config.checkpoint_restore,
956        ))),
957        Err(e) => {
958            eprintln!(
959                "warning: [capabilities.checkpoint] is enabled but the shadow store at \
960                 {} could not be opened — checkpoint is disabled for this session: {e}",
961                root.display()
962            );
963            None
964        }
965    }
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    fn tmp(tag: &str) -> PathBuf {
973        let dir = std::env::temp_dir().join(format!(
974            "supercode-checkpoint-test-{tag}-{}-{}",
975            std::process::id(),
976            mint_id()
977        ));
978        std::fs::create_dir_all(&dir).unwrap();
979        dir
980    }
981
982    #[test]
983    fn open_creates_objects_and_checkpoints_dirs() {
984        let root = tmp("open");
985        let store = CheckpointStore::open(&root).unwrap();
986        assert!(root.join("objects").is_dir());
987        assert!(root.join("checkpoints").is_dir());
988        drop(store);
989        std::fs::remove_dir_all(&root).ok();
990    }
991
992    #[test]
993    fn create_list_and_manifest_round_trip() {
994        let root = tmp("list");
995        let store = CheckpointStore::open(&root).unwrap();
996        let id1 = store.create_checkpoint("first turn").unwrap();
997        std::thread::sleep(std::time::Duration::from_millis(2));
998        let id2 = store.create_checkpoint("second turn").unwrap();
999        let metas = store.list().unwrap();
1000        assert_eq!(metas.len(), 2);
1001        // newest first.
1002        assert_eq!(metas[0].id, id2);
1003        assert_eq!(metas[1].id, id1);
1004        assert_eq!(metas[0].label, "second turn");
1005        let m = store.manifest(&id1).unwrap();
1006        assert_eq!(m.id, id1);
1007        assert!(m.files.is_empty());
1008        std::fs::remove_dir_all(&root).ok();
1009    }
1010
1011    #[test]
1012    fn record_pre_image_is_idempotent_keeping_the_earliest() {
1013        let root = tmp("idempotent");
1014        let store = CheckpointStore::open(&root).unwrap();
1015        let id = store.create_checkpoint("t").unwrap();
1016        store
1017            .record_pre_image(&id, "a.txt", Some(b"first".to_vec()))
1018            .unwrap();
1019        // A second capture of the SAME path (e.g. a second edit later in
1020        // the same turn) must NOT overwrite the first pre-image.
1021        store
1022            .record_pre_image(&id, "a.txt", Some(b"second".to_vec()))
1023            .unwrap();
1024        let m = store.manifest(&id).unwrap();
1025        assert_eq!(m.files.len(), 1);
1026        let bytes = store
1027            .read_blob(m.files[0].blob.as_deref().unwrap())
1028            .unwrap();
1029        assert_eq!(bytes, b"first");
1030        std::fs::remove_dir_all(&root).ok();
1031    }
1032
1033    #[test]
1034    fn blob_dedup_two_identical_contents_share_one_object() {
1035        let root = tmp("dedup");
1036        let store = CheckpointStore::open(&root).unwrap();
1037        let id = store.create_checkpoint("t").unwrap();
1038        store
1039            .record_pre_image(&id, "a.txt", Some(b"same".to_vec()))
1040            .unwrap();
1041        store
1042            .record_pre_image(&id, "b.txt", Some(b"same".to_vec()))
1043            .unwrap();
1044        let m = store.manifest(&id).unwrap();
1045        assert_eq!(m.files[0].blob, m.files[1].blob);
1046        // Exactly one blob file under objects/.
1047        let mut count = 0;
1048        for entry in walkdir(&root.join("objects")) {
1049            if entry.is_file() && !entry.to_string_lossy().contains(".tmp-") {
1050                count += 1;
1051            }
1052        }
1053        assert_eq!(count, 1);
1054        std::fs::remove_dir_all(&root).ok();
1055    }
1056
1057    fn walkdir(dir: &Path) -> Vec<PathBuf> {
1058        let mut out = Vec::new();
1059        let Ok(rd) = std::fs::read_dir(dir) else {
1060            return out;
1061        };
1062        for entry in rd.flatten() {
1063            let p = entry.path();
1064            if p.is_dir() {
1065                out.extend(walkdir(&p));
1066            } else {
1067                out.push(p);
1068            }
1069        }
1070        out
1071    }
1072
1073    #[test]
1074    fn restore_rewrites_modified_and_deletes_created_files() {
1075        let root = tmp("restore");
1076        let project = tmp("restore-project");
1077        std::fs::write(project.join("existing.txt"), "modified").unwrap();
1078        let store = CheckpointStore::open(&root).unwrap();
1079        let id = store.create_checkpoint("t").unwrap();
1080        // existing.txt existed before with "original".
1081        store
1082            .record_pre_image(&id, "existing.txt", Some(b"original".to_vec()))
1083            .unwrap();
1084        // new.txt did NOT exist before (a create this turn).
1085        store.record_pre_image(&id, "new.txt", None).unwrap();
1086        std::fs::write(project.join("new.txt"), "brand new").unwrap();
1087
1088        let report = store.restore(&id, &project, &[]).unwrap();
1089        assert!(report.refused.is_empty(), "{:?}", report.refused);
1090        assert_eq!(report.restored.len(), 2);
1091        assert_eq!(
1092            std::fs::read_to_string(project.join("existing.txt")).unwrap(),
1093            "original"
1094        );
1095        assert!(!project.join("new.txt").exists());
1096        std::fs::remove_dir_all(&root).ok();
1097        std::fs::remove_dir_all(&project).ok();
1098    }
1099
1100    #[test]
1101    fn restore_refuses_a_manifest_entry_that_traverses_outside_the_project_root() {
1102        // Hostile test (spec requirement): a checkpoint whose manifest
1103        // (however it got there) names a `..`-traversing path must be
1104        // refused, never applied — restore independently re-validates
1105        // every target, it does not trust the manifest.
1106        let root = tmp("hostile");
1107        let project = tmp("hostile-project");
1108        std::fs::create_dir_all(&project).unwrap();
1109        let store = CheckpointStore::open(&root).unwrap();
1110        let id = store.create_checkpoint("t").unwrap();
1111        // Hand-craft a manifest with a traversal path directly (bypassing
1112        // `record_pre_image`'s own normal, honest callers).
1113        let mut m = store.manifest(&id).unwrap();
1114        m.files.push(CheckpointFileEntry {
1115            path: "../../../../../../etc/passwd-supercode-test".to_string(),
1116            blob: None,
1117        });
1118        store.write_manifest(&m).unwrap();
1119
1120        let victim = project
1121            .parent()
1122            .unwrap()
1123            .parent()
1124            .unwrap()
1125            .join("etc/passwd-supercode-test");
1126        assert!(
1127            !victim.exists(),
1128            "test precondition: victim path must not already exist"
1129        );
1130
1131        let report = store.restore(&id, &project, &[]).unwrap();
1132        assert_eq!(report.restored.len(), 0);
1133        assert_eq!(report.refused.len(), 1);
1134        assert!(report.refused[0].1.contains("escapes"));
1135        assert!(
1136            !victim.exists(),
1137            "restore must never have written outside the project root"
1138        );
1139        std::fs::remove_dir_all(&root).ok();
1140        std::fs::remove_dir_all(&project).ok();
1141    }
1142
1143    #[test]
1144    fn restore_refuses_dot_git_unconditionally() {
1145        let root = tmp("gitfloor");
1146        let project = tmp("gitfloor-project");
1147        std::fs::create_dir_all(project.join(".git")).unwrap();
1148        std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1149        let store = CheckpointStore::open(&root).unwrap();
1150        let id = store.create_checkpoint("t").unwrap();
1151        let mut m = store.manifest(&id).unwrap();
1152        m.files.push(CheckpointFileEntry {
1153            path: ".git/config".to_string(),
1154            blob: None,
1155        });
1156        store.write_manifest(&m).unwrap();
1157
1158        let report = store.restore(&id, &project, &[]).unwrap();
1159        assert_eq!(report.restored.len(), 0);
1160        assert_eq!(report.refused.len(), 1);
1161        assert!(report.refused[0].1.contains("protected"));
1162        assert_eq!(
1163            std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1164            "real git config",
1165            "the real .git must be untouched"
1166        );
1167        std::fs::remove_dir_all(&root).ok();
1168        std::fs::remove_dir_all(&project).ok();
1169    }
1170
1171    #[test]
1172    fn restore_honors_extra_protected_globs() {
1173        let root = tmp("protectedglob");
1174        let project = tmp("protectedglob-project");
1175        std::fs::write(project.join(".env"), "SECRET=1").unwrap();
1176        let store = CheckpointStore::open(&root).unwrap();
1177        let id = store.create_checkpoint("t").unwrap();
1178        let mut m = store.manifest(&id).unwrap();
1179        m.files.push(CheckpointFileEntry {
1180            path: ".env".to_string(),
1181            blob: Some(store.write_blob(b"OLD=1").unwrap()),
1182        });
1183        store.write_manifest(&m).unwrap();
1184
1185        let report = store
1186            .restore(&id, &project, &[".env*".to_string()])
1187            .unwrap();
1188        assert_eq!(report.restored.len(), 0);
1189        assert_eq!(report.refused.len(), 1);
1190        assert_eq!(
1191            std::fs::read_to_string(project.join(".env")).unwrap(),
1192            "SECRET=1"
1193        );
1194        std::fs::remove_dir_all(&root).ok();
1195        std::fs::remove_dir_all(&project).ok();
1196    }
1197
1198    #[test]
1199    fn restore_refuses_traversal_into_dot_git_real_git_config_stays_untouched() {
1200        // The exact reviewer repro: a manifest entry `x/../.git/config`
1201        // does NOT string-match the raw `.git/` floor (it literally starts
1202        // with `x/`), but lexically normalizes right back into
1203        // `<root>/.git/config`. Pre-fix, `is_protected` (raw string) said
1204        // "not protected" while `contained` (normalized) said "inside
1205        // root" — so restore wrote straight into the real `.git`. Post-fix
1206        // the up-front `..`-rejection refuses this before either check
1207        // runs, and even if that were bypassed, `is_protected` now runs on
1208        // the SAME normalized path `contained` uses.
1209        let root = tmp("traversal-git");
1210        let project = tmp("traversal-git-project");
1211        std::fs::create_dir_all(project.join(".git")).unwrap();
1212        std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1213        let store = CheckpointStore::open(&root).unwrap();
1214        let id = store.create_checkpoint("t").unwrap();
1215        let mut m = store.manifest(&id).unwrap();
1216        m.files.push(CheckpointFileEntry {
1217            path: "x/../.git/config".to_string(),
1218            blob: Some(store.write_blob(b"PWNED-by-traversal").unwrap()),
1219        });
1220        store.write_manifest(&m).unwrap();
1221
1222        let report = store.restore(&id, &project, &[]).unwrap();
1223        assert_eq!(report.restored.len(), 0, "must not restore into .git");
1224        assert_eq!(report.refused.len(), 1);
1225        assert!(
1226            report.refused[0].1.contains("escapes"),
1227            "unexpected refusal reason: {}",
1228            report.refused[0].1
1229        );
1230        assert_eq!(
1231            std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1232            "real git config",
1233            "the real .git/config must be untouched by the traversal entry"
1234        );
1235        std::fs::remove_dir_all(&root).ok();
1236        std::fs::remove_dir_all(&project).ok();
1237    }
1238
1239    #[test]
1240    fn restore_refuses_traversal_bypass_of_protected_globs_dot_env_stays_untouched() {
1241        // Same bypass shape as the `.git` floor, but against
1242        // `protected_globs` this time: `x/../.env` doesn't glob-match
1243        // `.env*` as a raw string, but normalizes right back into
1244        // `<root>/.env`.
1245        let root = tmp("traversal-env");
1246        let project = tmp("traversal-env-project");
1247        std::fs::create_dir_all(&project).unwrap();
1248        std::fs::write(project.join(".env"), "SECRET=1").unwrap();
1249        let store = CheckpointStore::open(&root).unwrap();
1250        let id = store.create_checkpoint("t").unwrap();
1251        let mut m = store.manifest(&id).unwrap();
1252        m.files.push(CheckpointFileEntry {
1253            path: "x/../.env".to_string(),
1254            blob: Some(store.write_blob(b"PWNED=1").unwrap()),
1255        });
1256        store.write_manifest(&m).unwrap();
1257
1258        let report = store
1259            .restore(&id, &project, &[".env*".to_string()])
1260            .unwrap();
1261        assert_eq!(report.restored.len(), 0);
1262        assert_eq!(report.refused.len(), 1);
1263        assert_eq!(
1264            std::fs::read_to_string(project.join(".env")).unwrap(),
1265            "SECRET=1",
1266            "the real .env must be untouched by the traversal entry"
1267        );
1268        std::fs::remove_dir_all(&root).ok();
1269        std::fs::remove_dir_all(&project).ok();
1270    }
1271
1272    #[test]
1273    fn restore_refuses_an_absolute_manifest_path() {
1274        // An absolute manifest entry is never something a legitimate
1275        // capture produces (`record_pre_image` always stores a clean
1276        // relative path) — even one that happens to point AT a path
1277        // inside the project (here, the real `.git/config`) must be
1278        // refused up front, not evaluated by whatever it happens to
1279        // resolve to. (Pre-fix: `Path::join` REPLACES the base when the
1280        // joined component is absolute, so this entry's raw string never
1281        // matched the `.git/` floor and its target — being genuinely
1282        // inside root — passed `contained` too: a second, independent
1283        // bypass of the same floor.)
1284        let root = tmp("absolute");
1285        let project = tmp("absolute-project");
1286        std::fs::create_dir_all(project.join(".git")).unwrap();
1287        std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1288        let store = CheckpointStore::open(&root).unwrap();
1289        let id = store.create_checkpoint("t").unwrap();
1290        let absolute_git_config = project.join(".git").join("config");
1291        let mut m = store.manifest(&id).unwrap();
1292        m.files.push(CheckpointFileEntry {
1293            path: absolute_git_config.to_string_lossy().to_string(),
1294            blob: Some(store.write_blob(b"PWNED-by-absolute-path").unwrap()),
1295        });
1296        store.write_manifest(&m).unwrap();
1297
1298        let report = store.restore(&id, &project, &[]).unwrap();
1299        assert_eq!(report.restored.len(), 0);
1300        assert_eq!(report.refused.len(), 1);
1301        assert!(
1302            report.refused[0].1.contains("escapes"),
1303            "unexpected refusal reason: {}",
1304            report.refused[0].1
1305        );
1306        assert_eq!(
1307            std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1308            "real git config"
1309        );
1310        std::fs::remove_dir_all(&root).ok();
1311        std::fs::remove_dir_all(&project).ok();
1312    }
1313
1314    #[test]
1315    fn restore_still_restores_a_clean_relative_nested_entry() {
1316        // Proves the fix didn't over-block honest captures: a normal
1317        // capture -> restore round trip — including a nested directory, so
1318        // the normalize step's `strip_prefix` is exercised on a
1319        // multi-component path — still restores.
1320        let root = tmp("cleanroundtrip");
1321        let project = tmp("cleanroundtrip-project");
1322        std::fs::create_dir_all(project.join("src")).unwrap();
1323        std::fs::write(project.join("src").join("main.rs"), "fn main() {}").unwrap();
1324        let store = CheckpointStore::open(&root).unwrap();
1325        let id = store.create_checkpoint("t").unwrap();
1326        store
1327            .record_pre_image(&id, "src/main.rs", Some(b"fn old() {}".to_vec()))
1328            .unwrap();
1329
1330        let report = store.restore(&id, &project, &[]).unwrap();
1331        assert!(report.refused.is_empty(), "{:?}", report.refused);
1332        assert_eq!(report.restored, vec!["src/main.rs".to_string()]);
1333        assert_eq!(
1334            std::fs::read_to_string(project.join("src").join("main.rs")).unwrap(),
1335            "fn old() {}"
1336        );
1337        std::fs::remove_dir_all(&root).ok();
1338        std::fs::remove_dir_all(&project).ok();
1339    }
1340
1341    #[test]
1342    #[cfg(unix)]
1343    fn restore_refuses_symlink_traversal_into_dot_git() {
1344        // The exact reviewer repro this unit fixes: `is_protected` ran on
1345        // the LEXICAL normalized path (never resolves symlinks) while
1346        // `contained` CANONICALIZES (resolves symlinks). A pre-existing
1347        // symlink `foo -> .git` in the working tree plus a manifest entry
1348        // `foo/config`:
1349        //  - passes `reject_unsafe_manifest_path` (no `..`/absolute);
1350        //  - lexically normalizes to `foo/config` — `is_protected` says
1351        //    NOT protected (no literal `.git/` prefix);
1352        //  - `contained` canonicalizes `foo`, resolving the symlink to the
1353        //    real `<root>/.git`, which IS inside `real_root` — containment
1354        //    PASSES.
1355        // Pre-fix, restore then wrote straight into the real `.git/config`.
1356        // Post-fix, `resolved_project_rel` also runs `is_protected` on the
1357        // symlink-RESOLVED path (`.git/config`), so this is refused.
1358        let root = tmp("symlink-traversal");
1359        let project = tmp("symlink-traversal-project");
1360        std::fs::create_dir_all(project.join(".git")).unwrap();
1361        std::fs::write(project.join(".git").join("config"), "real git config").unwrap();
1362        std::os::unix::fs::symlink(project.join(".git"), project.join("foo")).unwrap();
1363
1364        let store = CheckpointStore::open(&root).unwrap();
1365        let id = store.create_checkpoint("t").unwrap();
1366        let mut m = store.manifest(&id).unwrap();
1367        m.files.push(CheckpointFileEntry {
1368            path: "foo/config".to_string(),
1369            blob: Some(store.write_blob(b"PWNED-VIA-SYMLINK").unwrap()),
1370        });
1371        store.write_manifest(&m).unwrap();
1372
1373        let report = store.restore(&id, &project, &[]).unwrap();
1374        assert_eq!(
1375            report.restored.len(),
1376            0,
1377            "must not restore through the symlink into .git"
1378        );
1379        assert_eq!(report.refused.len(), 1);
1380        assert!(
1381            report.refused[0].1.contains("protected"),
1382            "unexpected refusal reason: {}",
1383            report.refused[0].1
1384        );
1385        assert_eq!(
1386            std::fs::read_to_string(project.join(".git").join("config")).unwrap(),
1387            "real git config",
1388            "the real .git/config must be byte-identical — untouched by the symlink entry"
1389        );
1390        std::fs::remove_dir_all(&root).ok();
1391        std::fs::remove_dir_all(&project).ok();
1392    }
1393
1394    #[test]
1395    #[cfg(unix)]
1396    fn restore_refuses_symlink_traversal_into_a_protected_glob() {
1397        // Same shape as the `.git` floor above, but against a caller-
1398        // supplied `protected_globs` entry: a symlink `secrets -> real_env`
1399        // where `real_env/creds` is a file that, once resolved, matches
1400        // the `real_env/**` protected glob — even though the manifest's
1401        // lexical path (`secrets/creds`) does not.
1402        let root = tmp("symlink-glob");
1403        let project = tmp("symlink-glob-project");
1404        std::fs::create_dir_all(project.join("real_env")).unwrap();
1405        std::fs::write(project.join("real_env").join("creds"), "real secret").unwrap();
1406        std::os::unix::fs::symlink(project.join("real_env"), project.join("secrets")).unwrap();
1407
1408        let store = CheckpointStore::open(&root).unwrap();
1409        let id = store.create_checkpoint("t").unwrap();
1410        let mut m = store.manifest(&id).unwrap();
1411        m.files.push(CheckpointFileEntry {
1412            path: "secrets/creds".to_string(),
1413            blob: Some(store.write_blob(b"PWNED-VIA-SYMLINK-GLOB").unwrap()),
1414        });
1415        store.write_manifest(&m).unwrap();
1416
1417        let report = store
1418            .restore(&id, &project, &["real_env/**".to_string()])
1419            .unwrap();
1420        assert_eq!(report.restored.len(), 0);
1421        assert_eq!(report.refused.len(), 1);
1422        assert!(
1423            report.refused[0].1.contains("protected"),
1424            "unexpected refusal reason: {}",
1425            report.refused[0].1
1426        );
1427        assert_eq!(
1428            std::fs::read_to_string(project.join("real_env").join("creds")).unwrap(),
1429            "real secret",
1430            "the real protected file must be untouched by the symlink entry"
1431        );
1432        std::fs::remove_dir_all(&root).ok();
1433        std::fs::remove_dir_all(&project).ok();
1434    }
1435
1436    #[test]
1437    fn prune_keeps_only_the_newest_and_gcs_unreferenced_blobs() {
1438        let root = tmp("prune");
1439        let store = CheckpointStore::open(&root).unwrap();
1440        for i in 0..5 {
1441            let id = store.create_checkpoint(&format!("turn {i}")).unwrap();
1442            store
1443                .record_pre_image(&id, "f.txt", Some(format!("content-{i}").into_bytes()))
1444                .unwrap();
1445            std::thread::sleep(std::time::Duration::from_millis(2));
1446        }
1447        assert_eq!(store.list().unwrap().len(), 5);
1448        let removed = store.prune(2).unwrap();
1449        assert_eq!(removed, 3);
1450        let remaining = store.list().unwrap();
1451        assert_eq!(remaining.len(), 2);
1452        // The two newest survive (turn 4, turn 3).
1453        assert_eq!(remaining[0].label, "turn 4");
1454        assert_eq!(remaining[1].label, "turn 3");
1455        // Every surviving blob is still readable; nothing else lingers.
1456        for meta in &remaining {
1457            let m = store.manifest(&meta.id).unwrap();
1458            for f in &m.files {
1459                if let Some(hash) = &f.blob {
1460                    store.read_blob(hash).unwrap();
1461                }
1462            }
1463        }
1464        std::fs::remove_dir_all(&root).ok();
1465    }
1466
1467    #[test]
1468    fn turn_diff_lists_exactly_the_captured_files() {
1469        let root = tmp("diff");
1470        let store = CheckpointStore::open(&root).unwrap();
1471        let id = store.create_checkpoint("t").unwrap();
1472        store.record_pre_image(&id, "a.rs", None).unwrap();
1473        store
1474            .record_pre_image(&id, "b.rs", Some(b"x".to_vec()))
1475            .unwrap();
1476        let mut diff = store.turn_diff(&id).unwrap();
1477        diff.sort();
1478        assert_eq!(diff, vec!["a.rs".to_string(), "b.rs".to_string()]);
1479        std::fs::remove_dir_all(&root).ok();
1480    }
1481
1482    #[test]
1483    fn manifest_rejects_a_path_traversing_id() {
1484        let root = tmp("badid");
1485        let store = CheckpointStore::open(&root).unwrap();
1486        assert!(store.manifest("../../etc/passwd").is_err());
1487        assert!(store.restore("../../etc/passwd", &root, &[]).is_err());
1488        std::fs::remove_dir_all(&root).ok();
1489    }
1490
1491    #[tokio::test]
1492    async fn observer_before_write_ignores_paths_outside_the_project_root() {
1493        let root = tmp("obs-outside");
1494        let project = tmp("obs-outside-project");
1495        let outside = tmp("obs-outside-elsewhere");
1496        std::fs::write(outside.join("victim.txt"), "do not touch").unwrap();
1497        let store = CheckpointStore::open(&root).unwrap();
1498        let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1499        observer.begin_turn("t");
1500        observer.before_write(&outside.join("victim.txt")).await;
1501        // Nothing captured: the checkpoint's manifest stays empty.
1502        let id = observer.current().unwrap();
1503        let diff = observer.store().turn_diff(&id).unwrap();
1504        assert!(
1505            diff.is_empty(),
1506            "must not capture writes outside project_root"
1507        );
1508        std::fs::remove_dir_all(&root).ok();
1509        std::fs::remove_dir_all(&project).ok();
1510        std::fs::remove_dir_all(&outside).ok();
1511    }
1512
1513    #[tokio::test]
1514    async fn observer_captures_only_the_first_write_to_a_path_in_a_turn() {
1515        let root = tmp("obs-firstwrite");
1516        let project = tmp("obs-firstwrite-project");
1517        std::fs::write(project.join("f.txt"), "v1").unwrap();
1518        let store = CheckpointStore::open(&root).unwrap();
1519        let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1520        observer.begin_turn("t");
1521        observer.before_write(&project.join("f.txt")).await;
1522        std::fs::write(project.join("f.txt"), "v2").unwrap();
1523        observer.before_write(&project.join("f.txt")).await; // second write, same turn
1524        let id = observer.current().unwrap();
1525        let m = observer.store().manifest(&id).unwrap();
1526        assert_eq!(m.files.len(), 1);
1527        let bytes = observer
1528            .store()
1529            .read_blob(m.files[0].blob.as_deref().unwrap())
1530            .unwrap();
1531        assert_eq!(bytes, b"v1", "must keep the EARLIEST pre-image, not v2");
1532        std::fs::remove_dir_all(&root).ok();
1533        std::fs::remove_dir_all(&project).ok();
1534    }
1535
1536    #[tokio::test]
1537    async fn observer_begin_turn_clears_captured_set_for_a_new_turn() {
1538        let root = tmp("obs-newturn");
1539        let project = tmp("obs-newturn-project");
1540        std::fs::write(project.join("f.txt"), "v1").unwrap();
1541        let store = CheckpointStore::open(&root).unwrap();
1542        let observer = CheckpointObserver::new(store, project.clone(), DEFAULT_RETAIN, vec![]);
1543        observer.begin_turn("turn 1");
1544        observer.before_write(&project.join("f.txt")).await;
1545        std::fs::write(project.join("f.txt"), "v2").unwrap();
1546        observer.begin_turn("turn 2");
1547        observer.before_write(&project.join("f.txt")).await;
1548        let id2 = observer.current().unwrap();
1549        let m2 = observer.store().manifest(&id2).unwrap();
1550        assert_eq!(m2.files.len(), 1);
1551        let bytes = observer
1552            .store()
1553            .read_blob(m2.files[0].blob.as_deref().unwrap())
1554            .unwrap();
1555        assert_eq!(
1556            bytes, b"v2",
1557            "turn 2's checkpoint must capture v2 as ITS pre-image"
1558        );
1559        std::fs::remove_dir_all(&root).ok();
1560        std::fs::remove_dir_all(&project).ok();
1561    }
1562
1563    #[test]
1564    fn is_protected_hard_floor_covers_dot_git_regardless_of_extra_globs() {
1565        assert!(is_protected(".git", &[]));
1566        assert!(is_protected(".git/config", &[]));
1567        assert!(is_protected(".git/objects/aa/bb", &[]));
1568        assert!(!is_protected(".gitignore", &[]));
1569        assert!(!is_protected("src/main.rs", &[]));
1570    }
1571
1572    #[test]
1573    fn contained_rejects_symlink_escape_for_an_existing_target() {
1574        let project = tmp("symlink-project");
1575        let outside = tmp("symlink-outside");
1576        std::fs::write(outside.join("secret.txt"), "s").unwrap();
1577        #[cfg(unix)]
1578        {
1579            std::os::unix::fs::symlink(outside.join("secret.txt"), project.join("link.txt"))
1580                .unwrap();
1581            assert!(!contained(&project, &project.join("link.txt")));
1582        }
1583        std::fs::remove_dir_all(&project).ok();
1584        std::fs::remove_dir_all(&outside).ok();
1585    }
1586
1587    #[test]
1588    fn contained_accepts_a_brand_new_file_inside_the_root() {
1589        let project = tmp("newfile-project");
1590        assert!(contained(&project, &project.join("does_not_exist_yet.txt")));
1591        assert!(contained(&project, &project.join("nested/dir/new.txt")));
1592        std::fs::remove_dir_all(&project).ok();
1593    }
1594}