Require committed restore state

This commit is contained in:
2026-09-02 14:36:02 +00:00
parent e9c4873030
commit ccbd7ee820
3 changed files with 48 additions and 146 deletions

View File

@@ -18,10 +18,10 @@ func TestRestorePlanDefaultScope(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}"))
seedRestoreObject(store, current.SessionPrefix+"logs/publish.log", []byte("log"))
@@ -45,8 +45,8 @@ func TestRestorePlanIncludeAudio(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
if err != nil {
@@ -64,8 +64,8 @@ func TestRestorePlanExistingAudioRequiresVerifiedReplacement(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "local")
@@ -102,7 +102,7 @@ func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestorePreviousCurrent(t, store, cfg, "# previous recap\n")
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
@@ -122,7 +122,7 @@ func TestRestorePlanIgnoresCurrentSessionArchivedPreviousCache(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
seedRestoreObject(store, current.SessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
@@ -144,7 +144,7 @@ func TestRestorePlanMissingOptionalPreviousCacheSkipsArtifact(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
@@ -165,7 +165,7 @@ func TestRestorePlanMissingRequiredPreviousCacheFails(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
@@ -180,7 +180,7 @@ func TestRestorePlanPreviousCacheConflictRequiresForce(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestorePreviousCurrent(t, store, cfg, "# remote previous recap\n")
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
@@ -207,9 +207,9 @@ func TestRestorePlanClassifiesSameAndConflict(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`)
@@ -244,8 +244,8 @@ func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
@@ -303,8 +303,8 @@ func TestRestorePlanSymlinksRemainNonRegularConflicts(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
tt.setup(t, sessionRoot)
@@ -338,18 +338,34 @@ func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) {
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad"))
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad"))
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "escapes session scope") {
if !strings.Contains(err.Error(), "committed destination is unsafe") {
t.Fatalf("error = %v, want traversal safety failure", err)
}
}
func seedCurrentRestoreObject(t *testing.T, store *storage.FakeBackend, current *RemoteCurrentState, key string, data []byte) {
t.Helper()
artifactType := artifacts.RemoteArtifactTypePublishedOutput
source := "narratio.restore." + strings.ReplaceAll(strings.TrimPrefix(key, current.SessionPrefix), "/", ".")
destination := key
if key == current.CurrentManifestKey {
artifactType = artifacts.RemoteArtifactTypeSessionManifest
source = "session.manifest"
current.ManifestData = append([]byte(nil), data...)
} else if strings.HasPrefix(key, current.SessionPrefix) {
destination = artifacts.S3RunPrefix(current.SessionPrefix, current.RunID) + strings.TrimPrefix(key, current.SessionPrefix)
}
seedRestoreObject(store, destination, data)
current.Commit.Artifacts = append(current.Commit.Artifacts, remoteRestoreArtifact(store, artifactType, source, destination))
}
func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) {
if strings.HasSuffix(key, "/current/commit-pointer.json") {
if _, exists := store.Objects[key]; exists {
@@ -475,6 +491,12 @@ func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentSta
RunID: "20260519T010203Z-a1b2c3d4",
SessionID: cfg.Session.SessionID,
Campaign: cfg.Session.Campaign,
Commit: &artifacts.RemoteCommitManifest{
FormatVersion: artifacts.RemoteCommitFormatVersion,
Campaign: cfg.Session.Campaign,
SessionID: cfg.Session.SessionID,
RunID: "20260519T010203Z-a1b2c3d4",
},
}
}