feat(admin): add routed task evidence details
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func syncDirectory(path string) error {
|
||||
directory, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open directory for durability sync: %w", err)
|
||||
}
|
||||
defer directory.Close()
|
||||
if err := directory.Sync(); err != nil {
|
||||
return fmt.Errorf("sync directory metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// syncDirectory uses an explicit directory handle because os.Open(...).Sync is not a portable
|
||||
// Windows directory durability boundary. Any unsupported filesystem or access failure is fatal:
|
||||
// callers must not make the corresponding evidence row visible in SQLite.
|
||||
func syncDirectory(path string) error {
|
||||
pathPointer, err := syscall.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode directory path for durability sync: %w", err)
|
||||
}
|
||||
handle, err := syscall.CreateFile(
|
||||
pathPointer,
|
||||
syscall.GENERIC_WRITE,
|
||||
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
|
||||
nil,
|
||||
syscall.OPEN_EXISTING,
|
||||
syscall.FILE_FLAG_BACKUP_SEMANTICS,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open directory for durability sync: %w", err)
|
||||
}
|
||||
defer syscall.CloseHandle(handle)
|
||||
if err := syscall.FlushFileBuffers(handle); err != nil {
|
||||
return fmt.Errorf("flush directory metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
// Package evidence stores INTERNAL_RAW PNG assets outside the public web tree.
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
core "cmbuyer/admin/internal/evidence"
|
||||
)
|
||||
|
||||
var pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
|
||||
type Store struct {
|
||||
database *sql.DB
|
||||
root string
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
syncDirectory func(string) error
|
||||
syncFile func(*os.File) error
|
||||
renameFile func(string, string) error
|
||||
commitTx func(*sql.Tx) error
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStore(database *sql.DB, root string) (*Store, error) {
|
||||
return newStore(database, root, syncDirectory)
|
||||
}
|
||||
|
||||
func newStore(database *sql.DB, root string, directorySync func(string) error) (*Store, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("evidence database is required")
|
||||
}
|
||||
if directorySync == nil {
|
||||
return nil, errors.New("evidence directory sync is required")
|
||||
}
|
||||
if root == "" || !filepath.IsAbs(root) {
|
||||
return nil, errors.New("evidence root must be an absolute path")
|
||||
}
|
||||
absolute, err := filepath.Abs(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve evidence root: %w", err)
|
||||
}
|
||||
if filepath.Dir(absolute) == absolute {
|
||||
return nil, errors.New("evidence root cannot be a filesystem root")
|
||||
}
|
||||
if err := ensureDurableDirectory(absolute, 0o700, directorySync); err != nil {
|
||||
return nil, fmt.Errorf("create evidence root: %w", err)
|
||||
}
|
||||
// A prior startup may have created the root and then failed its parent sync.
|
||||
// Existence is therefore never accepted as proof that the directory entry is durable.
|
||||
if err := directorySync(filepath.Dir(absolute)); err != nil {
|
||||
return nil, fmt.Errorf("persist evidence root directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(absolute, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("protect evidence root: %w", err)
|
||||
}
|
||||
staging := filepath.Join(absolute, ".staging")
|
||||
if err := ensureDurableDirectory(staging, 0o700, directorySync); err != nil {
|
||||
return nil, fmt.Errorf("create evidence staging directory: %w", err)
|
||||
}
|
||||
if err := directorySync(absolute); err != nil {
|
||||
return nil, fmt.Errorf("persist evidence staging directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(staging, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("protect evidence staging directory: %w", err)
|
||||
}
|
||||
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("evidence migration is not available: %w", err)
|
||||
}
|
||||
return &Store{
|
||||
database: database,
|
||||
root: absolute,
|
||||
now: time.Now,
|
||||
random: rand.Reader,
|
||||
syncDirectory: directorySync,
|
||||
syncFile: func(file *os.File) error { return file.Sync() },
|
||||
renameFile: os.Rename,
|
||||
commitTx: func(transaction *sql.Tx) error { return transaction.Commit() },
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (store *Store) Stage(reader io.Reader, contentType string) (staged core.StagedFile, resultErr error) {
|
||||
if reader == nil || contentType != core.PNGContentType {
|
||||
return core.StagedFile{}, core.ErrInvalid
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Join(store.root, ".staging"), "upload-*.png")
|
||||
if err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
staged.Path = temporary.Name()
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(staged.Path)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(temporary, hasher), io.LimitReader(reader, core.MaxFileBytes+1))
|
||||
if err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
if written > core.MaxFileBytes {
|
||||
return core.StagedFile{}, core.ErrTooLarge
|
||||
}
|
||||
if written == 0 {
|
||||
return core.StagedFile{}, core.ErrInvalid
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
|
||||
imageFile, err := os.Open(staged.Path)
|
||||
if err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
defer imageFile.Close()
|
||||
width, height, err := validatePNG(imageFile)
|
||||
if err != nil {
|
||||
return core.StagedFile{}, err
|
||||
}
|
||||
|
||||
staged.SHA256 = hex.EncodeToString(hasher.Sum(nil))
|
||||
staged.ByteSize = written
|
||||
staged.ContentType = core.PNGContentType
|
||||
staged.Width = width
|
||||
staged.Height = height
|
||||
return staged, nil
|
||||
}
|
||||
|
||||
func (store *Store) Discard(staged core.StagedFile) {
|
||||
if store.isStagedPath(staged.Path) {
|
||||
_ = os.Remove(staged.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) Commit(ctx context.Context, principal core.DevicePrincipal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) {
|
||||
if !store.isStagedPath(staged.Path) || !validPrincipal(principal) || !validMetadata(metadata) || metadata.SHA256 != staged.SHA256 || staged.ContentType != core.PNGContentType || staged.ByteSize < 1 || staged.ByteSize > core.MaxFileBytes || staged.Width < 1 || staged.Height < 1 || staged.Width > core.MaxImageSide || staged.Height > core.MaxImageSide || int64(staged.Width)*int64(staged.Height) > core.MaxImagePixels {
|
||||
store.Discard(staged)
|
||||
return core.Asset{}, false, core.ErrInvalid
|
||||
}
|
||||
defer store.Discard(staged)
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
transaction, err := store.database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
|
||||
existing, found, err := findByUploadKey(ctx, transaction, principal.ID, metadata.UploadKey)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if found {
|
||||
if !sameUpload(existing, principal, metadata, staged) {
|
||||
return core.Asset{}, false, core.ErrConflict
|
||||
}
|
||||
if err := store.verifyStoredFile(existing); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if err := store.commitTx(transaction); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
var attemptCount int
|
||||
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if attemptCount != 1 {
|
||||
return core.Asset{}, false, core.ErrInvalid
|
||||
}
|
||||
|
||||
storageKey := storageKey(metadata.SHA256)
|
||||
finalPath, err := store.pathForKey(storageKey)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
finalDirectory := filepath.Dir(finalPath)
|
||||
if err := ensureDurableDirectory(finalDirectory, 0o700, store.syncDirectory); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
// Always repeat the shard-parent boundary. If an earlier attempt created this
|
||||
// directory and its parent sync failed, a retry must not trust mere existence.
|
||||
if err := store.syncDirectory(store.root); err != nil {
|
||||
return core.Asset{}, false, fmt.Errorf("persist evidence shard directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(finalDirectory, 0o700); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if info, statErr := os.Stat(finalPath); statErr == nil {
|
||||
if !info.Mode().IsRegular() || info.Size() != staged.ByteSize || fileSHA256(finalPath) != staged.SHA256 {
|
||||
return core.Asset{}, false, errors.New("stored evidence content does not match its key")
|
||||
}
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
return core.Asset{}, false, statErr
|
||||
} else {
|
||||
publishPath, err := store.preparePublishFile(staged, finalDirectory)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
defer os.Remove(publishPath)
|
||||
if err := store.renameFile(publishPath, finalPath); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
}
|
||||
// The publication file was fsynced in this shard before its same-directory rename.
|
||||
// Persist the final directory entry before SQLite can expose a referencing row.
|
||||
// A directory sync failure is deliberately fatal; the unreachable file may remain
|
||||
// as an orphan, but no evidence_assets row may be committed for it.
|
||||
if err := store.syncDirectory(finalDirectory); err != nil {
|
||||
return core.Asset{}, false, fmt.Errorf("persist evidence directory entry: %w", err)
|
||||
}
|
||||
|
||||
id, err := newUUID(store.random)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
now := store.now().UTC()
|
||||
asset := core.Asset{
|
||||
ID: id, TaskID: metadata.TaskID, AttemptID: metadata.AttemptID,
|
||||
Kind: metadata.Kind, PrivacyTier: metadata.PrivacyTier, SHA256: staged.SHA256,
|
||||
ByteSize: staged.ByteSize, ContentType: staged.ContentType, Width: staged.Width, Height: staged.Height,
|
||||
CapturedAt: metadata.CapturedAt.UTC(), UploadedByDeviceID: principal.ID,
|
||||
StorageKey: storageKey, CreatedAt: now,
|
||||
}
|
||||
_, err = transaction.ExecContext(ctx, `INSERT INTO evidence_assets
|
||||
(id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
asset.ID, metadata.UploadKey, asset.TaskID, asset.AttemptID, asset.Kind, asset.PrivacyTier,
|
||||
asset.SHA256, asset.ByteSize, asset.ContentType, asset.Width, asset.Height, asset.StorageKey,
|
||||
asset.UploadedByDeviceID, asset.CapturedAt.Format(time.RFC3339Nano), asset.CreatedAt.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
if err := store.commitTx(transaction); err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
return asset, false, nil
|
||||
}
|
||||
|
||||
func (store *Store) Open(ctx context.Context, id string) (core.Asset, io.ReadSeekCloser, error) {
|
||||
if !validUUID(id) {
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
asset, found, err := findByID(ctx, store.database, id)
|
||||
if err != nil {
|
||||
return core.Asset{}, nil, err
|
||||
}
|
||||
if !found || asset.StorageKey != storageKey(asset.SHA256) {
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
path, err := store.pathForKey(asset.StorageKey)
|
||||
if err != nil {
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return core.Asset{}, nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize {
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return core.Asset{}, nil, err
|
||||
}
|
||||
return core.Asset{}, nil, core.ErrNotFound
|
||||
}
|
||||
return asset, file, nil
|
||||
}
|
||||
|
||||
func (store *Store) verifyStoredFile(asset core.Asset) error {
|
||||
path, err := store.pathForKey(asset.StorageKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize || fileSHA256(path) != asset.SHA256 {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("stored evidence file is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) isStagedPath(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
relative, err := filepath.Rel(filepath.Join(store.root, ".staging"), filepath.Clean(path))
|
||||
return err == nil && relative != "." && relative != "" && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
||||
}
|
||||
|
||||
func (store *Store) pathForKey(key string) (string, error) {
|
||||
path := filepath.Join(store.root, filepath.FromSlash(key))
|
||||
relative, err := filepath.Rel(store.root, path)
|
||||
if err != nil || relative == "." || relative == "" || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
return "", errors.New("invalid evidence storage key")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (store *Store) preparePublishFile(staged core.StagedFile, directory string) (path string, resultErr error) {
|
||||
source, err := os.Open(staged.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
temporary, err := os.CreateTemp(directory, ".publish-*.png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
path = temporaryPath
|
||||
defer func() {
|
||||
if resultErr != nil {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(temporaryPath)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hasher := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(temporary, hasher), source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if written != staged.ByteSize || hex.EncodeToString(hasher.Sum(nil)) != staged.SHA256 {
|
||||
return "", errors.New("staged evidence changed before publication")
|
||||
}
|
||||
width, height, err := validatePNG(temporary)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if width != staged.Width || height != staged.Height {
|
||||
return "", errors.New("staged evidence dimensions changed before publication")
|
||||
}
|
||||
if err := store.syncFile(temporary); err != nil {
|
||||
return "", fmt.Errorf("sync evidence publication file: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func validatePNG(reader io.ReadSeeker) (int, int, error) {
|
||||
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
signature := make([]byte, len(pngSignature))
|
||||
if _, err := io.ReadFull(reader, signature); err != nil || string(signature) != string(pngSignature) {
|
||||
return 0, 0, core.ErrInvalid
|
||||
}
|
||||
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
configuration, err := png.DecodeConfig(reader)
|
||||
if err != nil || configuration.Width < 1 || configuration.Height < 1 || configuration.Width > core.MaxImageSide || configuration.Height > core.MaxImageSide || int64(configuration.Width)*int64(configuration.Height) > core.MaxImagePixels {
|
||||
return 0, 0, core.ErrInvalid
|
||||
}
|
||||
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if _, err := png.Decode(reader); err != nil {
|
||||
return 0, 0, core.ErrInvalid
|
||||
}
|
||||
var trailing [1]byte
|
||||
if count, err := reader.Read(trailing[:]); count != 0 || !errors.Is(err, io.EOF) {
|
||||
return 0, 0, core.ErrInvalid
|
||||
}
|
||||
return configuration.Width, configuration.Height, nil
|
||||
}
|
||||
|
||||
func ensureDurableDirectory(path string, mode os.FileMode, syncParent func(string) error) error {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path exists but is not a directory: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
parent := filepath.Dir(path)
|
||||
if parent == path {
|
||||
return fmt.Errorf("cannot create filesystem root as a managed directory: %s", path)
|
||||
}
|
||||
if err := ensureDurableDirectory(parent, mode, syncParent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Mkdir(path, mode); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return err
|
||||
}
|
||||
info, err = os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path exists but is not a directory: %s", path)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
// Syncing the parent makes creation of this directory durable. This also covers
|
||||
// a concurrent creator: returning success without the parent sync could otherwise
|
||||
// allow the following database transaction to outrun the directory entry.
|
||||
if err := syncParent(parent); err != nil {
|
||||
return fmt.Errorf("persist directory creation for %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func storageKey(hash string) string { return hash[:2] + "/" + hash + ".png" }
|
||||
|
||||
func validMetadata(metadata core.UploadMetadata) bool {
|
||||
return validUUID(metadata.UploadKey) && validUUID(metadata.TaskID) && validUUID(metadata.AttemptID) && metadata.Kind == core.KindSKUPanelGate1 && metadata.PrivacyTier == core.PrivacyInternalRaw && validSHA256(metadata.SHA256) && !metadata.CapturedAt.IsZero() && metadata.CapturedAt.Location() == time.UTC
|
||||
}
|
||||
|
||||
func validPrincipal(principal core.DevicePrincipal) bool {
|
||||
if principal.ID == "" || strings.TrimSpace(principal.ID) != principal.ID || len(principal.ID) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, character := range principal.ID {
|
||||
if unicode.IsControl(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func newUUID(reader io.Reader) (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := io.ReadFull(reader, bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(bytes)
|
||||
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) string {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func findByUploadKey(ctx context.Context, query interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}, deviceID, uploadKey string) (core.Asset, bool, error) {
|
||||
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE uploaded_by_device_id = ? AND upload_key = ?`, deviceID, uploadKey))
|
||||
}
|
||||
|
||||
func findByID(ctx context.Context, query interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}, id string) (core.Asset, bool, error) {
|
||||
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func scanAsset(row rowScanner) (core.Asset, bool, error) {
|
||||
var asset core.Asset
|
||||
var captured, created string
|
||||
err := row.Scan(&asset.ID, &asset.TaskID, &asset.AttemptID, &asset.Kind, &asset.PrivacyTier, &asset.SHA256, &asset.ByteSize, &asset.ContentType, &asset.Width, &asset.Height, &asset.StorageKey, &asset.UploadedByDeviceID, &captured, &created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.Asset{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
asset.CapturedAt, err = time.Parse(time.RFC3339Nano, captured)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
asset.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return core.Asset{}, false, err
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func sameUpload(asset core.Asset, principal core.DevicePrincipal, metadata core.UploadMetadata, staged core.StagedFile) bool {
|
||||
return asset.TaskID == metadata.TaskID && asset.AttemptID == metadata.AttemptID && asset.Kind == metadata.Kind && asset.PrivacyTier == metadata.PrivacyTier && asset.SHA256 == metadata.SHA256 && asset.ByteSize == staged.ByteSize && asset.ContentType == staged.ContentType && asset.Width == staged.Width && asset.Height == staged.Height && asset.UploadedByDeviceID == principal.ID && asset.CapturedAt.Equal(metadata.CapturedAt)
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "cmbuyer/admin/internal/evidence"
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
testTaskID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
testAuthID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
testAttemptID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
testUploadKey = "43c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestStageCommitReplayAndOpen(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 8, 6)
|
||||
hash := sha256Hex(pngBytes)
|
||||
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash, CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
|
||||
principal := core.DevicePrincipal{ID: "device-one"}
|
||||
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
asset, replayed, err := store.Commit(context.Background(), principal, metadata, staged)
|
||||
if err != nil || replayed {
|
||||
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
|
||||
}
|
||||
if asset.SHA256 != hash || asset.ByteSize != int64(len(pngBytes)) || asset.Width != 8 || asset.Height != 6 || asset.StorageKey != hash[:2]+"/"+hash+".png" {
|
||||
t.Fatalf("asset = %#v", asset)
|
||||
}
|
||||
opened, reader, err := store.Open(context.Background(), asset.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(reader)
|
||||
_ = reader.Close()
|
||||
if err != nil || !bytes.Equal(got, pngBytes) || opened.ID != asset.ID {
|
||||
t.Fatalf("opened asset changed: bytes=%t asset=%#v err=%v", bytes.Equal(got, pngBytes), opened, err)
|
||||
}
|
||||
|
||||
replayStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("stage replay: %v", err)
|
||||
}
|
||||
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, replayStage)
|
||||
if err != nil || !replayed || replayedAsset.ID != asset.ID {
|
||||
t.Fatalf("replay = %#v, %t, %v", replayedAsset, replayed, err)
|
||||
}
|
||||
|
||||
conflictStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("stage conflict: %v", err)
|
||||
}
|
||||
conflicting := metadata
|
||||
conflicting.CapturedAt = conflicting.CapturedAt.Add(time.Second)
|
||||
if _, _, err := store.Commit(context.Background(), principal, conflicting, conflictStage); !errors.Is(err, core.ErrConflict) {
|
||||
t.Fatalf("conflicting replay error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 3, 2)
|
||||
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
|
||||
staged := make([]core.StagedFile, 2)
|
||||
for index := range staged {
|
||||
var err error
|
||||
staged[index], err = store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
var wait sync.WaitGroup
|
||||
wait.Add(2)
|
||||
assets := make([]core.Asset, 2)
|
||||
replays := make([]bool, 2)
|
||||
errorsSeen := make([]error, 2)
|
||||
for index := range staged {
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
assets[index], replays[index], errorsSeen[index] = store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged[index])
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
if errorsSeen[0] != nil || errorsSeen[1] != nil || assets[0].ID != assets[1].ID || replays[0] == replays[1] {
|
||||
t.Fatalf("concurrent commits assets=%#v replays=%#v errors=%#v", assets, replays, errorsSeen)
|
||||
}
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("asset count = %d, err %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformDirectorySync(t *testing.T) {
|
||||
if err := syncDirectory(t.TempDir()); err != nil {
|
||||
t.Fatalf("syncDirectory must either establish the durability boundary or fail closed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStoreRetriesRootParentSyncWhenRootAlreadyExists(t *testing.T) {
|
||||
database, _ := newTestStore(t)
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "retry-root")
|
||||
injected := errors.New("injected root parent sync failure")
|
||||
if _, err := newStore(database, root, func(string) error { return injected }); !errors.Is(err, injected) {
|
||||
t.Fatalf("first newStore error = %v, want injected root sync failure", err)
|
||||
}
|
||||
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
||||
t.Fatalf("failed parent sync must leave root for retry: info=%v err=%v", info, err)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
store, err := newStore(database, root, func(path string) error {
|
||||
paths = append(paths, path)
|
||||
return syncDirectory(path)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry newStore: %v", err)
|
||||
}
|
||||
if store == nil || len(paths) == 0 || paths[0] != parent {
|
||||
t.Fatalf("retry sync paths = %#v, want root parent %q first", paths, parent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitSyncsShardAndRenameBeforeDatabaseWrite(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
metadata := testMetadata(hash)
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
finalDirectory := filepath.Dir(finalPath)
|
||||
var events []string
|
||||
store.syncDirectory = func(path string) error {
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
|
||||
t.Fatalf("count evidence before directory sync: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("database row became visible before directory sync: %d", count)
|
||||
}
|
||||
switch path {
|
||||
case store.root:
|
||||
events = append(events, "sync-root")
|
||||
if path != store.root {
|
||||
t.Fatalf("shard parent sync path = %q, want evidence root %q", path, store.root)
|
||||
}
|
||||
if info, err := os.Stat(finalDirectory); err != nil || !info.IsDir() {
|
||||
t.Fatalf("shard directory must exist before parent sync: info=%v err=%v", info, err)
|
||||
}
|
||||
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final file exists before publication: %v", err)
|
||||
}
|
||||
case finalDirectory:
|
||||
events = append(events, "sync-shard")
|
||||
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("renamed file must exist before shard sync: info=%v err=%v", info, err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected extra directory sync: %q", path)
|
||||
}
|
||||
return syncDirectory(path)
|
||||
}
|
||||
store.syncFile = func(file *os.File) error {
|
||||
if filepath.Dir(file.Name()) != finalDirectory || !strings.HasPrefix(filepath.Base(file.Name()), ".publish-") {
|
||||
t.Fatalf("publication temp is not inside shard: %q", file.Name())
|
||||
}
|
||||
events = append(events, "sync-file")
|
||||
return file.Sync()
|
||||
}
|
||||
store.renameFile = func(oldPath, newPath string) error {
|
||||
if filepath.Dir(oldPath) != filepath.Dir(newPath) || newPath != finalPath {
|
||||
t.Fatalf("rename is not same-directory publication: %q -> %q", oldPath, newPath)
|
||||
}
|
||||
events = append(events, "rename")
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); err != nil || replayed {
|
||||
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
|
||||
}
|
||||
if got, want := strings.Join(events, ","), "sync-root,sync-root,sync-file,rename,sync-shard"; got != want {
|
||||
t.Fatalf("durability order = %q, want %q", got, want)
|
||||
}
|
||||
assertEvidenceCount(t, database, 1)
|
||||
assertNoPublishTemps(t, finalDirectory)
|
||||
}
|
||||
|
||||
func TestCommitDirectorySyncFailuresNeverWriteDatabase(t *testing.T) {
|
||||
for _, failAt := range []int{1, 2, 3} {
|
||||
t.Run(map[int]string{1: "new shard parent", 2: "unconditional shard parent", 3: "rename target"}[failAt], func(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
injected := errors.New("injected directory sync failure")
|
||||
calls := 0
|
||||
store.syncDirectory = func(path string) error {
|
||||
calls++
|
||||
if calls == failAt {
|
||||
return injected
|
||||
}
|
||||
return syncDirectory(path)
|
||||
}
|
||||
|
||||
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) {
|
||||
t.Fatalf("Commit error = %v, want injected sync failure", err)
|
||||
}
|
||||
if calls != failAt {
|
||||
t.Fatalf("sync calls = %d, want %d", calls, failAt)
|
||||
}
|
||||
assertEvidenceCount(t, database, 0)
|
||||
_, statErr := os.Stat(finalPath)
|
||||
if failAt < 3 && !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("file exists before rename durability boundary: %v", statErr)
|
||||
}
|
||||
if failAt == 3 && statErr != nil {
|
||||
t.Fatalf("post-rename sync failure may leave an orphan file, stat error = %v", statErr)
|
||||
}
|
||||
assertNoPublishTemps(t, filepath.Dir(finalPath))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
firstStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("first Stage: %v", err)
|
||||
}
|
||||
injected := errors.New("injected first shard parent sync failure")
|
||||
store.syncDirectory = func(string) error { return injected }
|
||||
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), firstStage); !errors.Is(err, injected) {
|
||||
t.Fatalf("first Commit error = %v", err)
|
||||
}
|
||||
if info, err := os.Stat(filepath.Dir(finalPath)); err != nil || !info.IsDir() {
|
||||
t.Fatalf("failed first sync must leave the created shard for retry: info=%v err=%v", info, err)
|
||||
}
|
||||
assertEvidenceCount(t, database, 0)
|
||||
|
||||
secondStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("second Stage: %v", err)
|
||||
}
|
||||
var paths []string
|
||||
store.syncDirectory = func(path string) error {
|
||||
paths = append(paths, path)
|
||||
return syncDirectory(path)
|
||||
}
|
||||
if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), secondStage); err != nil || replayed {
|
||||
t.Fatalf("retry Commit = replayed %t, err %v", replayed, err)
|
||||
}
|
||||
if len(paths) != 2 || paths[0] != store.root || paths[1] != filepath.Dir(finalPath) {
|
||||
t.Fatalf("retry sync paths = %#v, want root then shard", paths)
|
||||
}
|
||||
assertEvidenceCount(t, database, 1)
|
||||
assertNoPublishTemps(t, filepath.Dir(finalPath))
|
||||
}
|
||||
|
||||
func TestCommitPublicationFailuresCleanTempAndNeverWriteDatabase(t *testing.T) {
|
||||
for _, name := range []string{"file sync", "rename"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
injected := errors.New("injected publication failure")
|
||||
if name == "file sync" {
|
||||
store.syncFile = func(*os.File) error { return injected }
|
||||
} else {
|
||||
store.renameFile = func(string, string) error { return injected }
|
||||
}
|
||||
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) {
|
||||
t.Fatalf("Commit error = %v, want injected publication failure", err)
|
||||
}
|
||||
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("final file exists after failed publication: %v", err)
|
||||
}
|
||||
assertNoPublishTemps(t, filepath.Dir(finalPath))
|
||||
assertEvidenceCount(t, database, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitDatabaseFailuresAfterDurableRenameLeaveOnlyOrphan(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
inject func(*testing.T, *sql.DB, *Store, error)
|
||||
}{
|
||||
{
|
||||
name: "insert",
|
||||
inject: func(t *testing.T, database *sql.DB, _ *Store, _ error) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`CREATE TRIGGER fail_evidence_insert BEFORE INSERT ON evidence_assets BEGIN SELECT RAISE(ABORT, 'injected insert failure'); END`); err != nil {
|
||||
t.Fatalf("create insert failure trigger: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "commit",
|
||||
inject: func(_ *testing.T, _ *sql.DB, store *Store, injected error) {
|
||||
store.commitTx = func(*sql.Tx) error { return injected }
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 4, 3)
|
||||
hash := sha256Hex(pngBytes)
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
finalPath, err := store.pathForKey(storageKey(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("final path: %v", err)
|
||||
}
|
||||
injected := errors.New("injected database failure")
|
||||
test.inject(t, database, store, injected)
|
||||
syncCalls := 0
|
||||
store.syncDirectory = func(path string) error {
|
||||
syncCalls++
|
||||
return syncDirectory(path)
|
||||
}
|
||||
|
||||
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); err == nil {
|
||||
t.Fatal("Commit unexpectedly succeeded")
|
||||
}
|
||||
if syncCalls != 3 {
|
||||
t.Fatalf("database failure occurred before both durability syncs: sync calls = %d", syncCalls)
|
||||
}
|
||||
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("durable rename may leave only an orphan file: info=%v err=%v", info, err)
|
||||
}
|
||||
assertNoPublishTemps(t, filepath.Dir(finalPath))
|
||||
assertEvidenceCount(t, database, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageRejectsUnsafeContent(t *testing.T) {
|
||||
_, store := newTestStore(t)
|
||||
largePNG := makePNG(t, core.MaxImageSide+1, 1)
|
||||
pngWithXML := append(makePNG(t, 1, 1), []byte("<hierarchy/>")...)
|
||||
for name, test := range map[string]struct {
|
||||
reader io.Reader
|
||||
contentType string
|
||||
}{
|
||||
"wrong content type": {reader: bytes.NewReader(makePNG(t, 1, 1)), contentType: "application/octet-stream"},
|
||||
"xml": {reader: bytes.NewBufferString("<hierarchy/>"), contentType: core.PNGContentType},
|
||||
"png with xml tail": {reader: bytes.NewReader(pngWithXML), contentType: core.PNGContentType},
|
||||
"truncated png": {reader: bytes.NewReader(pngSignature), contentType: core.PNGContentType},
|
||||
"too wide": {reader: bytes.NewReader(largePNG), contentType: core.PNGContentType},
|
||||
"too many bytes": {reader: io.LimitReader(zeroReader{}, core.MaxFileBytes+1), contentType: core.PNGContentType},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
staged, err := store.Stage(test.reader, test.contentType)
|
||||
if !errors.Is(err, core.ErrInvalid) && !errors.Is(err, core.ErrTooLarge) {
|
||||
store.Discard(staged)
|
||||
t.Fatalf("Stage error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitRequiresAttemptOwnedByTaskAndLowercaseHash(t *testing.T) {
|
||||
database, store := newTestStore(t)
|
||||
insertAttemptFixture(t, database)
|
||||
pngBytes := makePNG(t, 2, 2)
|
||||
base := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
|
||||
for name, mutate := range map[string]func(*core.UploadMetadata){
|
||||
"unknown attempt": func(value *core.UploadMetadata) { value.AttemptID = "53c9f507-7473-4fa6-8d71-8786c34c6301" },
|
||||
"uppercase hash": func(value *core.UploadMetadata) {
|
||||
value.SHA256 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
},
|
||||
"wrong kind": func(value *core.UploadMetadata) { value.Kind = "ORDER_CONFIRM" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
|
||||
if err != nil {
|
||||
t.Fatalf("Stage: %v", err)
|
||||
}
|
||||
metadata := base
|
||||
mutate(&metadata)
|
||||
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); !errors.Is(err, core.ErrInvalid) {
|
||||
t.Fatalf("Commit error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("invalid commits created %d assets, err %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStoreRejectsRelativeAndFilesystemRootPaths(t *testing.T) {
|
||||
database, _ := newTestStore(t)
|
||||
if _, err := NewStore(database, "relative-evidence"); err == nil {
|
||||
t.Fatal("relative evidence root succeeded")
|
||||
}
|
||||
volumeRoot := filepath.VolumeName(t.TempDir()) + string(filepath.Separator)
|
||||
if _, err := NewStore(database, volumeRoot); err == nil {
|
||||
t.Fatal("filesystem root succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
type zeroReader struct{}
|
||||
|
||||
func (zeroReader) Read(buffer []byte) (int, error) {
|
||||
for index := range buffer {
|
||||
buffer[index] = 0
|
||||
}
|
||||
return len(buffer), nil
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) (*sql.DB, *Store) {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "evidence.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate test")
|
||||
}
|
||||
directory := filepath.Join(filepath.Dir(file), "..", "..", "..", "migrations")
|
||||
if err := migrations.Up(context.Background(), database, directory); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
store, err := NewStore(database, filepath.Join(t.TempDir(), "assets"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
return database, store
|
||||
}
|
||||
|
||||
func insertAttemptFixture(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, testAuthID, testTaskID, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert authorization: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadata(hash string) core.UploadMetadata {
|
||||
return core.UploadMetadata{
|
||||
UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID,
|
||||
Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash,
|
||||
CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func assertEvidenceCount(t *testing.T, database *sql.DB, want int) {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
|
||||
t.Fatalf("count evidence assets: %v", err)
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("evidence asset count = %d, want %d", count, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoPublishTemps(t *testing.T, directory string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatalf("read shard directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".publish-") {
|
||||
t.Fatalf("publication temp leaked: %q", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makePNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
imageData := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
imageData.Set(0, 0, color.NRGBA{R: 12, G: 34, B: 56, A: 255})
|
||||
var buffer bytes.Buffer
|
||||
if err := png.Encode(&buffer, imageData); err != nil {
|
||||
t.Fatalf("encode PNG: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func sha256Hex(value []byte) string {
|
||||
hash := sha256.Sum256(value)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
Reference in New Issue
Block a user