diff --git a/internal/app/restore_execution_test.go b/internal/app/restore_execution_test.go index a47b444..d7ab2bc 100644 --- a/internal/app/restore_execution_test.go +++ b/internal/app/restore_execution_test.go @@ -534,8 +534,8 @@ func TestExecuteRestorePlanRejectsAncestorSymlinkCreatedAfterPlanning(t *testing 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")) plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true}) if err != nil { t.Fatalf("buildRestorePlan() error = %v", err) diff --git a/internal/app/restore_plan.go b/internal/app/restore_plan.go index f4631fb..92b6190 100644 --- a/internal/app/restore_plan.go +++ b/internal/app/restore_plan.go @@ -134,10 +134,7 @@ func buildCurrentRestoreActions( sessionRoot *os.Root, opts RestorePlanOptions, ) ([]RestoreAction, error) { - if current != nil && current.Commit != nil { - return buildCommittedRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts) - } - return buildLegacyRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts) + return buildCommittedRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts) } func buildCommittedRestoreActions( @@ -182,60 +179,6 @@ func buildCommittedRestoreActions( return actions, nil } -func buildLegacyRestoreActions( - ctx context.Context, - current *RemoteCurrentState, - store storage.ObjectStore, - sessionPaths artifacts.SessionPaths, - sessionRoot *os.Root, - opts RestorePlanOptions, -) ([]RestoreAction, error) { - prefix := normalizeRemoteKey(current.SessionPrefix) - if strings.TrimSpace(prefix) == "" { - return nil, fmt.Errorf("remote session prefix is required") - } - if !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - objects, err := store.List(ctx, prefix) - if err != nil { - return nil, fmt.Errorf("list remote session objects under %q: %w", prefix, err) - } - candidates := make(map[string]storage.ObjectInfo, len(objects)+1) - for _, obj := range objects { - key := normalizeRemoteKey(obj.Key) - if key != "" { - obj.Key = key - candidates[key] = obj - } - } - if key := normalizeRemoteKey(current.CurrentManifestKey); key != "" { - if _, ok := candidates[key]; !ok { - candidates[key] = storage.ObjectInfo{Key: key} - } - } - actions := make([]RestoreAction, 0, len(candidates)) - for key, object := range candidates { - rel, include, err := restoreLocalRelativePathForKey(prefix, normalizeRemoteKey(current.CurrentManifestKey), key, opts.IncludeAudio) - if err != nil { - return nil, fmt.Errorf("map remote key %q: %w", key, err) - } - if !include { - continue - } - localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel) - if err != nil { - return nil, fmt.Errorf("map remote key %q: %w", key, err) - } - action, err := classifyRestoreAction(ctx, store, object, "", rel, localPath, sessionRoot, opts.Force) - if err != nil { - return nil, fmt.Errorf("classify remote key %q: %w", key, err) - } - actions = append(actions, action) - } - return actions, nil -} - func restoreLocalRelativePathForCommittedArtifact(runPrefix string, artifact artifacts.RemoteArtifact, includeAudio bool) (string, bool, error) { if artifact.Type == artifacts.RemoteArtifactTypeSessionManifest { return config.PathManifestFile, true, nil @@ -268,69 +211,6 @@ func normalizeRemoteKey(v string) string { return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/") } -func restoreLocalRelativePathForKey(sessionPrefix, currentManifestKey, key string, includeAudio bool) (string, bool, error) { - if key == "" { - return "", false, nil - } - if key == currentManifestKey { - return config.PathManifestFile, true, nil - } - if !strings.HasPrefix(key, sessionPrefix) { - return "", false, fmt.Errorf("key is outside resolved session prefix %q", sessionPrefix) - } - - rel := strings.TrimPrefix(key, sessionPrefix) - rel = strings.TrimSpace(rel) - if rel == "" { - return "", false, nil - } - - cleanRel := path.Clean(rel) - if cleanRel == "." || cleanRel == "" { - return "", false, nil - } - if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") { - return "", false, fmt.Errorf("key relative path %q escapes session scope", rel) - } - - if cleanRel == config.PathManifestFile { - return config.PathManifestFile, true, nil - } - if strings.HasPrefix(cleanRel, config.S3CurrentSegment+"/") { - return "", false, nil - } - if strings.HasPrefix(cleanRel, config.S3RunsSegment+"/") { - return "", false, nil - } - - excludedRoots := []string{ - config.PathLogsDirSegment, - config.PathReportsDirSegment, - config.PathConfigDirSegment, - config.PathInputsDirSegment, - } - for _, root := range excludedRoots { - if cleanRel == root || strings.HasPrefix(cleanRel, root+"/") { - return "", false, nil - } - } - - if cleanRel == config.PathTranscriptsSegment || strings.HasPrefix(cleanRel, config.PathTranscriptsSegment+"/") { - return cleanRel, true, nil - } - if cleanRel == config.PathArtifactsDirSegment || strings.HasPrefix(cleanRel, config.PathArtifactsDirSegment+"/") { - return cleanRel, true, nil - } - if cleanRel == config.PathPreviousDirSegment || strings.HasPrefix(cleanRel, config.PathPreviousDirSegment+"/") { - return "", false, nil - } - if includeAudio && (cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")) { - return cleanRel, true, nil - } - - return "", false, nil -} - func joinWithinSessionRoot(sessionRoot, relative string) (string, error) { if strings.TrimSpace(sessionRoot) == "" { return "", fmt.Errorf("session root is required") diff --git a/internal/app/restore_plan_test.go b/internal/app/restore_plan_test.go index a99c9d4..21dc4ec 100644 --- a/internal/app/restore_plan_test.go +++ b/internal/app/restore_plan_test.go @@ -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", + }, } }