From d9fa1d93288edee954ccf6b2595d36a88d196e15 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 10 Aug 2026 21:09:21 +0000 Subject: [PATCH] Bind audio cache reuse to remote identity --- docs/internal/command-restore.md | 4 +- docs/operations.md | 4 + docs/policy/architecture.md | 4 + docs/roadmap/implementation.md | 2 +- internal/app/restore_plan.go | 22 +--- internal/app/restore_plan_test.go | 16 ++- internal/audio/cache_identity.go | 177 +++++++++++++++++++++++++ internal/audio/s3_audio.go | 37 ++---- internal/audio/s3_audio_test.go | 206 +++++++++++++++++++++++++++++- 9 files changed, 414 insertions(+), 58 deletions(-) create mode 100644 internal/audio/cache_identity.go diff --git a/docs/internal/command-restore.md b/docs/internal/command-restore.md index 7b92ed2..cbcc40f 100644 --- a/docs/internal/command-restore.md +++ b/docs/internal/command-restore.md @@ -69,7 +69,9 @@ Audio restore path: - uses `audio.MaterializeS3Audio`; - integrates spool and S3 audio cache paths; -- supports cache-hit reuse without object redownload. +- reuses cached audio only when its no-follow regular file, content digest, and + identity sidecar all match the selected remote object version; otherwise it + refreshes through the durable download path. ## Reporting Contract diff --git a/docs/operations.md b/docs/operations.md index f963d34..592be34 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -329,6 +329,10 @@ Cache layout (durable S3 audio cache): - `{cache.root}/s3/{bucket}/...` +Each cached audio file has an adjacent managed identity record. It binds the +file to its remote object version and verified digest; deleting or altering the +record simply causes Narratio to download and verify the object again. + ### Workspace Permissions Ordinary Narratio workspace content is intentionally shareable with the diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index 111a021..d7176cc 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -157,6 +157,10 @@ Canonical helpers own workspace, spool, cache, session, run, input, transcript, artifact, log, report, configuration, and publish-current paths. Callers must not reconstruct canonical paths through scattered string concatenation. +Reusable audio cache entries require a typed record that binds a confined, +no-follow regular file and its digest to the selected remote object identity. +Size alone and unqualified multipart ETags are not content-integrity evidence. + Artifact resolution is deterministic and manifest-aware. Producers materialize canonical outputs before reporting success, and consumers resolve declared artifact identities rather than infer files from unrelated directory contents. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 44426f5..684e33e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -34,7 +34,7 @@ All stages are pending when this plan is created. | 16 | Persist retryable post-commit cleanup state | COR-006, COR-007 | Completed | | 17 | Bind restore/status to a committed snapshot and reject conflicts | COR-008, COR-009, TST-005 | Completed | | 18 | Serialize restore transitions and make restored paths portable | RSK-006, RSK-008 | Completed | -| 19 | Bind audio cache reuse to remote object identity | RSK-007 | Pending | +| 19 | Bind audio cache reuse to remote object identity | RSK-007 | Completed | | 20 | Unify previous-source readiness and eliminate duplicate transfers | COR-010, EFF-001 | Pending | | 21 | Tighten configuration parsing, values, and expectations | COR-012–COR-015, TST-011, TST-014 | Pending | | 22 | Make product configuration truthful and own remote temp files | COR-024, RSK-009, ARC-004 | Pending | diff --git a/internal/app/restore_plan.go b/internal/app/restore_plan.go index 992c216..01efc27 100644 --- a/internal/app/restore_plan.go +++ b/internal/app/restore_plan.go @@ -430,33 +430,15 @@ func classifyRestoreAction( } if restoreRelativePathIsAudio(localRelPath) { - if object.Size > 0 { - if info.Size() == object.Size { - action.Kind = RestoreActionSkipSame - action.SameLocal = true - action.Reason = "local audio size matches remote content" - return action, nil - } - if force { - action.Kind = RestoreActionDownload - action.Reason = "local audio differs (size mismatch); overwrite with --force" - return action, nil - } - action.Kind = RestoreActionConflict - action.Conflict = true - action.ConflictKind = RestoreConflictContentMismatch - action.Reason = "local audio differs (size mismatch)" - return action, nil - } if force { action.Kind = RestoreActionDownload - action.Reason = "local audio exists; remote size unavailable; overwrite with --force" + action.Reason = "local audio identity requires verified replacement; overwrite with --force" return action, nil } action.Kind = RestoreActionConflict action.Conflict = true action.ConflictKind = RestoreConflictContentMismatch - action.Reason = "local audio exists; remote size unavailable" + action.Reason = "local audio identity is not verified; use --force to replace it" return action, nil } diff --git a/internal/app/restore_plan_test.go b/internal/app/restore_plan_test.go index 6ed0c0b..237b466 100644 --- a/internal/app/restore_plan_test.go +++ b/internal/app/restore_plan_test.go @@ -58,7 +58,7 @@ func TestRestorePlanIncludeAudio(t *testing.T) { } } -func TestRestorePlanExistingAudioUsesSizeWithoutRemoteChecksumDownload(t *testing.T) { +func TestRestorePlanExistingAudioRequiresVerifiedReplacement(t *testing.T) { cfg := restorePlanConfig(t) current := restorePlanCurrentState(t, cfg) store := &storage.FakeBackend{} @@ -80,8 +80,18 @@ func TestRestorePlanExistingAudioUsesSizeWithoutRemoteChecksumDownload(t *testin actionByRel[action.LocalRelativePath] = action } audioAction := actionByRel["audio/alice.flac"] - if audioAction.Kind != RestoreActionSkipSame { - t.Fatalf("audio action kind = %q, want %q", audioAction.Kind, RestoreActionSkipSame) + if audioAction.Kind != RestoreActionConflict { + t.Fatalf("audio action kind = %q, want %q", audioAction.Kind, RestoreActionConflict) + } + + forcedPlan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true, Force: true}) + if err != nil { + t.Fatalf("forced buildRestorePlan() error = %v", err) + } + for _, action := range forcedPlan.Actions { + if action.LocalRelativePath == "audio/alice.flac" && action.Kind != RestoreActionDownload { + t.Fatalf("forced audio action kind = %q, want %q", action.Kind, RestoreActionDownload) + } } } diff --git a/internal/audio/cache_identity.go b/internal/audio/cache_identity.go new file mode 100644 index 0000000..0655ee3 --- /dev/null +++ b/internal/audio/cache_identity.go @@ -0,0 +1,177 @@ +package audio + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/fileops" + "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" +) + +const audioCacheIdentityVersion = 1 + +type audioCacheIdentity struct { + Version int `json:"version"` + Bucket string `json:"bucket"` + ObjectKey string `json:"object_key"` + Generation string `json:"generation"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +func cacheIdentityPath(cachePath string) string { + return cachePath + ".identity.json" +} + +func cacheIdentityForRequest(req S3MaterializeRequest, checksum string) audioCacheIdentity { + return audioCacheIdentity{ + Version: audioCacheIdentityVersion, + Bucket: strings.TrimSpace(req.Bucket), + ObjectKey: strings.TrimSpace(req.Object.Key), + Generation: strings.TrimSpace(req.Object.ETag), + Size: req.Object.Size, + SHA256: strings.ToLower(strings.TrimSpace(checksum)), + } +} + +func (identity audioCacheIdentity) matchesRequest(req S3MaterializeRequest) bool { + return identity.Version == audioCacheIdentityVersion && + identity.Bucket == strings.TrimSpace(req.Bucket) && + identity.ObjectKey == strings.TrimSpace(req.Object.Key) && + identity.Generation != "" && identity.Generation == strings.TrimSpace(req.Object.ETag) && + identity.Size > 0 && identity.Size == req.Object.Size && + validSHA256(identity.SHA256) +} + +func cacheIdentityEligible(req S3MaterializeRequest) bool { + return strings.TrimSpace(req.Object.Key) != "" && strings.TrimSpace(req.Bucket) != "" && + strings.TrimSpace(req.Object.ETag) != "" && req.Object.Size > 0 +} + +func loadAudioCacheIdentity(path string) (audioCacheIdentity, bool, error) { + data, err := fileops.ReadRegularFile(path, 64*1024) + if os.IsNotExist(err) { + return audioCacheIdentity{}, false, nil + } + if err != nil { + return audioCacheIdentity{}, false, nil + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var identity audioCacheIdentity + if err := decoder.Decode(&identity); err != nil { + return audioCacheIdentity{}, false, nil + } + if err := decoder.Decode(&struct{}{}); err != io.EOF || !validSHA256(identity.SHA256) { + return audioCacheIdentity{}, false, nil + } + return identity, true, nil +} + +func writeAudioCacheIdentity(path string, identity audioCacheIdentity) error { + data, err := json.Marshal(identity) + if err != nil { + return fmt.Errorf("encode audio cache identity: %w", err) + } + if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil { + return fmt.Errorf("write audio cache identity: %w", err) + } + return nil +} + +func materializeVerifiedCachedAudio(ctx context.Context, req S3MaterializeRequest, cachePath string) (string, bool, error) { + if err := ctx.Err(); err != nil { + return "", false, err + } + identity, ok, err := loadAudioCacheIdentity(cacheIdentityPath(cachePath)) + if err != nil { + return "", false, err + } + if !ok || !identity.matchesRequest(req) { + return "", false, nil + } + + relativePath, err := cacheRelativePath(req.CacheRoot, cachePath) + if err != nil { + return "", false, err + } + source, err := fileops.OpenConfinedRegularFile(req.CacheRoot, relativePath) + if err != nil { + return "", false, nil + } + defer func() { _ = source.Close() }() + info, err := source.Stat() + if err != nil { + return "", false, fmt.Errorf("inspect cached audio: %w", err) + } + if !info.Mode().IsRegular() || info.Size() != identity.Size { + return "", false, nil + } + if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(req.DestPath)); err != nil { + return "", false, fmt.Errorf("create audio destination directory: %w", err) + } + temporary, err := fileops.DownloadToSiblingTemp(req.DestPath, func(destination io.Writer) error { + _, err := io.Copy(destination, source) + return err + }) + if err != nil { + return "", false, fmt.Errorf("copy verified cached audio: %w", err) + } + defer func() { _ = temporary.Cleanup() }() + checksum, size, err := temporaryChecksum(temporary) + if err != nil { + return "", false, err + } + if size != identity.Size || checksum != identity.SHA256 { + return "", false, nil + } + if err := temporary.Install(filepath.Base(req.DestPath), fileops.WorkspaceFileMode); err != nil { + return "", false, fmt.Errorf("install verified cached audio: %w", err) + } + return checksum, true, nil +} + +func cacheRelativePath(cacheRoot, cachePath string) (string, error) { + relativePath, err := filepath.Rel(filepath.Clean(cacheRoot), filepath.Clean(cachePath)) + if err != nil { + return "", fmt.Errorf("resolve audio cache path: %w", err) + } + normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(relativePath)) + if err != nil { + return "", fmt.Errorf("audio cache path escapes cache root: %w", err) + } + return normalized, nil +} + +func temporaryChecksum(temporary *fileops.DownloadedTempFile) (string, int64, error) { + file, err := temporary.Open() + if err != nil { + return "", 0, fmt.Errorf("open cached audio copy: %w", err) + } + digest := sha256.New() + size, copyErr := io.Copy(digest, file) + closeErr := file.Close() + if copyErr != nil { + return "", 0, fmt.Errorf("checksum cached audio copy: %w", copyErr) + } + if closeErr != nil { + return "", 0, fmt.Errorf("close cached audio copy: %w", closeErr) + } + return hex.EncodeToString(digest.Sum(nil)), size, nil +} + +func validSHA256(value string) bool { + if len(value) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} diff --git a/internal/audio/s3_audio.go b/internal/audio/s3_audio.go index 5a7b16f..831aa36 100644 --- a/internal/audio/s3_audio.go +++ b/internal/audio/s3_audio.go @@ -51,19 +51,15 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi } result := S3MaterializeResult{} - if req.CacheEnabled { + if req.CacheEnabled && cacheIdentityEligible(req) { cachePath, err := artifacts.S3AudioCachePath(req.CacheRoot, req.Bucket, key) if err != nil { return S3MaterializeResult{}, fmt.Errorf("resolve audio cache path: %w", err) } result.CachePath = cachePath - if ok, err := validCachedAudio(cachePath, req.Object.Size); err != nil { + if checksum, ok, err := materializeVerifiedCachedAudio(ctx, req, cachePath); err != nil { return S3MaterializeResult{}, err } else if ok { - checksum, err := fileops.CopyFileAtomicWithChecksum(cachePath, req.DestPath, fileops.WorkspaceFileMode) - if err != nil { - return S3MaterializeResult{}, fmt.Errorf("materialize cached audio %q: %w", cachePath, err) - } result.Checksum = checksum result.CacheHit = true return result, nil @@ -90,34 +86,21 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi result.Downloaded = true if result.CachePath != "" { - if _, err := fileops.CopyFileAtomicWithChecksum(spoolPath, result.CachePath, fileops.WorkspaceFileMode); err != nil { + cacheChecksum, err := fileops.CopyFileAtomicWithChecksum(spoolPath, result.CachePath, fileops.WorkspaceFileMode) + if err != nil { return S3MaterializeResult{}, fmt.Errorf("populate audio cache %q: %w", result.CachePath, err) } + if cacheChecksum != result.Checksum { + return S3MaterializeResult{}, fmt.Errorf("audio cache checksum differs from downloaded audio") + } + if err := writeAudioCacheIdentity(cacheIdentityPath(result.CachePath), cacheIdentityForRequest(req, cacheChecksum)); err != nil { + return S3MaterializeResult{}, err + } } return result, nil } -func validCachedAudio(path string, expectedSize int64) (bool, error) { - info, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, fmt.Errorf("stat cached audio %q: %w", path, err) - } - if info.IsDir() { - return false, fmt.Errorf("cached audio path is a directory: %q", path) - } - if info.Size() <= 0 { - return false, nil - } - if expectedSize > 0 && info.Size() != expectedSize { - return false, nil - } - return true, nil -} - func validateLocalAudio(path string, expectedSize int64) error { info, err := os.Stat(path) if err != nil { diff --git a/internal/audio/s3_audio_test.go b/internal/audio/s3_audio_test.go index 91d7f05..7a3a281 100644 --- a/internal/audio/s3_audio_test.go +++ b/internal/audio/s3_audio_test.go @@ -40,12 +40,11 @@ func TestMaterializeS3AudioCacheHitSkipsDownload(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio")}) req := testMaterializeRequest(t, root, fake, key, int64(len("audio"))) - cachePath, err := artifacts.S3AudioCachePath(req.CacheRoot, req.Bucket, key) - if err != nil { - t.Fatalf("cache path: %v", err) + if _, err := MaterializeS3Audio(context.Background(), req); err != nil { + t.Fatalf("populate cache: %v", err) } - writeAudioTestFile(t, cachePath, "audio") result, err := MaterializeS3Audio(context.Background(), req) if err != nil { @@ -54,8 +53,8 @@ func TestMaterializeS3AudioCacheHitSkipsDownload(t *testing.T) { if !result.CacheHit || result.Downloaded { t.Fatalf("result = %#v, want cache hit", result) } - if len(fake.Downloads) != 0 { - t.Fatalf("downloads = %d, want 0", len(fake.Downloads)) + if len(fake.Downloads) != 1 { + t.Fatalf("downloads = %d, want only the initial population download", len(fake.Downloads)) } assertFileEquals(t, req.DestPath, "audio") if result.SpoolPath != "" { @@ -63,6 +62,192 @@ func TestMaterializeS3AudioCacheHitSkipsDownload(t *testing.T) { } } +func TestMaterializeS3AudioRefreshesSameSizeChangedGeneration(t *testing.T) { + root := t.TempDir() + fake := &storage.FakeBackend{} + key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio-one"), ETag: "generation-one"}) + req := testMaterializeRequest(t, root, fake, key, int64(len("audio-one"))) + req.Object.ETag = "generation-one" + if _, err := MaterializeS3Audio(context.Background(), req); err != nil { + t.Fatalf("initial MaterializeS3Audio() error = %v", err) + } + + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio-two"), ETag: "generation-two"}) + req.Object.ETag = "generation-two" + result, err := MaterializeS3Audio(context.Background(), req) + if err != nil { + t.Fatalf("refreshed MaterializeS3Audio() error = %v", err) + } + if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 2 { + t.Fatalf("result=%#v downloads=%d, want refreshed download", result, len(fake.Downloads)) + } + assertFileEquals(t, req.DestPath, "audio-two") +} + +func TestMaterializeS3AudioRefreshesCorruptedSameSizeCache(t *testing.T) { + root := t.TempDir() + fake := &storage.FakeBackend{} + key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) + req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) + req.Object.ETag = "generation" + if _, err := MaterializeS3Audio(context.Background(), req); err != nil { + t.Fatalf("initial MaterializeS3Audio() error = %v", err) + } + writeAudioTestFile(t, cachePathForTest(t, req), "stale-audio") + + result, err := MaterializeS3Audio(context.Background(), req) + if err != nil { + t.Fatalf("refreshed MaterializeS3Audio() error = %v", err) + } + if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 2 { + t.Fatalf("result=%#v downloads=%d, want refreshed download", result, len(fake.Downloads)) + } + assertFileEquals(t, req.DestPath, "fresh-audio") +} + +func TestMaterializeS3AudioMissingIdentityDoesNotReuseCache(t *testing.T) { + root := t.TempDir() + fake := &storage.FakeBackend{} + key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) + req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) + req.Object.ETag = "generation" + writeAudioTestFile(t, cachePathForTest(t, req), "stale-audio") + + result, err := MaterializeS3Audio(context.Background(), req) + if err != nil { + t.Fatalf("MaterializeS3Audio() error = %v", err) + } + if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 1 { + t.Fatalf("result=%#v downloads=%d, want cache refresh", result, len(fake.Downloads)) + } +} + +func TestMaterializeS3AudioTreatsMultipartETagAsGeneration(t *testing.T) { + root := t.TempDir() + fake := &storage.FakeBackend{} + key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "not-a-checksum-2"}) + req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) + req.Object.ETag = "not-a-checksum-2" + if _, err := MaterializeS3Audio(context.Background(), req); err != nil { + t.Fatalf("populate cache: %v", err) + } + result, err := MaterializeS3Audio(context.Background(), req) + if err != nil { + t.Fatalf("reuse cache: %v", err) + } + if !result.CacheHit || result.Downloaded || len(fake.Downloads) != 1 { + t.Fatalf("result=%#v downloads=%d, want verified cache hit", result, len(fake.Downloads)) + } +} + +func TestMaterializeS3AudioUnknownRemoteSizeDoesNotReuseCache(t *testing.T) { + root := t.TempDir() + fake := &storage.FakeBackend{} + key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) + req := testMaterializeRequest(t, root, fake, key, 0) + req.Object.ETag = "generation" + if _, err := MaterializeS3Audio(context.Background(), req); err != nil { + t.Fatalf("first MaterializeS3Audio() error = %v", err) + } + result, err := MaterializeS3Audio(context.Background(), req) + if err != nil { + t.Fatalf("second MaterializeS3Audio() error = %v", err) + } + if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 2 { + t.Fatalf("result=%#v downloads=%d, want refresh without identity evidence", result, len(fake.Downloads)) + } +} + +func TestMaterializeS3AudioSymlinkedCacheRefreshesWithoutFollowingLink(t *testing.T) { + root := t.TempDir() + fake := &storage.FakeBackend{} + key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) + req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) + req.Object.ETag = "generation" + if _, err := MaterializeS3Audio(context.Background(), req); err != nil { + t.Fatalf("populate cache: %v", err) + } + + cachePath := cachePathForTest(t, req) + outsidePath := filepath.Join(root, "outside-audio") + writeAudioTestFile(t, outsidePath, "untrusted-audio") + if err := os.Remove(cachePath); err != nil { + t.Fatalf("remove cache file: %v", err) + } + if err := os.Symlink(outsidePath, cachePath); err != nil { + t.Fatalf("create cache symlink: %v", err) + } + + result, err := MaterializeS3Audio(context.Background(), req) + if err != nil { + t.Fatalf("refresh symlinked cache: %v", err) + } + if !result.Downloaded || result.CacheHit || len(fake.Downloads) != 2 { + t.Fatalf("result=%#v downloads=%d, want safe refresh", result, len(fake.Downloads)) + } + assertFileEquals(t, outsidePath, "untrusted-audio") + assertFileEquals(t, req.DestPath, "fresh-audio") +} + +func TestMaterializeS3AudioNonRegularCacheFailsClearly(t *testing.T) { + root := t.TempDir() + fake := &storage.FakeBackend{} + key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("fresh-audio"), ETag: "generation"}) + req := testMaterializeRequest(t, root, fake, key, int64(len("fresh-audio"))) + req.Object.ETag = "generation" + if _, err := MaterializeS3Audio(context.Background(), req); err != nil { + t.Fatalf("populate cache: %v", err) + } + + cachePath := cachePathForTest(t, req) + if err := os.Remove(cachePath); err != nil { + t.Fatalf("remove cache file: %v", err) + } + if err := os.Mkdir(cachePath, 0o755); err != nil { + t.Fatalf("create cache directory: %v", err) + } + _, err := MaterializeS3Audio(context.Background(), req) + if err == nil || !strings.Contains(err.Error(), "populate audio cache") { + t.Fatalf("error = %v, want clear cache refresh error", err) + } +} + +func TestMaterializeS3AudioInterruptedRefreshCanRetry(t *testing.T) { + root := t.TempDir() + fake := &storage.FakeBackend{} + key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio-one"), ETag: "generation-one"}) + req := testMaterializeRequest(t, root, fake, key, int64(len("audio-one"))) + req.Object.ETag = "generation-one" + if _, err := MaterializeS3Audio(context.Background(), req); err != nil { + t.Fatalf("populate cache: %v", err) + } + + fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("audio-two"), ETag: "generation-two"}) + req.Object.ETag = "generation-two" + fake.DownloadErr = os.ErrPermission + if _, err := MaterializeS3Audio(context.Background(), req); err == nil { + t.Fatal("MaterializeS3Audio() error = nil, want interrupted refresh failure") + } + fake.DownloadErr = nil + + result, err := MaterializeS3Audio(context.Background(), req) + if err != nil { + t.Fatalf("retry refresh: %v", err) + } + if !result.Downloaded || result.CacheHit { + t.Fatalf("result = %#v, want refreshed download after retry", result) + } + assertFileEquals(t, req.DestPath, "audio-two") +} + func TestMaterializeS3AudioInvalidCacheRefreshesFromS3(t *testing.T) { root := t.TempDir() fake := &storage.FakeBackend{} @@ -116,6 +301,15 @@ func testMaterializeRequest(t *testing.T, root string, fake *storage.FakeBackend } } +func cachePathForTest(t *testing.T, req S3MaterializeRequest) string { + t.Helper() + cachePath, err := artifacts.S3AudioCachePath(req.CacheRoot, req.Bucket, req.Object.Key) + if err != nil { + t.Fatalf("cache path: %v", err) + } + return cachePath +} + func writeAudioTestFile(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {