diff --git a/CHANGELOG.md b/CHANGELOG.md index 284d1b5d8..5d485c1fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed double-save bug in logout flow that caused unnecessary database operations - Fixed save operation ordering - now saves data before session cleanup instead of after - Fixed stale transmog/armor appearance shown to other players - user binary cache now invalidated when plate data is saved +- Fixed server crash when Discord relay receives messages with unsupported Shift-JIS characters (emoji, Lenny faces, cuneiform, etc.) ### Security diff --git a/common/stringsupport/string_convert.go b/common/stringsupport/string_convert.go index 16627b2cc..2d09138f8 100644 --- a/common/stringsupport/string_convert.go +++ b/common/stringsupport/string_convert.go @@ -15,7 +15,15 @@ func UTF8ToSJIS(x string) []byte { e := japanese.ShiftJIS.NewEncoder() xt, _, err := transform.String(e, x) if err != nil { - panic(err) + // Filter out runes that can't be encoded to Shift-JIS instead of + // crashing the server (see PR #116). + var filtered []rune + for _, r := range x { + if _, _, err := transform.String(japanese.ShiftJIS.NewEncoder(), string(r)); err == nil { + filtered = append(filtered, r) + } + } + xt, _, _ = transform.String(japanese.ShiftJIS.NewEncoder(), string(filtered)) } return []byte(xt) } @@ -36,9 +44,10 @@ func ToNGWord(x string) []uint16 { t := UTF8ToSJIS(string(r)) if len(t) > 1 { w = append(w, uint16(t[1])<<8|uint16(t[0])) - } else { + } else if len(t) == 1 { w = append(w, uint16(t[0])) } + // Skip runes that produced no SJIS output (unsupported characters) } else { w = append(w, uint16(r)) } diff --git a/common/stringsupport/string_convert_test.go b/common/stringsupport/string_convert_test.go index adfc434f4..69a93fdea 100644 --- a/common/stringsupport/string_convert_test.go +++ b/common/stringsupport/string_convert_test.go @@ -458,6 +458,80 @@ func BenchmarkCSVElems(b *testing.B) { } } +func TestUTF8ToSJIS_UnsupportedCharacters(t *testing.T) { + // Regression test for PR #116: Characters outside the Shift-JIS range + // (e.g. Lenny face, cuneiform) previously caused a panic in UTF8ToSJIS, + // crashing the server when relayed from Discord. + tests := []struct { + name string + input string + }{ + {"lenny_face", "( ͡° ͜ʖ ͡°)"}, + {"cuneiform", "𒀜"}, + {"emoji", "Hello 🎮 World"}, + {"mixed_unsupported", "Test ͡° message 𒀜 here"}, + {"zalgo_text", "H̷e̸l̵l̶o̷"}, + {"only_unsupported", "🎮🎲🎯"}, + {"cyrillic", "Привет"}, + {"arabic", "مرحبا"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Must not panic - the old code would panic here + defer func() { + if r := recover(); r != nil { + t.Errorf("UTF8ToSJIS panicked on input %q: %v", tt.input, r) + } + }() + result := UTF8ToSJIS(tt.input) + if result == nil { + t.Error("UTF8ToSJIS returned nil") + } + }) + } +} + +func TestUTF8ToSJIS_PreservesValidContent(t *testing.T) { + // Verify that valid Shift-JIS content is preserved when mixed with + // unsupported characters. + tests := []struct { + name string + input string + expected string + }{ + {"ascii_with_emoji", "Hello 🎮 World", "Hello World"}, + {"japanese_with_emoji", "テスト🎮データ", "テストデータ"}, + {"only_valid", "Hello World", "Hello World"}, + {"only_invalid", "🎮🎲🎯", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sjis := UTF8ToSJIS(tt.input) + roundTripped := SJISToUTF8(sjis) + if roundTripped != tt.expected { + t.Errorf("UTF8ToSJIS(%q) round-tripped to %q, want %q", tt.input, roundTripped, tt.expected) + } + }) + } +} + +func TestToNGWord_UnsupportedCharacters(t *testing.T) { + // ToNGWord also calls UTF8ToSJIS internally, so it must not panic either. + inputs := []string{"( ͡° ͜ʖ ͡°)", "🎮", "Hello 🎮 World"} + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("ToNGWord panicked on input %q: %v", input, r) + } + }() + _ = ToNGWord(input) + }) + } +} + func BenchmarkUTF8ToSJIS(b *testing.B) { text := "Hello World テスト" b.ResetTimer()