mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
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:
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [9.3.0] - 2026-03-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"UploadQuality":100
|
"UploadQuality":100
|
||||||
},
|
},
|
||||||
"DeleteOnSaveCorruption": false,
|
"DeleteOnSaveCorruption": false,
|
||||||
|
"DisableSaveIntegrityCheck": false,
|
||||||
"ClientMode": "ZZ",
|
"ClientMode": "ZZ",
|
||||||
"QuestCacheExpiry": 300,
|
"QuestCacheExpiry": 300,
|
||||||
"CommandPrefix": "!",
|
"CommandPrefix": "!",
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ type Config struct {
|
|||||||
LoginNotices []string // MHFML string of the login notices displayed
|
LoginNotices []string // MHFML string of the login notices displayed
|
||||||
PatchServerManifest string // Manifest patch server override
|
PatchServerManifest string // Manifest patch server override
|
||||||
PatchServerFile string // File patch server override
|
PatchServerFile string // File patch server override
|
||||||
DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
|
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
|
ClientMode string
|
||||||
RealClientMode Mode
|
RealClientMode Mode
|
||||||
QuestCacheExpiry int // Number of seconds to keep quest data cached
|
QuestCacheExpiry int // Number of seconds to keep quest data cached
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ func TestConfigStruct(t *testing.T) {
|
|||||||
LoginNotices: []string{"Welcome"},
|
LoginNotices: []string{"Welcome"},
|
||||||
PatchServerManifest: "http://patch.example.com/manifest",
|
PatchServerManifest: "http://patch.example.com/manifest",
|
||||||
PatchServerFile: "http://patch.example.com/files",
|
PatchServerFile: "http://patch.example.com/files",
|
||||||
DeleteOnSaveCorruption: false,
|
DeleteOnSaveCorruption: false,
|
||||||
|
DisableSaveIntegrityCheck: false,
|
||||||
ClientMode: "ZZ",
|
ClientMode: "ZZ",
|
||||||
RealClientMode: ZZ,
|
RealClientMode: ZZ,
|
||||||
QuestCacheExpiry: 3600,
|
QuestCacheExpiry: 3600,
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ func TestConfigStructTypes(t *testing.T) {
|
|||||||
LoginNotices: []string{"Notice"},
|
LoginNotices: []string{"Notice"},
|
||||||
PatchServerManifest: "http://patch.example.com",
|
PatchServerManifest: "http://patch.example.com",
|
||||||
PatchServerFile: "http://files.example.com",
|
PatchServerFile: "http://files.example.com",
|
||||||
DeleteOnSaveCorruption: false,
|
DeleteOnSaveCorruption: false,
|
||||||
|
DisableSaveIntegrityCheck: false,
|
||||||
ClientMode: "ZZ",
|
ClientMode: "ZZ",
|
||||||
RealClientMode: ZZ,
|
RealClientMode: ZZ,
|
||||||
QuestCacheExpiry: 3600,
|
QuestCacheExpiry: 3600,
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error)
|
|||||||
// Verify integrity checksum if one was stored with this save.
|
// Verify integrity checksum if one was stored with this save.
|
||||||
// A nil hash means the character was saved before checksums were introduced,
|
// 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).
|
// 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)
|
computedHash := sha256.Sum256(saveData.decompSave)
|
||||||
if !bytes.Equal(storedHash, computedHash[:]) {
|
if !bytes.Equal(storedHash, computedHash[:]) {
|
||||||
s.logger.Error("Savedata integrity check failed: hash mismatch",
|
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
|
// TODO: attempt recovery from savedata_backups here
|
||||||
return nil, errors.New("savedata integrity check failed")
|
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()
|
saveData.updateStructWithSaveData()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ type mockCharacterRepo struct {
|
|||||||
loadSaveDataData []byte
|
loadSaveDataData []byte
|
||||||
loadSaveDataNew bool
|
loadSaveDataNew bool
|
||||||
loadSaveDataName string
|
loadSaveDataName string
|
||||||
|
loadSaveDataHash []byte
|
||||||
loadSaveDataErr error
|
loadSaveDataErr error
|
||||||
|
|
||||||
// ReadEtcPoints mock fields
|
// 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) GetLastBackupTime(_ uint32) (time.Time, error) { return time.Time{}, nil }
|
||||||
func (m *mockCharacterRepo) SaveCharacterDataAtomic(_ SaveAtomicParams) error { return nil }
|
func (m *mockCharacterRepo) SaveCharacterDataAtomic(_ SaveAtomicParams) error { return nil }
|
||||||
func (m *mockCharacterRepo) LoadSaveDataWithHash(_ uint32) (uint32, []byte, bool, string, []byte, error) {
|
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 ---
|
// --- mockGoocooRepo ---
|
||||||
|
|||||||
Reference in New Issue
Block a user