From 9b69564c49b1534e3f47131bbf7dce3c0c0e7bbb Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 16 Feb 2026 18:16:42 +0100 Subject: [PATCH] fix: prevent server crash on unsupported Shift-JIS characters (#116) UTF8ToSJIS panicked when encountering characters outside the Shift-JIS range (emoji, Lenny faces, cuneiform, etc.), crashing the server when such characters were sent via the Discord relay channel. Replace the panic with graceful filtering that drops unmappable runes and preserves valid content. Also fix ToNGWord index-out-of-range panic on empty encoder output. Closes #116 --- CHANGELOG.md | 1 + common/stringsupport/string_convert.go | 13 +++- common/stringsupport/string_convert_test.go | 74 +++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) 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()