refactor(battle-node): SocketIoFrame throws on namespace; typed JSON construction

This commit is contained in:
gamer147
2026-06-01 11:48:17 -04:00
parent 2588388d9d
commit 133346e3e8
2 changed files with 41 additions and 20 deletions

View File

@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
namespace SVSim.BattleNode.Wire; namespace SVSim.BattleNode.Wire;
@@ -65,11 +66,17 @@ public sealed class SocketIoFrame
cursor = dashIdx + 1; cursor = dashIdx + 1;
} }
// Skip namespace (only present if a '/' starts here, terminated by ','). // Namespace prefix (only present if '/' starts here, terminated by ','). v1 only
// uses the default namespace; anything else is a protocol surprise we should
// surface rather than silently route to default. If we ever support non-default
// namespaces, capture into a property and let callers branch.
if (cursor < raw.Length && raw[cursor] == '/') if (cursor < raw.Length && raw[cursor] == '/')
{ {
var commaIdx = raw.IndexOf(',', cursor); var commaIdx = raw.IndexOf(',', cursor);
cursor = commaIdx >= 0 ? commaIdx + 1 : raw.Length; var ns = commaIdx >= 0 ? raw.Substring(cursor, commaIdx - cursor) : raw.Substring(cursor);
throw new ArgumentException(
$"Socket.IO namespaces aren't supported — got '{ns}'. v1 expects default namespace only.",
nameof(raw));
} }
int? ackId = null; int? ackId = null;
@@ -126,19 +133,15 @@ public sealed class SocketIoFrame
/// </summary> /// </summary>
public static SocketIoFrame BinaryEventWithAttachments(string eventName, IReadOnlyList<byte[]> attachments) public static SocketIoFrame BinaryEventWithAttachments(string eventName, IReadOnlyList<byte[]> attachments)
{ {
// Build the placeholder-only portion (without the event name) — event name is stored separately. // Build placeholders via the typed Nodes API; event name is stored separately.
var sb = new StringBuilder(); var placeholders = new JsonArray();
sb.Append('[');
for (var i = 0; i < attachments.Count; i++) for (var i = 0; i < attachments.Count; i++)
{ {
if (i > 0) sb.Append(','); placeholders.Add(new JsonObject
sb.Append("{\"_placeholder\":true,\"num\":").Append(i).Append('}'); {
} ["_placeholder"] = true,
sb.Append(']'); ["num"] = i,
JsonElement[] args; });
using (var doc = JsonDocument.Parse(sb.ToString()))
{
args = doc.RootElement.EnumerateArray().Select(el => el.Clone()).ToArray();
} }
return new SocketIoFrame( return new SocketIoFrame(
@@ -146,20 +149,28 @@ public sealed class SocketIoFrame
ackId: null, ackId: null,
attachmentCount: attachments.Count, attachmentCount: attachments.Count,
eventName: eventName, eventName: eventName,
rawArgs: args, rawArgs: NodesToElements(placeholders),
binaryAttachments: attachments); binaryAttachments: attachments);
} }
/// <summary>Build an ack response with a single int argument (the spec's pubSeq echo).</summary> /// <summary>Build an ack response with a single int argument (the spec's pubSeq echo).</summary>
public static SocketIoFrame AckResponse(int ackId, int arg) public static SocketIoFrame AckResponse(int ackId, int arg)
{ {
JsonElement[] args; var args = new JsonArray { arg };
using (var doc = JsonDocument.Parse($"[{arg}]"))
{
args = doc.RootElement.EnumerateArray().Select(el => el.Clone()).ToArray();
}
return new SocketIoFrame( return new SocketIoFrame(
SocketIoPacketType.Ack, ackId, 0, null, args, Array.Empty<byte[]>()); SocketIoPacketType.Ack, ackId, 0, null, NodesToElements(args), Array.Empty<byte[]>());
}
/// <summary>
/// Convert a <see cref="JsonArray"/> into the <see cref="JsonElement"/>[] that
/// <see cref="RawArgs"/> stores. The current storage type is <see cref="JsonElement"/>
/// because <see cref="Parse"/> produces it from <see cref="JsonDocument"/>; this helper
/// keeps the typed-construction call sites without changing <see cref="RawArgs"/>.
/// </summary>
private static JsonElement[] NodesToElements(JsonArray nodes)
{
using var doc = JsonDocument.Parse(nodes.ToJsonString());
return doc.RootElement.EnumerateArray().Select(el => el.Clone()).ToArray();
} }
/// <summary> /// <summary>

View File

@@ -105,6 +105,16 @@ public class SocketIoFrameTests
Assert.That(text, Is.EqualTo(wire)); Assert.That(text, Is.EqualTo(wire));
} }
[Test]
public void Parse_NamespacePrefix_Throws()
{
// v1 only supports the default namespace. A "/foo," prefix used to be silently
// skipped, which would route a frame meant for namespace /foo to the default
// handler. Fail loud instead so we'd notice if the client ever started using one.
var ex = Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("2/foo,[\"msg\"]"));
Assert.That(ex!.Message, Does.Contain("/foo"));
}
[Test] [Test]
public void Encode_EventNameWithSpecialChars_IsJsonEscaped() public void Encode_EventNameWithSpecialChars_IsJsonEscaped()
{ {