Files
narratio/internal/audio/cache_identity.go

178 lines
5.4 KiB
Go

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
}