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
This commit is contained in:
Houmgaor
2026-02-16 18:16:42 +01:00
parent 7689bc2ee9
commit 9b69564c49
3 changed files with 86 additions and 2 deletions

View File

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

View File

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

View File

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