acdream/tests/AcDream.Core.Net.Tests/Packets/PacketCodecTests.cs
Erik c64bbf29e4 feat(net): PacketHeaderOptional + full packet decode + CRC verify (Phase 4.4)
Brings the codec to end-to-end: a raw UDP datagram goes in, a parsed
Packet comes out with verified CRC (both plain and ISAAC-encrypted
variants). Synthetic packets built inside tests round-trip through
TryDecode cleanly.

Added:
  - Packets/PacketHeaderOptional.cs: parses every flag-gated section
    that lives between the 20-byte header and the body fragments —
    AckSequence, RequestRetransmit (with count + array), RejectRetransmit,
    ServerSwitch, LoginRequest (tail slurp), WorldLoginRequest,
    ConnectResponse, CICMDCommand, TimeSync (double), EchoRequest (float),
    Flow (FlowBytes + FlowInterval). Records the raw consumed bytes into
    RawBytes so CalculateHash32 can hash them verbatim — AC's CRC requires
    hashing the optional section separately from the main header and the
    fragments.
  - Packets/Packet.cs: a record type bundling Header, Optional, Fragments,
    and the raw body bytes. Produced by the decoder, consumed by downstream
    handlers in Phase 4.5.
  - Packets/PacketCodec.cs: TryDecode(datagram, isaac?) that
      1. Unpacks the header,
      2. Bounds-checks DataSize against the buffer,
      3. Parses the optional section,
      4. If BlobFragments is set, walks the body tail as back-to-back
         MessageFragment.TryParse calls,
      5. Computes headerHash + optionalHash + fragmentHash,
      6. Verifies CRC:
         - Unencrypted: sum equals header.Checksum
         - Encrypted: (header.Checksum - headerHash) XOR payloadHash must
           equal the next ISAAC keystream word (which is consumed on match)
    Returns a PacketDecodeResult(Packet?, DecodeError) so callers can log
    and drop malformed packets instead of throwing.
  - Public helper PacketCodec.CalculateFragmentHash32 so tests (and later
    the encode path) can reuse the fragment-hash math.

Tests (7 new, 44 total in net project, 121 across both test projects):
  - Minimal valid packet with AckSequence optional, no fragments, plain
    checksum — verifies optional parse + CRC accept
  - Wrong checksum rejected
  - Buffer shorter than header → TooShort
  - Header DataSize > buffer → HeaderSizeExceedsBuffer
  - Packet with BlobFragments flag + one fragment: parses fragment and
    validates the full headerHash + fragmentHash equals wire checksum
  - Encrypted checksum ROUND TRIP: two ISAAC instances with same seed,
    one encodes the checksum key, one decodes — validates the
    (Header.Checksum - headerHash) XOR payloadHash == isaacNext contract
    byte-for-byte
  - Encrypted checksum with wrong key on the wire → rejected

Known limitation: the parser advances past WorldLoginRequest and
ConnectResponse their full 8 bytes whereas ACE "peeks" them (seek/reset).
The on-wire byte count is the same, only the read-position behavior
differs; any consumer that wanted to re-read those sections can do so
from Packet.BodyBytes.

Phase 4.5 (NetClient UDP pump + handshake state machine) next.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:24:29 +02:00

171 lines
6.5 KiB
C#

