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

@@ -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 ---