Verify restored audio before installation
This commit is contained in:
@@ -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) {
|
func TestCommittedRestoreRejectsMissingDeclaredObjectBeforeManifestInstall(t *testing.T) {
|
||||||
cfg := restorePlanConfig(t)
|
cfg := restorePlanConfig(t)
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func executeRestoreDownloadAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if restoreActionIsAudio(action) {
|
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 {
|
temporaryPath, err := downloadRestoreTemp(safeLocalPath, func(destination io.Writer) error {
|
||||||
@@ -324,6 +324,8 @@ func executeRestoreAudioAction(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
safeLocalPath string,
|
safeLocalPath string,
|
||||||
|
parent *os.Root,
|
||||||
|
leaf string,
|
||||||
action RestoreAction,
|
action RestoreAction,
|
||||||
store storage.ObjectStore,
|
store storage.ObjectStore,
|
||||||
) error {
|
) error {
|
||||||
@@ -332,8 +334,19 @@ func executeRestoreAudioAction(
|
|||||||
}
|
}
|
||||||
spoolDir := artifacts.SessionSpoolRestoreAudioDir(cfg.Pipeline.Spool.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
spoolDir := artifacts.SessionSpoolRestoreAudioDir(cfg.Pipeline.Spool.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
spoolPath := filepath.Join(spoolDir, filepath.Base(safeLocalPath))
|
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
|
cacheEnabled := cfg.Pipeline.Cache.S3Audio == nil || *cfg.Pipeline.Cache.S3Audio
|
||||||
_, err := audio.MaterializeS3Audio(ctx, audio.S3MaterializeRequest{
|
_, err = audio.MaterializeS3Audio(ctx, audio.S3MaterializeRequest{
|
||||||
Store: store,
|
Store: store,
|
||||||
Object: storage.ObjectInfo{
|
Object: storage.ObjectInfo{
|
||||||
Key: action.RemoteKey,
|
Key: action.RemoteKey,
|
||||||
@@ -344,10 +357,19 @@ func executeRestoreAudioAction(
|
|||||||
CacheRoot: strings.TrimSpace(cfg.Pipeline.Cache.Root),
|
CacheRoot: strings.TrimSpace(cfg.Pipeline.Cache.Root),
|
||||||
CacheEnabled: cacheEnabled,
|
CacheEnabled: cacheEnabled,
|
||||||
SpoolPath: spoolPath,
|
SpoolPath: spoolPath,
|
||||||
DestPath: safeLocalPath,
|
DestPath: temporaryPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("materialize audio: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user