using AcDream.Core.Net.Cryptography;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Tests.Packets;
public class PacketCodecTests
{
/// <summary>
/// Build a complete packet byte array by hand, compute its correct
/// unencrypted CRC, and verify TryDecode parses it and accepts the CRC.
/// </summary>
[Fact]
public void TryDecode_MinimalValidPacket_UnencryptedChecksum_Accepted()
{
// Body = one optional section (AckSequence, 4 bytes) + zero fragments.
// ACK sequence in optional body = 0x12345678.
byte[] body = new byte[4];
body[0] = 0x78; body[1] = 0x56; body[2] = 0x34; body[3] = 0x12;
var header = new PacketHeader
{
Sequence = 1,
Flags = PacketHeaderFlags.AckSequence,
Id = 42,
Time = 100,
DataSize = (ushort)body.Length,
Iteration = 0,
};
// Compute the unencrypted checksum: headerHash + optionalHash.
// No fragments → fragmentHash = 0.
uint headerHash = header.CalculateHeaderHash32();
uint optionalHash = Hash32.Calculate(body);
header.Checksum = headerHash + optionalHash;
// Assemble the datagram.
byte[] datagram = new byte[PacketHeader.Size + body.Length];
header.Pack(datagram);
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
Assert.NotNull(result.Packet);
Assert.Equal(0x12345678u, result.Packet!.Optional.AckSequence);
}
[Fact]
public void TryDecode_WrongChecksum_Rejected()
{
byte[] body = new byte[4] { 1, 2, 3, 4 };
var header = new PacketHeader
{
Flags = PacketHeaderFlags.AckSequence,
DataSize = (ushort)body.Length,
Checksum = 0xDEADBEEFu, // garbage checksum
};
byte[] datagram = new byte[PacketHeader.Size + body.Length];
header.Pack(datagram);
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
Assert.Equal(PacketCodec.DecodeError.ChecksumMismatch, result.Error);
}
[Fact]
public void TryDecode_BufferShorterThanHeader_ReturnsTooShort()
{
var result = PacketCodec.TryDecode(new byte[19], inboundIsaac: null);
Assert.Equal(PacketCodec.DecodeError.TooShort, result.Error);
}
[Fact]
public void TryDecode_DataSizeExceedsBuffer_Rejected()
{
var header = new PacketHeader { DataSize = 1000 };
byte[] datagram = new byte[PacketHeader.Size + 10]; // only 10 body bytes available
header.Pack(datagram);
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
Assert.Equal(PacketCodec.DecodeError.HeaderSizeExceedsBuffer, result.Error);
}
[Fact]
public void TryDecode_PacketWithOneFragment_ParsesAndValidatesCRC()
{
// Body: no optional section + one 16+3 = 19-byte fragment (BlobFragments flag).
var fragHeader = new MessageFragmentHeader
{
Sequence = 1, Id = 0x80000001u, Count = 1,
TotalSize = 19, Index = 0, Queue = 5,
};
byte[] fragBytes = new byte[19];
fragHeader.Pack(fragBytes);
fragBytes[16] = 0xAA; fragBytes[17] = 0xBB; fragBytes[18] = 0xCC;
var header = new PacketHeader
{
Flags = PacketHeaderFlags.BlobFragments,
DataSize = (ushort)fragBytes.Length,
};
// Compute unencrypted checksum.
uint headerHash = header.CalculateHeaderHash32();
uint optionalHash = 0; // empty optional section
var frag = new MessageFragment(fragHeader, new byte[] { 0xAA, 0xBB, 0xCC });
uint fragmentHash = PacketCodec.CalculateFragmentHash32(frag);
header.Checksum = headerHash + optionalHash + fragmentHash;
byte[] datagram = new byte[PacketHeader.Size + fragBytes.Length];
header.Pack(datagram);
fragBytes.CopyTo(datagram.AsSpan(PacketHeader.Size));
var result = PacketCodec.TryDecode(datagram, inboundIsaac: null);
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
Assert.Single(result.Packet!.Fragments);
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, result.Packet.Fragments[0].Payload);
}
[Fact]
public void TryDecode_EncryptedChecksum_AcceptsMatchingIsaacKey()
{
// The ISAAC-encrypted form: the wire Checksum should equal
// headerHash + (isaacNext XOR payloadHash)
// so that VerifyCRC recovers isaacNext as the key and consumes it.
var body = new byte[4] { 1, 2, 3, 4 };
var header = new PacketHeader
{
Flags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum,
DataSize = (ushort)body.Length,
};
// Build the same isaac in two places: one to compute the expected
// checksum, one to pass to TryDecode. Both use seed 0x11223344.
var seed = new byte[] { 0x44, 0x33, 0x22, 0x11 };
var isaacForEncode = new IsaacRandom(seed);
var isaacForDecode = new IsaacRandom(seed);
uint headerHash = header.CalculateHeaderHash32();
uint payloadHash = Hash32.Calculate(body); // optional hash; no fragments
uint isaacKey = isaacForEncode.Next();
header.Checksum = headerHash + (isaacKey ^ payloadHash);
byte[] datagram = new byte[PacketHeader.Size + body.Length];
header.Pack(datagram);
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
var result = PacketCodec.TryDecode(datagram, isaacForDecode);
Assert.Equal(PacketCodec.DecodeError.None, result.Error);
}
[Fact]
public void TryDecode_EncryptedChecksum_WrongIsaacKey_Rejected()
{
var body = new byte[4] { 1, 2, 3, 4 };
var header = new PacketHeader
{
Flags = PacketHeaderFlags.AckSequence | PacketHeaderFlags.EncryptedChecksum,
DataSize = (ushort)body.Length,
Checksum = 0xDEADBEEFu, // wrong
};
byte[] datagram = new byte[PacketHeader.Size + body.Length];
header.Pack(datagram);
body.CopyTo(datagram.AsSpan(PacketHeader.Size));
// Decoder gets a fresh ISAAC; its first Next() definitely won't match 0xDEADBEEF.
var result = PacketCodec.TryDecode(datagram, new IsaacRandom(new byte[] { 1, 2, 3, 4 }));
Assert.Equal(PacketCodec.DecodeError.ChecksumMismatch, result.Error);
}
}