feat(config): add DisableSaveIntegrityCheck flag for save transfers

The SHA-256 integrity check introduced in migration 0007 blocks saves
when a character's savedata blob is imported from another server instance,
because the stored hash in the target DB no longer matches the new blob.

Adding DisableSaveIntegrityCheck (default: false) lets server operators
bypass the check to unblock cross-server save transfers. A warning is
logged each time the check is skipped so the flag's use is auditable.

Documents the per-character SQL alternative in CHANGELOG:
  UPDATE characters SET savedata_hash = NULL WHERE id = <id>

Closes #183.
This commit is contained in:
Houmgaor
2026-03-21 19:38:16 +01:00
parent 0911d15709
commit 0ea399f135
8 changed files with 83 additions and 5 deletions

View File

@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- `DisableSaveIntegrityCheck` config flag: when `true`, the SHA-256 savedata integrity check is skipped on load. Intended for cross-server save transfers where the stored hash in the database does not match the imported save blob. Defaults to `false`. Affected characters can alternatively be unblocked per-character with `UPDATE characters SET savedata_hash = NULL WHERE id = <id>`.
## [9.3.0] - 2026-03-19
### Fixed

View File

@@ -17,6 +17,7 @@
"UploadQuality":100
},
"DeleteOnSaveCorruption": false,
"DisableSaveIntegrityCheck": false,
"ClientMode": "ZZ",
"QuestCacheExpiry": 300,
"CommandPrefix": "!",

View File

@@ -73,6 +73,7 @@ type Config struct {
PatchServerManifest string // Manifest patch server override
PatchServerFile string // File patch server override
DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
DisableSaveIntegrityCheck bool // Skip SHA-256 hash verification on load (needed for cross-server save transfers)
ClientMode string
RealClientMode Mode
QuestCacheExpiry int // Number of seconds to keep quest data cached

View File

@@ -151,6 +151,7 @@ func TestConfigStruct(t *testing.T) {
PatchServerManifest: "http://patch.example.com/manifest",
PatchServerFile: "http://patch.example.com/files",
DeleteOnSaveCorruption: false,
DisableSaveIntegrityCheck: false,
ClientMode: "ZZ",
RealClientMode: ZZ,
QuestCacheExpiry: 3600,

View File

@@ -158,6 +158,7 @@ func TestConfigStructTypes(t *testing.T) {
PatchServerManifest: "http://patch.example.com",
PatchServerFile: "http://files.example.com",
DeleteOnSaveCorruption: false,
DisableSaveIntegrityCheck: false,
ClientMode: "ZZ",
RealClientMode: ZZ,
QuestCacheExpiry: 3600,

View File

@@ -55,7 +55,8 @@ func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error)
// Verify integrity checksum if one was stored with this save.
// A nil hash means the character was saved before checksums were introduced,
// so we skip verification (the next save will compute and store the hash).
if storedHash != nil {
// DisableSaveIntegrityCheck bypasses this entirely for cross-server save transfers.
if storedHash != nil && !s.server.erupeConfig.DisableSaveIntegrityCheck {
computedHash := sha256.Sum256(saveData.decompSave)
if !bytes.Equal(storedHash, computedHash[:]) {
s.logger.Error("Savedata integrity check failed: hash mismatch",
@@ -66,6 +67,10 @@ func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error)
// TODO: attempt recovery from savedata_backups here
return nil, errors.New("savedata integrity check failed")
}
} else if storedHash != nil && s.server.erupeConfig.DisableSaveIntegrityCheck {
s.logger.Warn("Savedata integrity check skipped (DisableSaveIntegrityCheck=true)",
zap.Uint32("charID", charID),
)
}
saveData.updateStructWithSaveData()

View File

@@ -747,3 +747,68 @@ func TestGetCharacterSaveData_ConfigMode(t *testing.T) {
})
}
}
// TestGetCharacterSaveData_IntegrityCheck verifies the SHA-256 hash guard and
// that DisableSaveIntegrityCheck bypasses it without returning an error.
func TestGetCharacterSaveData_IntegrityCheck(t *testing.T) {
// Build a minimal valid savedata blob and compress it.
rawSave := make([]byte, 150000)
copy(rawSave[88:], []byte("TestChar\x00"))
compressed, err := nullcomp.Compress(rawSave)
if err != nil {
t.Fatalf("compress: %v", err)
}
// A hash that deliberately does NOT match rawSave.
wrongHash := bytes.Repeat([]byte{0xDE}, 32)
tests := []struct {
name string
disable bool
hash []byte
wantErr bool
}{
{
name: "nil hash skips check",
disable: false,
hash: nil,
wantErr: false,
},
{
name: "mismatched hash fails when check enabled",
disable: false,
hash: wrongHash,
wantErr: true,
},
{
name: "mismatched hash passes when check disabled",
disable: true,
hash: wrongHash,
wantErr: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mock := newMockCharacterRepo()
mock.loadSaveDataID = 1
mock.loadSaveDataData = compressed
mock.loadSaveDataName = "TestChar"
mock.loadSaveDataHash = tc.hash
server := createMockServer()
server.erupeConfig.RealClientMode = cfg.ZZ
server.erupeConfig.DisableSaveIntegrityCheck = tc.disable
server.charRepo = mock
session := createMockSession(1, server)
_, err := GetCharacterSaveData(session, 1)
if tc.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}

View File

@@ -133,6 +133,7 @@ type mockCharacterRepo struct {
loadSaveDataData []byte
loadSaveDataNew bool
loadSaveDataName string
loadSaveDataHash []byte
loadSaveDataErr error
// ReadEtcPoints mock fields
@@ -245,7 +246,7 @@ func (m *mockCharacterRepo) SaveBackup(_ uint32, _ int, _ []byte) error {
func (m *mockCharacterRepo) GetLastBackupTime(_ uint32) (time.Time, error) { return time.Time{}, nil }
func (m *mockCharacterRepo) SaveCharacterDataAtomic(_ SaveAtomicParams) error { return nil }
func (m *mockCharacterRepo) LoadSaveDataWithHash(_ uint32) (uint32, []byte, bool, string, []byte, error) {
return m.loadSaveDataID, m.loadSaveDataData, m.loadSaveDataNew, m.loadSaveDataName, nil, m.loadSaveDataErr
return m.loadSaveDataID, m.loadSaveDataData, m.loadSaveDataNew, m.loadSaveDataName, m.loadSaveDataHash, m.loadSaveDataErr
}
// --- mockGoocooRepo ---