2026-08-04 19:42:35 +08:00
// 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"
2026-08-04 20:57:33 +08:00
"cmbuyer/admin/internal/deviceauth"
2026-08-04 19:42:35 +08:00
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 , 0 o700 , 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 , 0 o700 ); err != nil {
return nil , fmt . Errorf ( "protect evidence root: %w" , err )
}
staging := filepath . Join ( absolute , ".staging" )
if err := ensureDurableDirectory ( staging , 0 o700 , 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 , 0 o700 ); 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 ( 0 o600 ); 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 )
}
}
2026-08-04 20:57:33 +08:00
func ( store * Store ) Commit ( ctx context . Context , principal deviceauth . Principal , metadata core . UploadMetadata , staged core . StagedFile ) ( core . Asset , bool , error ) {
2026-08-04 19:42:35 +08:00
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 , 0 o700 , 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 , 0 o700 ); 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 ( 0 o600 ); 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
}
2026-08-04 20:57:33 +08:00
func validPrincipal ( principal deviceauth . Principal ) bool {
return deviceauth . ValidDeviceID ( principal . ID )
2026-08-04 19:42:35 +08:00
}
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
}
2026-08-04 20:57:33 +08:00
func sameUpload ( asset core . Asset , principal deviceauth . Principal , metadata core . UploadMetadata , staged core . StagedFile ) bool {
2026-08-04 19:42:35 +08:00
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 )
}