diff --git a/internal/app/restore_commit_test.go b/internal/app/restore_commit_test.go index 6535496..951ea4e 100644 --- a/internal/app/restore_commit_test.go +++ b/internal/app/restore_commit_test.go @@ -173,6 +173,58 @@ func TestCommittedRestoreRejectsChangedDeclaredObjectGeneration(t *testing.T) { } } +func TestCommittedRestoreRejectsChangedAudioBeforeInstall(t *testing.T) { + tests := []struct { + name string + data []byte + generation string + wantError string + }{ + {name: "content", data: []byte("tampered-audio"), wantError: "checksum mismatch"}, + {name: "generation", data: []byte("original-audio"), generation: "replacement-generation", wantError: "generation mismatch"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := restorePlanConfig(t) + cfg.Pipeline.Spool.Root = filepath.Join(t.TempDir(), "spool") + cacheDisabled := false + cfg.Pipeline.Cache.S3Audio = &cacheDisabled + fake := &storage.FakeBackend{} + current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ + "audio/alice.flac": []byte("original-audio"), + }) + plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{IncludeAudio: true}) + if err != nil { + t.Fatalf("buildRestorePlan() error = %v", err) + } + key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, current.RunID), "audio/alice.flac") + generation := test.generation + if generation == "" { + generation = fake.Objects[key].ETag + } + fake.SeedObject(storage.FakeObject{Key: key, Data: test.data, ETag: generation}) + + root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) + destination := filepath.Join(root, "audio", "alice.flac") + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + t.Fatalf("MkdirAll(audio directory) error = %v", err) + } + if err := os.WriteFile(destination, []byte("local-audio-remains"), 0o644); err != nil { + t.Fatalf("WriteFile(existing audio) error = %v", err) + } + + _, err = executeRestorePlan(context.Background(), cfg, current, plan, nil, fake) + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("executeRestorePlan() error = %v, want %q", err, test.wantError) + } + mustReadEquals(t, destination, "local-audio-remains") + if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) { + t.Fatalf("manifest should not be installed after failed audio restore; stat err=%v", err) + } + }) + } +} + func TestCommittedRestoreRejectsMissingDeclaredObjectBeforeManifestInstall(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} diff --git a/internal/app/restore_execute.go b/internal/app/restore_execute.go index 2073995..3403f26 100644 --- a/internal/app/restore_execute.go +++ b/internal/app/restore_execute.go @@ -117,7 +117,7 @@ func executeRestoreDownloadAction( } if restoreActionIsAudio(action) { - return executeRestoreAudioAction(ctx, cfg, safeLocalPath, action, store) + return executeRestoreAudioAction(ctx, cfg, safeLocalPath, parent, leaf, action, store) } temporaryPath, err := downloadRestoreTemp(safeLocalPath, func(destination io.Writer) error { @@ -324,6 +324,8 @@ func executeRestoreAudioAction( ctx context.Context, cfg *config.Config, safeLocalPath string, + parent *os.Root, + leaf string, action RestoreAction, store storage.ObjectStore, ) error { @@ -332,8 +334,19 @@ func executeRestoreAudioAction( } spoolDir := artifacts.SessionSpoolRestoreAudioDir(cfg.Pipeline.Spool.Root, cfg.Session.Campaign, cfg.Session.SessionID) spoolPath := filepath.Join(spoolDir, filepath.Base(safeLocalPath)) + temporary, err := os.CreateTemp(filepath.Dir(safeLocalPath), "."+filepath.Base(safeLocalPath)+".restore-*") + if err != nil { + return fmt.Errorf("create sibling audio staging file: %w", err) + } + temporaryPath := temporary.Name() + if err := temporary.Close(); err != nil { + _ = os.Remove(temporaryPath) + return fmt.Errorf("close sibling audio staging file: %w", err) + } + defer func() { _ = os.Remove(temporaryPath) }() + cacheEnabled := cfg.Pipeline.Cache.S3Audio == nil || *cfg.Pipeline.Cache.S3Audio - _, err := audio.MaterializeS3Audio(ctx, audio.S3MaterializeRequest{ + _, err = audio.MaterializeS3Audio(ctx, audio.S3MaterializeRequest{ Store: store, Object: storage.ObjectInfo{ Key: action.RemoteKey, @@ -344,10 +357,19 @@ func executeRestoreAudioAction( CacheRoot: strings.TrimSpace(cfg.Pipeline.Cache.Root), CacheEnabled: cacheEnabled, SpoolPath: spoolPath, - DestPath: safeLocalPath, + DestPath: temporaryPath, }) if err != nil { return fmt.Errorf("materialize audio: %w", err) } + if err := verifyRestoredObject(ctx, store, action, temporaryPath); err != nil { + return err + } + if err := validateRestoreDestinationLeaf(parent, leaf); err != nil { + return err + } + if err := fileops.InstallSiblingTempFile(temporaryPath, safeLocalPath, fileops.WorkspaceFileMode); err != nil { + return fmt.Errorf("install verified audio atomically: %w", err) + } return nil }