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 }