Require committed restore state
This commit is contained in:
@@ -534,8 +534,8 @@ func TestExecuteRestorePlanRejectsAncestorSymlinkCreatedAfterPlanning(t *testing
|
|||||||
cfg := restorePlanConfig(t)
|
cfg := restorePlanConfig(t)
|
||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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+"artifacts/session_recap.md", []byte("remote-content\n"))
|
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})
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildRestorePlan() error = %v", err)
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
|||||||
@@ -134,10 +134,7 @@ func buildCurrentRestoreActions(
|
|||||||
sessionRoot *os.Root,
|
sessionRoot *os.Root,
|
||||||
opts RestorePlanOptions,
|
opts RestorePlanOptions,
|
||||||
) ([]RestoreAction, error) {
|
) ([]RestoreAction, error) {
|
||||||
if current != nil && current.Commit != nil {
|
return buildCommittedRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts)
|
||||||
return buildCommittedRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts)
|
|
||||||
}
|
|
||||||
return buildLegacyRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCommittedRestoreActions(
|
func buildCommittedRestoreActions(
|
||||||
@@ -182,60 +179,6 @@ func buildCommittedRestoreActions(
|
|||||||
return actions, nil
|
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) {
|
func restoreLocalRelativePathForCommittedArtifact(runPrefix string, artifact artifacts.RemoteArtifact, includeAudio bool) (string, bool, error) {
|
||||||
if artifact.Type == artifacts.RemoteArtifactTypeSessionManifest {
|
if artifact.Type == artifacts.RemoteArtifactTypeSessionManifest {
|
||||||
return config.PathManifestFile, true, nil
|
return config.PathManifestFile, true, nil
|
||||||
@@ -268,69 +211,6 @@ func normalizeRemoteKey(v string) string {
|
|||||||
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/")
|
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) {
|
func joinWithinSessionRoot(sessionRoot, relative string) (string, error) {
|
||||||
if strings.TrimSpace(sessionRoot) == "" {
|
if strings.TrimSpace(sessionRoot) == "" {
|
||||||
return "", fmt.Errorf("session root is required")
|
return "", fmt.Errorf("session root is required")
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ func TestRestorePlanDefaultScope(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
||||||
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
|
||||||
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
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+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}"))
|
||||||
seedRestoreObject(store, current.SessionPrefix+"logs/publish.log", []byte("log"))
|
seedRestoreObject(store, current.SessionPrefix+"logs/publish.log", []byte("log"))
|
||||||
|
|
||||||
@@ -45,8 +45,8 @@ func TestRestorePlanIncludeAudio(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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+"audio/alice.flac", []byte("audio"))
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
||||||
|
|
||||||
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,8 +64,8 @@ func TestRestorePlanExistingAudioRequiresVerifiedReplacement(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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+"audio/alice.flac", []byte("audio"))
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
mustWriteTestFile(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "local")
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "local")
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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")
|
seedRestorePreviousCurrent(t, store, cfg, "# previous recap\n")
|
||||||
|
|
||||||
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
@@ -122,7 +122,7 @@ func TestRestorePlanIgnoresCurrentSessionArchivedPreviousCache(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
|
||||||
seedRestoreObject(store, current.SessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
|
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)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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)
|
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
|
||||||
|
|
||||||
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
@@ -165,7 +165,7 @@ func TestRestorePlanMissingRequiredPreviousCacheFails(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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)
|
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
|
||||||
|
|
||||||
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
@@ -180,7 +180,7 @@ func TestRestorePlanPreviousCacheConflictRequiresForce(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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")
|
seedRestorePreviousCurrent(t, store, cfg, "# remote previous recap\n")
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
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")
|
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)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
seedCurrentRestoreObject(t, store, current, 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.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
||||||
|
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`)
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`)
|
||||||
@@ -244,8 +244,8 @@ func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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+"artifacts/session_recap.md", []byte("remote-content\n"))
|
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)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
|
||||||
@@ -303,8 +303,8 @@ func TestRestorePlanSymlinksRemainNonRegularConflicts(t *testing.T) {
|
|||||||
cfg := restorePlanConfig(t)
|
cfg := restorePlanConfig(t)
|
||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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+"artifacts/session_recap.md", []byte("remote-content\n"))
|
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)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
tt.setup(t, sessionRoot)
|
tt.setup(t, sessionRoot)
|
||||||
|
|
||||||
@@ -338,18 +338,34 @@ func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) {
|
|||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
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+"artifacts/../../escape.txt", []byte("bad"))
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad"))
|
||||||
|
|
||||||
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got 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)
|
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) {
|
func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) {
|
||||||
if strings.HasSuffix(key, "/current/commit-pointer.json") {
|
if strings.HasSuffix(key, "/current/commit-pointer.json") {
|
||||||
if _, exists := store.Objects[key]; exists {
|
if _, exists := store.Objects[key]; exists {
|
||||||
@@ -475,6 +491,12 @@ func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentSta
|
|||||||
RunID: "20260519T010203Z-a1b2c3d4",
|
RunID: "20260519T010203Z-a1b2c3d4",
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
Campaign: cfg.Session.Campaign,
|
Campaign: cfg.Session.Campaign,
|
||||||
|
Commit: &artifacts.RemoteCommitManifest{
|
||||||
|
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||||
|
Campaign: cfg.Session.Campaign,
|
||||||
|
SessionID: cfg.Session.SessionID,
|
||||||
|
RunID: "20260519T010203Z-a1b2c3d4",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user