feat(savedata): add tier 2 integrity protections

Strengthen savedata persistence against corruption and race conditions:

- SHA-256 checksum: hash the decompressed blob on every save, store in
  new savedata_hash column, verify on load to detect silent corruption.
  Pre-existing characters with no hash are silently upgraded on next save.
- Atomic transactions: wrap character data + house data + hash + backup
  into a single DB transaction via SaveCharacterDataAtomic, so a crash
  mid-save never leaves partial state.
- Per-character save mutex: CharacterLocks (sync.Map of charID → Mutex)
  serializes concurrent saves for the same character, preventing races
  that could defeat corruption detection. Different characters remain
  fully independent.

Migration 0008 adds the savedata_hash column to the characters table.
This commit is contained in:
Houmgaor
2026-03-17 19:21:55 +01:00
parent d578e68b79
commit 01b829d0e9
9 changed files with 319 additions and 36 deletions

View File

@@ -2,11 +2,36 @@ package channelserver
import (
"database/sql"
"fmt"
"time"
"github.com/jmoiron/sqlx"
)
// SaveAtomicParams bundles all fields needed for an atomic save transaction.
type SaveAtomicParams struct {
CharID uint32
CompSave []byte
Hash []byte // SHA-256 of decompressed savedata
HR uint16
GR uint16
IsFemale bool
WeaponType uint8
WeaponID uint16
// House data (written to user_binary)
HouseTier []byte
HouseData []byte
BookshelfData []byte
GalleryData []byte
ToreData []byte
GardenData []byte
// Optional backup (nil means skip)
BackupSlot int
BackupData []byte
}
// CharacterRepository centralizes all database access for the characters table.
type CharacterRepository struct {
db *sqlx.DB
@@ -238,6 +263,60 @@ func (r *CharacterRepository) GetLastBackupTime(charID uint32) (time.Time, error
return t.Time, nil
}
// SaveCharacterDataAtomic performs all save-related writes in a single
// database transaction. If any step fails, everything is rolled back.
func (r *CharacterRepository) SaveCharacterDataAtomic(params SaveAtomicParams) error {
tx, err := r.db.Beginx()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback is no-op after commit
// 1. Save character data + hash
if _, err := tx.Exec(
`UPDATE characters SET savedata=$1, savedata_hash=$2, is_new_character=false, hr=$3, gr=$4, is_female=$5, weapon_type=$6, weapon_id=$7 WHERE id=$8`,
params.CompSave, params.Hash, params.HR, params.GR, params.IsFemale, params.WeaponType, params.WeaponID, params.CharID,
); err != nil {
return fmt.Errorf("save character data: %w", err)
}
// 2. Save house data
if _, err := tx.Exec(
`UPDATE user_binary SET house_tier=$1, house_data=$2, bookshelf=$3, gallery=$4, tore=$5, garden=$6 WHERE id=$7`,
params.HouseTier, params.HouseData, params.BookshelfData, params.GalleryData, params.ToreData, params.GardenData, params.CharID,
); err != nil {
return fmt.Errorf("save house data: %w", err)
}
// 3. Optional backup
if params.BackupData != nil {
if _, err := tx.Exec(
`INSERT INTO savedata_backups (char_id, slot, savedata, saved_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (char_id, slot) DO UPDATE SET savedata = $3, saved_at = now()`,
params.CharID, params.BackupSlot, params.BackupData,
); err != nil {
return fmt.Errorf("save backup: %w", err)
}
}
return tx.Commit()
}
// LoadSaveDataWithHash reads the core save columns plus the integrity hash.
// The hash may be nil for characters saved before checksums were introduced.
func (r *CharacterRepository) LoadSaveDataWithHash(charID uint32) (uint32, []byte, bool, string, []byte, error) {
var id uint32
var savedata []byte
var isNew bool
var name string
var hash []byte
err := r.db.QueryRow(
"SELECT id, savedata, is_new_character, name, savedata_hash FROM characters WHERE id = $1", charID,
).Scan(&id, &savedata, &isNew, &name, &hash)
return id, savedata, isNew, name, hash, err
}
// FindByRastaID looks up name and id by rasta_id.
func (r *CharacterRepository) FindByRastaID(rastaID int) (charID uint32, name string, err error) {
err = r.db.QueryRow("SELECT name, id FROM characters WHERE rasta_id=$1", rastaID).Scan(&name, &charID)