Bind audio cache reuse to remote identity
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
177
internal/audio/cache_identity.go
Normal file
177
internal/audio/cache_identity.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user