mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user