mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* Session checkpoints & safe rewind: content-addressed blobs, atomic restore, file locking Module 4 of the runtime-core split. Scope: internal/sessions only. 3-way merged cleanly onto main's #106 Tag/Depth work (preserved); no new deps (filelock uses stdlib syscall); build/vet/-race/windows-cross-compile/full-suite green. - CaptureToolCheckpoint snapshots a tool's target files into content-addressed (sha256) blobs before a mutating tool runs; SnapshotForCheckpoint records file mode; oversize files are skipped, identical content deduped. - RestoreToSequence reverts to a target sequence: applies only the closest-to-target checkpoint per path, verifies each blob's sha256 before writing, preserves file mode, and confines paths with EvalSymlinks (no symlink escape). ApplyRewind runs restore+truncate+prune+marker atomically under one session lock. - Cross-process file lock (flock) serializes session mutations; Fork copies checkpoint blobs so a fork can rewind; writeMetadata uses a unique tmp suffix. FORWARD: this is the persistence layer; the CLI 'zero sessions rewind' and the TUI '/rewind' + pre-tool checkpoint capture wiring land in later modules. Exercised here by checkpoint_test.go / rewind_test.go. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * Address #112 review: capture-side confinement, atomic-only checkpoint API, Windows lock, test hygiene - [critical/human blocker] snapshotForCheckpoint now confines every capture target with resolveWithinWorkspace (same EvalSymlinks/no-".." guard as restore) BEFORE stat/read, so a traversal/symlink target can't read files outside the workspace into a blob. os.Stat failures are classified correctly: only os.IsNotExist -> Absent (restore deletes); permission/IO/symlink-loop -> Skipped (restore leaves alone), instead of treating every failure as Absent. Regression test added (TestCaptureRejectsTraversalTargets). - [major] SnapshotForCheckpoint unexported -> snapshotForCheckpoint: the blob writes and the referencing EventSessionCheckpoint must be committed atomically under one session lock, which only CaptureToolCheckpoint guarantees. Removes the unsafe snapshot-only external entry point (blob/event gap). - [major] Windows: real LockFileEx/UnlockFileEx (filelock_windows.go) replacing the no-op, so cross-process/cross-Store session serialization holds on Windows too. No new deps (x/sys already required for the unix flock). - [minor] checkpoint_test hygiene: check os.WriteFile/ReadDir/ReadFile/ReadEvents errors via helpers, add bounds checks before p.Files[0]/events[...] so setup failures fail at the source instead of panicking or passing spuriously. - rewind restore: documented the residual symlink-swap TOCTOU (boundary re-resolved immediately before the mutation; full closure needs openat2 RESOLVE_BENEATH / per-component O_NOFOLLOW, tracked for the CLI/TUI rewind-wiring work). build/vet/-race/full-suite + GOOS=windows build all green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * Address #112 re-review: fail rewind on undecodable checkpoint payload [major] restoreToSequenceLocked silently continued past a checkpoint whose payload failed to decode, which let a NEWER checkpoint win for that path and restore a later state than the caller requested. Treat corruption as a hard rewind error. Regression test TestRestoreFailsOnCorruptCheckpointPayload (fails-open before, errors now). Re-review note: the earlier capture-target-validation (critical) and test bounds (minor) were already addressed in600337b(resolveWithinWorkspace guard + len checks); those re-anchored threads are stale. The restore-side symlink-swap TOCTOU remains a documented heavy-lift residual (needs openat2/descriptor traversal), tracked for the rewind-wiring work. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * Address #112 re-review: dedupe rewind on resolved path, not raw path [major] restoreToSequenceLocked deduped the per-path closest-to-target short-circuit on the RAW checkpoint path, so equivalent paths (./a.txt, dir/../a.txt, a symlink alias) keyed differently and a NEWER checkpoint could overwrite the closest-to-target snapshot for the same underlying file. Now resolve/confine FIRST and dedupe on the resolved workspace path (unresolvable targets dedupe on raw path). Regression test TestRestoreDedupesEquivalentRawPaths. This + the prior commit (24e9ba2, fail-hard on undecodable payload) address both correctness blockers @Vasanthdev2004 raised after CodeRabbit's pass. build/vet/-race/full-suite + GOOS=windows build green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> --------- Co-authored-by: KRATOS <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
337 lines
11 KiB
Go
337 lines
11 KiB
Go
package sessions
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func newCkStore(t *testing.T) (*Store, string) {
|
|
t.Helper()
|
|
store := NewStore(StoreOptions{RootDir: t.TempDir()})
|
|
if _, err := store.Create(CreateInput{SessionID: "s"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return store, t.TempDir() // store, workspaceRoot
|
|
}
|
|
|
|
func decodeCk(t *testing.T, ev Event) CheckpointPayload {
|
|
t.Helper()
|
|
var p CheckpointPayload
|
|
if err := json.Unmarshal(ev.Payload, &p); err != nil {
|
|
t.Fatalf("decode checkpoint payload: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func TestCaptureToolCheckpointWritesBlobAndEvent(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
if err := os.WriteFile(filepath.Join(ws, "a.txt"), []byte("v1"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev, err := store.CaptureToolCheckpoint("s", ws, "edit_file", []string{"a.txt"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ev.Type != EventSessionCheckpoint {
|
|
t.Fatalf("event type = %s", ev.Type)
|
|
}
|
|
p := decodeCk(t, ev)
|
|
if len(p.Files) != 1 || p.Files[0].Path != "a.txt" || p.Files[0].Blob == "" || p.Files[0].Bytes != 2 {
|
|
t.Fatalf("unexpected payload: %+v", p)
|
|
}
|
|
if _, err := store.readBlob("s", p.Files[0].Blob); err != nil {
|
|
t.Fatalf("blob not stored: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCaptureDedupsIdenticalContent(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
mustWriteFile(t, filepath.Join(ws, "a.txt"), "same")
|
|
mustWriteFile(t, filepath.Join(ws, "b.txt"), "same")
|
|
if _, err := store.CaptureToolCheckpoint("s", ws, "write_file", []string{"a.txt"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.CaptureToolCheckpoint("s", ws, "write_file", []string{"b.txt"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, err := os.ReadDir(store.blobsDir("s"))
|
|
if err != nil {
|
|
t.Fatalf("read blobs dir: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("expected 1 deduped blob, got %d", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestCaptureRecordsAbsentForNewFile(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
ev, err := store.CaptureToolCheckpoint("s", ws, "write_file", []string{"new.txt"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := decodeCk(t, ev)
|
|
if len(p.Files) != 1 {
|
|
t.Fatalf("expected 1 file entry, got %+v", p.Files)
|
|
}
|
|
if !p.Files[0].Absent || p.Files[0].Blob != "" {
|
|
t.Fatalf("expected absent marker, got %+v", p.Files[0])
|
|
}
|
|
}
|
|
|
|
func TestCaptureRejectsTraversalTargets(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
// A file outside the workspace must never be read into a blob or marked
|
|
// Absent (which would delete it on rewind). Regression for capture-side
|
|
// path-traversal confinement.
|
|
outside := filepath.Join(filepath.Dir(ws), "secret.txt")
|
|
mustWriteFile(t, outside, "top secret")
|
|
ev, err := store.CaptureToolCheckpoint("s", ws, "write_file", []string{"../secret.txt"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := decodeCk(t, ev)
|
|
if len(p.Files) != 1 {
|
|
t.Fatalf("expected 1 file entry, got %+v", p.Files)
|
|
}
|
|
if f := p.Files[0]; !f.Skipped || f.Absent || f.Blob != "" {
|
|
t.Fatalf("traversal target must be skipped (not captured/absent), got %+v", f)
|
|
}
|
|
if got, rerr := os.ReadFile(outside); rerr != nil || string(got) != "top secret" {
|
|
t.Fatalf("outside file must remain untouched: got %q err %v", got, rerr)
|
|
}
|
|
}
|
|
|
|
func TestCaptureSkipsOversizeFiles(t *testing.T) {
|
|
t.Setenv("ZERO_CHECKPOINT_MAX_BYTES", "4")
|
|
store, ws := newCkStore(t)
|
|
mustWriteFile(t, filepath.Join(ws, "big.txt"), "123456")
|
|
ev, err := store.CaptureToolCheckpoint("s", ws, "write_file", []string{"big.txt"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := decodeCk(t, ev)
|
|
if len(p.Files) != 1 {
|
|
t.Fatalf("expected 1 file entry, got %+v", p.Files)
|
|
}
|
|
if !p.Files[0].Skipped || p.Files[0].Blob != "" {
|
|
t.Fatalf("expected skipped, got %+v", p.Files[0])
|
|
}
|
|
}
|
|
|
|
func TestCaptureDisabled(t *testing.T) {
|
|
t.Setenv("ZERO_CHECKPOINTS", "off")
|
|
store, ws := newCkStore(t)
|
|
mustWriteFile(t, filepath.Join(ws, "a.txt"), "v1")
|
|
ev, err := store.CaptureToolCheckpoint("s", ws, "write_file", []string{"a.txt"})
|
|
if err != nil || ev.Type != "" {
|
|
t.Fatalf("expected no-op when disabled, got ev=%+v err=%v", ev, err)
|
|
}
|
|
}
|
|
|
|
func TestTruncateEvents(t *testing.T) {
|
|
store, _ := newCkStore(t)
|
|
var seqs []int
|
|
for i := 0; i < 3; i++ {
|
|
ev, err := store.AppendEvent("s", AppendEventInput{Type: EventMessage, Payload: map[string]any{"i": i}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seqs = append(seqs, ev.Sequence)
|
|
}
|
|
if err := store.TruncateEvents("s", seqs[1]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
events, err := store.ReadEvents("s")
|
|
if err != nil {
|
|
t.Fatalf("read events: %v", err)
|
|
}
|
|
if len(events) != 2 || events[len(events)-1].Sequence != seqs[1] {
|
|
t.Fatalf("expected 2 events through seq %d, got %d", seqs[1], len(events))
|
|
}
|
|
}
|
|
|
|
func TestRestoreToSequenceRevertsFileContent(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
target, _ := store.AppendEvent("s", AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
|
|
path := filepath.Join(ws, "a.txt")
|
|
mustWriteFile(t, path, "original")
|
|
mustCapture(t, store, ws, "write_file", "a.txt") // captures "original"
|
|
mustWriteFile(t, path, "edited1")
|
|
mustCapture(t, store, ws, "edit_file", "a.txt") // captures "edited1"
|
|
mustWriteFile(t, path, "edited2")
|
|
|
|
report, err := store.RestoreToSequence("s", ws, target.Sequence)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read restored file: %v", err)
|
|
}
|
|
if string(got) != "original" {
|
|
t.Fatalf("restore should yield 'original', got %q", got)
|
|
}
|
|
if report.FilesRestored < 1 {
|
|
t.Fatalf("expected FilesRestored>=1, got %+v", report)
|
|
}
|
|
}
|
|
|
|
func TestRestoreDeletesFileThatWasAbsent(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
target, _ := store.AppendEvent("s", AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
|
|
path := filepath.Join(ws, "new.txt")
|
|
mustCapture(t, store, ws, "write_file", "new.txt") // absent before
|
|
mustWriteFile(t, path, "created")
|
|
|
|
report, err := store.RestoreToSequence("s", ws, target.Sequence)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("expected file deleted on restore, stat err=%v", err)
|
|
}
|
|
if report.FilesDeleted < 1 {
|
|
t.Fatalf("expected FilesDeleted>=1, got %+v", report)
|
|
}
|
|
}
|
|
|
|
func TestApplyRewindRestoresAndTruncates(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
target, _ := store.AppendEvent("s", AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
|
|
path := filepath.Join(ws, "a.txt")
|
|
mustWriteFile(t, path, "original")
|
|
mustCapture(t, store, ws, "write_file", "a.txt")
|
|
mustWriteFile(t, path, "changed")
|
|
|
|
report, err := store.ApplyRewind("s", ws, target.Sequence)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, rerr := os.ReadFile(path)
|
|
if rerr != nil {
|
|
t.Fatalf("read restored file: %v", rerr)
|
|
}
|
|
if string(got) != "original" {
|
|
t.Fatalf("file not restored: %q", got)
|
|
}
|
|
events, err := store.ReadEvents("s")
|
|
if err != nil {
|
|
t.Fatalf("read events: %v", err)
|
|
}
|
|
if len(events) < 2 {
|
|
t.Fatalf("expected >=2 events, got %d", len(events))
|
|
}
|
|
last := events[len(events)-1]
|
|
if last.Type != EventSessionRewind {
|
|
t.Fatalf("expected trailing rewind marker, got %s", last.Type)
|
|
}
|
|
// kept events through target + the appended rewind marker
|
|
if events[len(events)-2].Sequence != target.Sequence {
|
|
t.Fatalf("expected truncation to target seq %d", target.Sequence)
|
|
}
|
|
_ = report
|
|
}
|
|
|
|
func TestRestoreRejectsPathTraversal(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
target, _ := store.AppendEvent("s", AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
|
|
// Hand-craft a tampered checkpoint event with a traversal path.
|
|
outside := filepath.Join(filepath.Dir(ws), "evil.txt")
|
|
mustWriteFile(t, outside, "keep me")
|
|
mustAppend(t, store, AppendEventInput{Type: EventSessionCheckpoint, Payload: CheckpointPayload{
|
|
Tool: "write_file",
|
|
Files: []CheckpointFile{{Path: "../evil.txt", Absent: true}},
|
|
}})
|
|
report, err := store.RestoreToSequence("s", ws, target.Sequence)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(outside); err != nil {
|
|
t.Fatalf("restore must NOT delete files outside the workspace: %v", err)
|
|
}
|
|
if len(report.Skipped) == 0 {
|
|
t.Errorf("expected traversal path reported as skipped, got %+v", report)
|
|
}
|
|
}
|
|
|
|
func TestRestoreDedupesEquivalentRawPaths(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
target := mustAppend(t, store, AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
|
|
path := filepath.Join(ws, "a.txt")
|
|
|
|
// Closest-to-target checkpoint captures "closest" via raw path "a.txt".
|
|
mustWriteFile(t, path, "closest")
|
|
mustCapture(t, store, ws, "write_file", "a.txt")
|
|
// A NEWER checkpoint references the SAME file via an equivalent raw path.
|
|
mustWriteFile(t, path, "newer")
|
|
mustCapture(t, store, ws, "edit_file", "./a.txt")
|
|
mustWriteFile(t, path, "current")
|
|
|
|
if _, err := store.RestoreToSequence("s", ws, target.Sequence); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The closest-to-target snapshot must win; the newer entry under the
|
|
// equivalent raw path must not overwrite it.
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read restored file: %v", err)
|
|
}
|
|
if string(got) != "closest" {
|
|
t.Fatalf("equivalent raw path bypassed dedupe: got %q, want \"closest\"", got)
|
|
}
|
|
}
|
|
|
|
func TestRestoreFailsOnCorruptCheckpointPayload(t *testing.T) {
|
|
store, ws := newCkStore(t)
|
|
target := mustAppend(t, store, AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
|
|
// A checkpoint event whose payload does not decode into CheckpointPayload must
|
|
// abort the rewind, not be silently skipped (which could restore a later state).
|
|
mustAppend(t, store, AppendEventInput{Type: EventSessionCheckpoint, Payload: "corrupt-not-a-checkpoint"})
|
|
if _, err := store.RestoreToSequence("s", ws, target.Sequence); err == nil {
|
|
t.Fatal("expected rewind to fail on undecodable checkpoint payload")
|
|
}
|
|
}
|
|
|
|
func TestTruncateToZeroProducesEmptyFile(t *testing.T) {
|
|
store, _ := newCkStore(t)
|
|
mustAppend(t, store, AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
|
|
if err := store.TruncateEvents("s", 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
events, err := store.ReadEvents("s")
|
|
if err != nil {
|
|
t.Fatalf("read events: %v", err)
|
|
}
|
|
if len(events) != 0 {
|
|
t.Fatalf("expected 0 events after truncate-to-0, got %d", len(events))
|
|
}
|
|
}
|
|
|
|
// mustWriteFile / mustCapture / mustAppend are setup helpers that fail the test
|
|
// immediately on error, so a setup failure is diagnosed at its source instead of
|
|
// surfacing as a confusing downstream assertion.
|
|
func mustWriteFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func mustCapture(t *testing.T, store *Store, ws, tool, path string) {
|
|
t.Helper()
|
|
if _, err := store.CaptureToolCheckpoint("s", ws, tool, []string{path}); err != nil {
|
|
t.Fatalf("CaptureToolCheckpoint(%s, %s): %v", tool, path, err)
|
|
}
|
|
}
|
|
|
|
func mustAppend(t *testing.T, store *Store, input AppendEventInput) Event {
|
|
t.Helper()
|
|
ev, err := store.AppendEvent("s", input)
|
|
if err != nil {
|
|
t.Fatalf("AppendEvent: %v", err)
|
|
}
|
|
return ev
|
|
}
|