Bind audio cache reuse to remote identity

This commit is contained in:
2026-08-10 21:09:21 +00:00
parent 8375ad83f3
commit d9fa1d9328
9 changed files with 414 additions and 58 deletions

View File

@@ -69,7 +69,9 @@ Audio restore path:
- uses `audio.MaterializeS3Audio`; - uses `audio.MaterializeS3Audio`;
- integrates spool and S3 audio cache paths; - 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 ## Reporting Contract

View File

@@ -329,6 +329,10 @@ Cache layout (durable S3 audio cache):
- `{cache.root}/s3/{bucket}/...` - `{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 ### Workspace Permissions
Ordinary Narratio workspace content is intentionally shareable with the Ordinary Narratio workspace content is intentionally shareable with the

View File

@@ -157,6 +157,10 @@ Canonical helpers own workspace, spool, cache, session, run, input, transcript,
artifact, log, report, configuration, and publish-current paths. Callers must artifact, log, report, configuration, and publish-current paths. Callers must
not reconstruct canonical paths through scattered string concatenation. 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 Artifact resolution is deterministic and manifest-aware. Producers materialize
canonical outputs before reporting success, and consumers resolve declared canonical outputs before reporting success, and consumers resolve declared
artifact identities rather than infer files from unrelated directory contents. artifact identities rather than infer files from unrelated directory contents.

View File

@@ -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 | | 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 | | 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 | | 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 | | 20 | Unify previous-source readiness and eliminate duplicate transfers | COR-010, EFF-001 | Pending |
| 21 | Tighten configuration parsing, values, and expectations | COR-012COR-015, TST-011, TST-014 | Pending | | 21 | Tighten configuration parsing, values, and expectations | COR-012COR-015, TST-011, TST-014 | Pending |
| 22 | Make product configuration truthful and own remote temp files | COR-024, RSK-009, ARC-004 | Pending | | 22 | Make product configuration truthful and own remote temp files | COR-024, RSK-009, ARC-004 | Pending |

View File

@@ -430,33 +430,15 @@ func classifyRestoreAction(
} }
if restoreRelativePathIsAudio(localRelPath) { 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 { if force {
action.Kind = RestoreActionDownload 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 return action, nil
} }
action.Kind = RestoreActionConflict action.Kind = RestoreActionConflict
action.Conflict = true action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch 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 return action, nil
} }

View File

@@ -58,7 +58,7 @@ func TestRestorePlanIncludeAudio(t *testing.T) {
} }
} }
func TestRestorePlanExistingAudioUsesSizeWithoutRemoteChecksumDownload(t *testing.T) { func TestRestorePlanExistingAudioRequiresVerifiedReplacement(t *testing.T) {
cfg := restorePlanConfig(t) cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg) current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{} store := &storage.FakeBackend{}
@@ -80,8 +80,18 @@ func TestRestorePlanExistingAudioUsesSizeWithoutRemoteChecksumDownload(t *testin
actionByRel[action.LocalRelativePath] = action actionByRel[action.LocalRelativePath] = action
} }
audioAction := actionByRel["audio/alice.flac"] audioAction := actionByRel["audio/alice.flac"]
if audioAction.Kind != RestoreActionSkipSame { if audioAction.Kind != RestoreActionConflict {
t.Fatalf("audio action kind = %q, want %q", audioAction.Kind, RestoreActionSkipSame) 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)
}
} }
} }

View File

@@ -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
}

View File

@@ -51,19 +51,15 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
} }
result := S3MaterializeResult{} result := S3MaterializeResult{}
if req.CacheEnabled { if req.CacheEnabled && cacheIdentityEligible(req) {
cachePath, err := artifacts.S3AudioCachePath(req.CacheRoot, req.Bucket, key) cachePath, err := artifacts.S3AudioCachePath(req.CacheRoot, req.Bucket, key)
if err != nil { if err != nil {
return S3MaterializeResult{}, fmt.Errorf("resolve audio cache path: %w", err) return S3MaterializeResult{}, fmt.Errorf("resolve audio cache path: %w", err)
} }
result.CachePath = cachePath 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 return S3MaterializeResult{}, err
} else if ok { } 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.Checksum = checksum
result.CacheHit = true result.CacheHit = true
return result, nil return result, nil
@@ -90,34 +86,21 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
result.Downloaded = true result.Downloaded = true
if result.CachePath != "" { 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) 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 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 { func validateLocalAudio(path string, expectedSize int64) error {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {

View File

@@ -40,12 +40,11 @@ func TestMaterializeS3AudioCacheHitSkipsDownload(t *testing.T) {
root := t.TempDir() root := t.TempDir()
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
key := "dnd/campaigns/forsaken/sessions/2026-04-19/audio/alice.flac" 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"))) req := testMaterializeRequest(t, root, fake, key, int64(len("audio")))
cachePath, err := artifacts.S3AudioCachePath(req.CacheRoot, req.Bucket, key) if _, err := MaterializeS3Audio(context.Background(), req); err != nil {
if err != nil { t.Fatalf("populate cache: %v", err)
t.Fatalf("cache path: %v", err)
} }
writeAudioTestFile(t, cachePath, "audio")
result, err := MaterializeS3Audio(context.Background(), req) result, err := MaterializeS3Audio(context.Background(), req)
if err != nil { if err != nil {
@@ -54,8 +53,8 @@ func TestMaterializeS3AudioCacheHitSkipsDownload(t *testing.T) {
if !result.CacheHit || result.Downloaded { if !result.CacheHit || result.Downloaded {
t.Fatalf("result = %#v, want cache hit", result) t.Fatalf("result = %#v, want cache hit", result)
} }
if len(fake.Downloads) != 0 { if len(fake.Downloads) != 1 {
t.Fatalf("downloads = %d, want 0", len(fake.Downloads)) t.Fatalf("downloads = %d, want only the initial population download", len(fake.Downloads))
} }
assertFileEquals(t, req.DestPath, "audio") assertFileEquals(t, req.DestPath, "audio")
if result.SpoolPath != "" { 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) { func TestMaterializeS3AudioInvalidCacheRefreshesFromS3(t *testing.T) {
root := t.TempDir() root := t.TempDir()
fake := &storage.FakeBackend{} 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) { func writeAudioTestFile(t *testing.T, path, contents string) {
t.Helper() t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {