fix(battle-node): harden SIO parse + narrow Matched OppoId/Seed to int
#3: SocketIoFrame.Parse now range-checks the packet type char (was unchecked cast — any char outside 0-6 produced an undefined enum value) and uses int.TryParse for ack-id (was int.Parse — a >10-digit ack-id threw OverflowException, tearing down the WS mid-game). Both now throw ArgumentException consistently. The read loop in RealParticipant wraps both EIO and SIO parse calls with try-catch so a malformed frame is logged and skipped instead of killing the battle. #4: MatchedSelfInfo/MatchedOppoInfo OppoId and Seed narrowed from long to int. The client reads both with Convert.ToInt32 inside a swallowing try/catch — any value > int.MaxValue silently dropped the Matched event, preventing the battle from starting. Seed was already int-range (BattleSeeds.Stable returns int); OppoId (viewer ID) is ~847M in captures, well under int.MaxValue. The narrowing cast now happens explicitly in ServerBattleFrames.BuildMatched at the wire boundary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ public static class ServerBattleFrames
|
||||
public static MsgEnvelope BuildMatched(
|
||||
MatchContext selfCtx, MatchContext oppoCtx,
|
||||
long selfViewerId, long oppoViewerId,
|
||||
string battleId, long seed, IReadOnlyList<long> selfDeckOrder) =>
|
||||
string battleId, int seed, IReadOnlyList<long> selfDeckOrder) =>
|
||||
EnvelopeForPush(NetworkBattleUri.Matched,
|
||||
new MatchedBody(
|
||||
SelfInfo: new MatchedSelfInfo(
|
||||
@@ -34,7 +34,7 @@ public static class ServerBattleFrames
|
||||
DegreeId: selfCtx.DegreeId,
|
||||
FieldId: selfCtx.FieldId,
|
||||
IsOfficial: selfCtx.IsOfficial != 0,
|
||||
OppoId: oppoViewerId,
|
||||
OppoId: (int)oppoViewerId,
|
||||
Seed: seed),
|
||||
OppoInfo: new MatchedOppoInfo(
|
||||
CountryCode: oppoCtx.CountryCode,
|
||||
@@ -44,7 +44,7 @@ public static class ServerBattleFrames
|
||||
DegreeId: oppoCtx.DegreeId,
|
||||
FieldId: oppoCtx.FieldId,
|
||||
IsOfficial: oppoCtx.IsOfficial != 0,
|
||||
OppoId: selfViewerId,
|
||||
OppoId: (int)selfViewerId,
|
||||
Seed: seed,
|
||||
OppoDeckCount: oppoCtx.SelfDeckCardIds.Count),
|
||||
SelfDeck: BuildPlayerDeck(selfDeckOrder)),
|
||||
|
||||
@@ -19,8 +19,8 @@ public sealed record MatchedSelfInfo(
|
||||
[property: JsonPropertyName("fieldId")] int FieldId,
|
||||
[property: JsonPropertyName("isOfficial")]
|
||||
[property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial,
|
||||
[property: JsonPropertyName("oppoId")] long OppoId,
|
||||
[property: JsonPropertyName("seed")] long Seed);
|
||||
[property: JsonPropertyName("oppoId")] int OppoId,
|
||||
[property: JsonPropertyName("seed")] int Seed);
|
||||
|
||||
public sealed record MatchedOppoInfo(
|
||||
[property: JsonPropertyName("country_code")] string CountryCode,
|
||||
@@ -31,8 +31,8 @@ public sealed record MatchedOppoInfo(
|
||||
[property: JsonPropertyName("fieldId")] int FieldId,
|
||||
[property: JsonPropertyName("isOfficial")]
|
||||
[property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial,
|
||||
[property: JsonPropertyName("oppoId")] long OppoId,
|
||||
[property: JsonPropertyName("seed")] long Seed,
|
||||
[property: JsonPropertyName("oppoId")] int OppoId,
|
||||
[property: JsonPropertyName("seed")] int Seed,
|
||||
[property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount);
|
||||
|
||||
public sealed record DeckCardRef(
|
||||
|
||||
@@ -134,7 +134,15 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
|
||||
if (text.Length == 0) continue;
|
||||
var eio = EngineIoFrame.Parse(text);
|
||||
|
||||
EngineIoFrame eio;
|
||||
try { eio = EngineIoFrame.Parse(text); }
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Dropping unparseable EIO frame from viewer {Vid}", ViewerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_diagnosticLogging)
|
||||
{
|
||||
_log.LogInformation(
|
||||
@@ -149,7 +157,13 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
}
|
||||
if (eio.Type != EngineIoPacketType.Message) continue;
|
||||
|
||||
var sio = SocketIoFrame.Parse(eio.Payload);
|
||||
SocketIoFrame sio;
|
||||
try { sio = SocketIoFrame.Parse(eio.Payload); }
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Dropping unparseable SIO frame from viewer {Vid}", ViewerId);
|
||||
continue;
|
||||
}
|
||||
if (sio.AttachmentCount > 0)
|
||||
{
|
||||
pendingFrame = sio;
|
||||
|
||||
@@ -52,7 +52,10 @@ public sealed class SocketIoFrame
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
throw new ArgumentException("Empty SIO payload", nameof(raw));
|
||||
|
||||
var type = (SocketIoPacketType)(raw[0] - '0');
|
||||
var typeChar = raw[0];
|
||||
if (typeChar < '0' || typeChar > '6')
|
||||
throw new ArgumentException($"Invalid SIO type char '{typeChar}'", nameof(raw));
|
||||
var type = (SocketIoPacketType)(typeChar - '0');
|
||||
var cursor = 1;
|
||||
|
||||
var attachmentCount = 0;
|
||||
@@ -84,7 +87,9 @@ public sealed class SocketIoFrame
|
||||
{
|
||||
var start = cursor;
|
||||
while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
|
||||
ackId = int.Parse(raw.AsSpan(start, cursor - start));
|
||||
if (!int.TryParse(raw.AsSpan(start, cursor - start), out var parsedAckId))
|
||||
throw new ArgumentException("SIO ack-id overflows int32", nameof(raw));
|
||||
ackId = parsedAckId;
|
||||
}
|
||||
|
||||
var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;
|
||||
|
||||
@@ -14,19 +14,19 @@ public class ServerBattleFramesTests
|
||||
{
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||
selfViewerId: 906243102, oppoViewerId: 847666884,
|
||||
battleId: "b", seed: 17_548_138L, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
|
||||
battleId: "b", seed: 17_548_138, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
|
||||
|
||||
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
|
||||
var body = (MatchedBody)env.Body;
|
||||
Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884L));
|
||||
Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102L));
|
||||
Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884));
|
||||
Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102));
|
||||
Assert.That(env.Bid, Is.EqualTo("b"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildMatched_ContainsThirtyCardSelfDeck()
|
||||
{
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", 17_548_138L, FixtureCtx().SelfDeckCardIds);
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", 17_548_138, FixtureCtx().SelfDeckCardIds);
|
||||
var body = (MatchedBody)env.Body;
|
||||
Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public class ServerBattleFramesTests
|
||||
public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids()
|
||||
{
|
||||
var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList();
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", 17_548_138L, draftedDeck);
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", 17_548_138, draftedDeck);
|
||||
var body = (MatchedBody)env.Body;
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
@@ -56,7 +56,7 @@ public class ServerBattleFramesTests
|
||||
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1,
|
||||
};
|
||||
|
||||
var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", 17_548_138L, ctx.SelfDeckCardIds);
|
||||
var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", 17_548_138, ctx.SelfDeckCardIds);
|
||||
var body = (MatchedBody)env.Body;
|
||||
|
||||
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));
|
||||
|
||||
@@ -28,7 +28,7 @@ public class TypedBodyWireShapeTests
|
||||
// with "Value cannot be null. Parameter name: source". The prod wire format
|
||||
// emits envelope keys (uri first) before body keys; we must too.
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: 17_548_138L,
|
||||
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: 17_548_138,
|
||||
selfDeckOrder: FixtureCtx().SelfDeckCardIds);
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
|
||||
@@ -48,7 +48,7 @@ public class TypedBodyWireShapeTests
|
||||
{
|
||||
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
|
||||
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
|
||||
seed: 17_548_138L, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
|
||||
seed: 17_548_138, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
|
||||
|
||||
var json = MsgEnvelope.ToJson(env);
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
@@ -15,11 +15,11 @@ public class MatchedBodyTests
|
||||
SelfInfo: new MatchedSelfInfo(
|
||||
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
|
||||
EmblemId: "701441011", DegreeId: "300003", FieldId: 43,
|
||||
IsOfficial: false, OppoId: 847666884L, Seed: 17_548_138L),
|
||||
IsOfficial: false, OppoId: 847666884, Seed: 17_548_138),
|
||||
OppoInfo: new MatchedOppoInfo(
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5,
|
||||
IsOfficial: false, OppoId: 906243102L, Seed: 17_548_138L, OppoDeckCount: 30),
|
||||
IsOfficial: false, OppoId: 906243102, Seed: 17_548_138, OppoDeckCount: 30),
|
||||
SelfDeck: new[] { new DeckCardRef(Idx: 1, CardId: 100011010L) });
|
||||
|
||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
||||
@@ -32,16 +32,16 @@ public class MatchedBodyTests
|
||||
Assert.That(selfInfo["degreeId"]!.GetValue<string>(), Is.EqualTo("300003"));
|
||||
Assert.That(selfInfo["fieldId"]!.GetValue<int>(), Is.EqualTo(43));
|
||||
Assert.That(selfInfo["isOfficial"]!.GetValue<int>(), Is.EqualTo(0));
|
||||
Assert.That(selfInfo["oppoId"]!.GetValue<long>(), Is.EqualTo(847666884L));
|
||||
Assert.That(selfInfo["seed"]!.GetValue<long>(), Is.EqualTo(17_548_138L));
|
||||
Assert.That(selfInfo["oppoId"]!.GetValue<int>(), Is.EqualTo(847666884));
|
||||
Assert.That(selfInfo["seed"]!.GetValue<int>(), Is.EqualTo(17_548_138));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OppoInfo_HasOppoDeckCount_OnTheWire()
|
||||
{
|
||||
var body = new MatchedBody(
|
||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1L,1L),
|
||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1L,1L, OppoDeckCount: 30),
|
||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
|
||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1, OppoDeckCount: 30),
|
||||
SelfDeck: System.Array.Empty<DeckCardRef>());
|
||||
|
||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
||||
@@ -54,8 +54,8 @@ public class MatchedBodyTests
|
||||
public void SelfInfo_DoesNotHaveOppoDeckCount_OnTheWire()
|
||||
{
|
||||
var body = new MatchedBody(
|
||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1L,1L),
|
||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1L,1L,30),
|
||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
|
||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
|
||||
SelfDeck: System.Array.Empty<DeckCardRef>());
|
||||
|
||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
||||
@@ -68,8 +68,8 @@ public class MatchedBodyTests
|
||||
public void ResultCode_DefaultsToOne_OnConstruction()
|
||||
{
|
||||
var body = new MatchedBody(
|
||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1L,1L),
|
||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1L,1L,30),
|
||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
|
||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
|
||||
SelfDeck: System.Array.Empty<DeckCardRef>());
|
||||
|
||||
Assert.That(body.ResultCode, Is.EqualTo(1));
|
||||
@@ -81,8 +81,8 @@ public class MatchedBodyTests
|
||||
public void SelfDeck_SerializesAsArray_WithIdxAndCardIdKeys()
|
||||
{
|
||||
var body = new MatchedBody(
|
||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1L,1L),
|
||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1L,1L,30),
|
||||
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
|
||||
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
|
||||
SelfDeck: new[]
|
||||
{
|
||||
new DeckCardRef(Idx: 1, CardId: 100011010L),
|
||||
|
||||
@@ -99,8 +99,8 @@ public class BattleSessionDispatchTests
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
|
||||
var body = (MatchedBody)routes[0].Frame.Body;
|
||||
Assert.That(body.SelfInfo.Seed, Is.EqualTo((long)BattleSeeds.Stable(s.MasterSeed)));
|
||||
Assert.That(body.OppoInfo.Seed, Is.EqualTo((long)BattleSeeds.Stable(s.MasterSeed)));
|
||||
Assert.That(body.SelfInfo.Seed, Is.EqualTo(BattleSeeds.Stable(s.MasterSeed)));
|
||||
Assert.That(body.OppoInfo.Seed, Is.EqualTo(BattleSeeds.Stable(s.MasterSeed)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -125,4 +125,17 @@ public class SocketIoFrameTests
|
||||
// The event name must be JSON-escaped: each " becomes \", and the literal \ becomes \\.
|
||||
Assert.That(text, Does.Contain("\"weird \\\"name\\\" with \\\\ backslash\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_InvalidTypeChar_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("9[\"msg\"]"));
|
||||
Assert.That(ex!.Message, Does.Contain("Invalid SIO type char"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_OverflowingAckId_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("2999999999999[\"msg\"]"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user