From 7fbb26092fd3a619a131ec1f60d25dee136f6cef Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:10:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D0=B0:=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B4=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20=D0=B1=D1=83=D1=84=D0=B5=D1=80=20=D0=B8=20=D1=80?= =?UTF-8?q?=D1=83=D0=BA=D0=BE=D0=BF=D0=BE=D0=B6=D0=B0=D1=82=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Это первый настоящий кусок сервера. Пока он ничего не умеет, кроме того что умеет правильно: принимать байты от чужого человека и не дать ему ничего сделать с нами. Формат кадра — четыре байта магии RSX1, версия, id пакета, длина тела и само тело. Магия стоит в начале, чтобы порт мог сразу ответить, а не ждать объяснений. Главное здесь — границы. Сервер живёт тем, что вынимает числа из сокетов чужих людей, и разница между дешёвой проверкой и аллокацией — это разница между «противник стоил нам пары тактов» и «противник стоил нам кучи». - Длина тела проверяется до того, как под неё что-то зарезервировано. Единственное, на что полезна вредная длина, — заставить сервер выделить память. - Varint ограничен пятью байтами для int и десятью для long. Без этого десять байт 0xFF превращают пятибайтовое целое в бесконечный цикл. - Длина строки ограничена до того, как под неё читают байты. - Декодер держит не больше одного максимального кадра плюс неполный заголовок. Неполные данные и испорченные — это разные вещи, и раньше это не было разделено: конверт, разрезанный сетью посреди varint, выглядел как испорченный и рвал соединение. IncompleteException означает «остальное уже в пути», ProtocolException — «потока больше не будет». TCP режет кадры почти всегда, так что первое поведение было бы отказом от большинства соединений. Позиция упакована одним varint, но доли у осей не равные: три поля по 26 бит это 78 бит, они не влезают в 64. X и Z получают по 26 бит с шагом 1/32 блока, Y — оставшиеся 12 бит целыми блоками. Со смещением на 2048 не вышло бы: для диапазона 0..4095 нужен тринадцатый бит, и это ровно тот бит, по которому значение читается обратно отрицательным. Первый прогон тестов нашёл все три ошибки сразу, две из них — в упаковке позиции. Проверено 17 сценариев, включая «по одному байту», пятьдесят кадров за одно чтение, чужую магию и объявленное тело в два гигабайта. Собирается и тестируется одним JDK, без Gradle и без сети, как и всё остальное в проекте. --- rsx-protocol/scripts/build.ps1 | 63 +++ rsx-protocol/scripts/test.ps1 | 42 ++ .../main/java/net/rustnotmc/protocol/Buf.java | 343 +++++++++++++++ .../java/net/rustnotmc/protocol/Frame.java | 144 +++++++ .../net/rustnotmc/protocol/FrameDecoder.java | 124 ++++++ .../protocol/IncompleteException.java | 19 + .../java/net/rustnotmc/protocol/Packets.java | 266 ++++++++++++ .../rustnotmc/protocol/ProtocolException.java | 21 + .../net/rustnotmc/protocol/ProtocolTest.java | 407 ++++++++++++++++++ 9 files changed, 1429 insertions(+) create mode 100644 rsx-protocol/scripts/build.ps1 create mode 100644 rsx-protocol/scripts/test.ps1 create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/Buf.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/Frame.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/FrameDecoder.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/IncompleteException.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java create mode 100644 rsx-protocol/src/main/java/net/rustnotmc/protocol/ProtocolException.java create mode 100644 rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java diff --git a/rsx-protocol/scripts/build.ps1 b/rsx-protocol/scripts/build.ps1 new file mode 100644 index 0000000..c34f6d7 --- /dev/null +++ b/rsx-protocol/scripts/build.ps1 @@ -0,0 +1,63 @@ +# Builds rsx-protocol into dist\. +# +# Only a JDK is required, same as the loader: javac with --release 17, no Gradle +# and no network. The protocol is deliberately dependency free so both ends can +# carry it and so its tests run without a game, a server or a socket library. +# +# NOTE: ASCII on purpose. Windows PowerShell 5.1 reads a .ps1 with no byte order +# mark using the system ANSI code page, which mangles UTF-8 and breaks the +# parser. Keep script messages English. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Dist = Join-Path $Repo 'dist' +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +if (-not (Get-Command javac -ErrorAction SilentlyContinue)) { + Write-Host ' A JDK (javac) is required.' -ForegroundColor Red + exit 1 +} + +New-Item -ItemType Directory -Force -Path $Dist, $Out | Out-Null + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +if ($mainFiles.Count -eq 0) { + Write-Host ' No sources found.' -ForegroundColor Red + exit 1 +} + +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { + Write-Host ' Protocol failed to compile.' -ForegroundColor Red + exit 1 +} +Say "protocol compiled ($($mainFiles.Count) files)" + +$manifest = Join-Path $Out 'manifest.txt' +@" +Manifest-Version: 1.0 +Implementation-Title: RustNotMC protocol +Implementation-Version: 0.1.0 +"@ | Set-Content -LiteralPath $manifest -Encoding ASCII + +$jar = Join-Path $Dist 'rsx-protocol.jar' +if (Test-Path -LiteralPath $jar) { Remove-Item -LiteralPath $jar -Force } +& jar cfm $jar $manifest -C $mainOut . +if ($LASTEXITCODE -ne 0) { + Write-Host ' Could not package the jar.' -ForegroundColor Red + exit 1 +} +Say "rsx-protocol.jar ($((Get-Item $jar).Length) bytes)" + +Write-Host '' +Write-Host ' Done:' -ForegroundColor Green +Write-Host " $jar" \ No newline at end of file diff --git a/rsx-protocol/scripts/test.ps1 b/rsx-protocol/scripts/test.ps1 new file mode 100644 index 0000000..eb01e50 --- /dev/null +++ b/rsx-protocol/scripts/test.ps1 @@ -0,0 +1,42 @@ +# Compiles rsx-protocol plus its tests and runs them. +# +# ASCII only, for the same reason as build.ps1. + +$ErrorActionPreference = 'Stop' + +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Out = Join-Path $Repo 'build' + +function Say($text) { Write-Host " $text" -ForegroundColor Cyan } + +New-Item -ItemType Directory -Force -Path $Out | Out-Null + +$mainOut = Join-Path $Out 'classes' +Remove-Item $mainOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $mainOut | Out-Null + +$mainFiles = @(Get-ChildItem (Join-Path $Repo 'src\main\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -d $mainOut $mainFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Main failed.' -ForegroundColor Red; exit 1 } + +$testOut = Join-Path $Out 'test-classes' +Remove-Item $testOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $testOut | Out-Null + +$testFiles = @(Get-ChildItem (Join-Path $Repo 'src\test\java') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName }) +& javac -encoding UTF-8 --release 17 -Xlint:-options -nowarn -cp $mainOut -d $testOut $testFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Test failed to compile.' -ForegroundColor Red; exit 1 } +Say "compiled $($mainFiles.Count) main + $($testFiles.Count) test files" + +& java -cp "$mainOut;$testOut" net.rustnotmc.protocol.ProtocolTest +if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host ' PROTOCOL TESTS FAILED' -ForegroundColor Red + exit 1 +} + +Write-Host '' +Write-Host ' Protocol tests passed.' -ForegroundColor Green \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Buf.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Buf.java new file mode 100644 index 0000000..0fac27d --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Buf.java @@ -0,0 +1,343 @@ +package net.rustnotmc.protocol; + +/** + * A growable byte buffer with the primitives the wire format is built from. + * + * Every read is bounded. A server spends its life taking numbers off sockets + * that belong to strangers, and the difference between a cheap check and an + * allocation is the difference between a hostile peer costing a few cycles and + * costing the heap. Nothing here trusts a length before the bytes behind it + * have actually arrived. + * + * Integers travel as zig-zag varints, so small negatives stay small. The whole + * point of that is worth one sentence: negative coordinates are ordinary in a + * world, and without zig-zag they would take five bytes each instead of one. + */ +public final class Buf { + + /** A varint of a 32 bit value never needs more than five bytes. */ + public static final int MAX_VAR_INT_BYTES = 5; + + /** A varint of a 64 bit value never needs more than ten bytes. */ + public static final int MAX_VAR_LONG_BYTES = 10; + + /** Default ceiling for a length-prefixed byte array or string. */ + public static final int DEFAULT_MAX_PAYLOAD = 2 * 1024 * 1024; + + private byte[] data; + private int writerIndex; + private int readerIndex; + + public Buf() { + this(64); + } + + public Buf(int capacity) { + if (capacity < 0) { + throw new IllegalArgumentException("negative capacity: " + capacity); + } + this.data = new byte[Math.max(8, capacity)]; + } + + public Buf(byte[] initial) { + this(initial.length); + this.writerIndex = initial.length; + System.arraycopy(initial, 0, this.data, 0, initial.length); + } + + // ------------------------------------------------------------- capacity + + public int readableBytes() { + return this.writerIndex - this.readerIndex; + } + + public int writerIndex() { + return this.writerIndex; + } + + /** + * Moves the write cursor, used to rewind a buffer that has nothing left in + * it. Refuses to move past what has actually been written, because a cursor + * past the end would hand out zeroes that were never sent. + */ + public Buf writerIndex(int index) { + if (index < 0 || index > this.data.length) { + throw new IndexOutOfBoundsException("writer index " + index + + " outside 0.." + this.data.length); + } + this.writerIndex = index; + if (this.readerIndex > index) { + this.readerIndex = index; + } + return this; + } + + public int readerIndex() { + return this.readerIndex; + } + + public Buf readerIndex(int index) { + if (index < 0 || index > this.writerIndex) { + throw new IndexOutOfBoundsException("reader index " + index + + " outside 0.." + this.writerIndex); + } + this.readerIndex = index; + return this; + } + + private void ensure(int extra) { + if (this.writerIndex + extra <= this.data.length) { + return; + } + int needed = this.writerIndex + extra; + int grown = Math.max(this.data.length * 2, needed); + byte[] bigger = new byte[grown]; + System.arraycopy(this.data, 0, bigger, 0, this.writerIndex); + this.data = bigger; + } + + // ---------------------------------------------------------------- write + + public Buf writeByte(int value) { + this.ensure(1); + this.data[this.writerIndex++] = (byte) value; + return this; + } + + public Buf writeBoolean(boolean value) { + return writeByte(value ? 1 : 0); + } + + public Buf writeShort(int value) { + this.ensure(2); + this.data[this.writerIndex++] = (byte) (value >>> 8); + this.data[this.writerIndex++] = (byte) value; + return this; + } + + public Buf writeInt(int value) { + this.ensure(4); + this.data[this.writerIndex++] = (byte) (value >>> 24); + this.data[this.writerIndex++] = (byte) (value >>> 16); + this.data[this.writerIndex++] = (byte) (value >>> 8); + this.data[this.writerIndex++] = (byte) value; + return this; + } + + public Buf writeLong(long value) { + this.ensure(8); + for (int shift = 56; shift >= 0; shift -= 8) { + this.data[this.writerIndex++] = (byte) (value >>> shift); + } + return this; + } + + public Buf writeFloat(float value) { + return writeInt(Float.floatToIntBits(value)); + } + + public Buf writeDouble(double value) { + return writeLong(Double.doubleToLongBits(value)); + } + + /** Writes a zig-zag encoded varint. */ + public Buf writeVarInt(int value) { + int zig = (value << 1) ^ (value >> 31); + while (true) { + if ((zig & 0xFFFFFF80) == 0) { + return writeByte(zig); + } + writeByte((zig & 0x7F) | 0x80); + zig >>>= 7; + } + } + + /** Writes a zig-zag encoded varint over a long. */ + public Buf writeVarLong(long value) { + long zig = (value << 1) ^ (value >> 63); + while (true) { + if ((zig & 0xFFFFFFFFFFFFFF80L) == 0L) { + return writeByte((int) zig); + } + writeByte((int) ((zig & 0x7F) | 0x80)); + zig >>>= 7; + } + } + + public Buf writeBytes(byte[] value) { + return writeBytes(value, 0, value.length); + } + + public Buf writeBytes(byte[] value, int offset, int length) { + this.ensure(length); + System.arraycopy(value, offset, this.data, this.writerIndex, length); + this.writerIndex += length; + return this; + } + + public Buf writeString(String value) { + byte[] encoded = value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + writeVarInt(encoded.length); + return writeBytes(encoded); + } + + /** + * Writes a block position as one varint. + * + * The axes do not get equal shares of the long. Three 26 bit fields would be 78 + * bits and simply do not fit, so the split follows what a world actually needs: + * X and Z take 26 bits each, +/-33 million blocks at 1/32 block resolution, and + * Y takes the remaining 12 bits as whole blocks in two's complement, covering + * -2048 to 2047, which is the whole vertical range the format supports. + * + * Y is stored signed and not offset by 2048 on purpose: an offset would need 13 + * bits to hold 0..4095, and the twelfth bit is exactly the one that decides + * whether a value reads back negative. + * + * Every mask is parenthesised on purpose: in Java, << binds tighter than + * &, so "a & 0x3FFFFFF << 12" silently masks the wrong value and + * throws away the low bits of the axis. + */ +public Buf writePosition(double x, double y, double z) { + long packed = ((long) (x * 32.0D) & 0x3FFFFFFL) << 38; + packed |= ((long) (int) y & 0xFFFL) << 26; + packed |= (long) (z * 32.0D) & 0x3FFFFFFL; + return writeVarLong(packed); + } + + /** Reads a position written by {@link #writePosition}. */ + public double[] readPosition() { + long packed = readVarLong(); + // a field of n bits at offset k is sign extended by shifting left far + // enough to land its top bit at 63, then arithmetically shifting right + double x = (double) (packed << 0 >> 38) / 32.0D; + double y = (double) (packed << 26 >> 52); + double z = (double) (packed << 38 >> 38) / 32.0D; + return new double[]{x, y, z}; + } + + // ----------------------------------------------------------------- read + + public int readUnsignedByte() { + require(1); + return this.data[this.readerIndex++] & 0xFF; + } + + public boolean readBoolean() { + return readUnsignedByte() != 0; + } + + public int readShort() { + require(2); + int high = this.data[this.readerIndex++] & 0xFF; + int low = this.data[this.readerIndex++] & 0xFF; + return (high << 8) | low; + } + + public int readInt() { + require(4); + int value = ((this.data[this.readerIndex++] & 0xFF) << 24) + | ((this.data[this.readerIndex++] & 0xFF) << 16) + | ((this.data[this.readerIndex++] & 0xFF) << 8) + | (this.data[this.readerIndex++] & 0xFF); + return value; + } + + public long readLong() { + require(8); + long value = 0L; + for (int i = 0; i < 8; i++) { + value = (value << 8) | (this.data[this.readerIndex++] & 0xFF); + } + return value; + } + + public float readFloat() { + return Float.intBitsToFloat(readInt()); + } + + public double readDouble() { + return Double.longBitsToDouble(readLong()); + } + + /** + * Reads a varint, refusing anything longer than it has to be. + * + * Without the byte cap a peer can send ten bytes of 0xFF and turn a five + * byte integer into an endless loop. + */ + public int readVarInt() { + int result = 0; + for (int shift = 0; shift < MAX_VAR_INT_BYTES * 7; shift += 7) { + byte b = (byte) readUnsignedByte(); + result |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return (result >>> 1) ^ -(result & 1); + } + } + throw new ProtocolException("varint is longer than " + MAX_VAR_INT_BYTES + " bytes"); + } + + public long readVarLong() { + long result = 0L; + for (int shift = 0; shift < MAX_VAR_LONG_BYTES * 7; shift += 7) { + byte b = (byte) readUnsignedByte(); + result |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return (result >>> 1) ^ -(result & 1); + } + } + throw new ProtocolException("varlong is longer than " + MAX_VAR_LONG_BYTES + " bytes"); + } + + public byte[] readBytes(int length) { + if (length < 0) { + throw new ProtocolException("negative length: " + length); + } + require(length); + byte[] value = new byte[length]; + System.arraycopy(this.data, this.readerIndex, value, 0, length); + this.readerIndex += length; + return value; + } + + public String readString(int maxLength) { + int length = readVarInt(); + if (length < 0 || length > maxLength) { + throw new ProtocolException("string length " + length + " is out of range"); + } + require(length); + String value = new String(this.data, this.readerIndex, length, + java.nio.charset.StandardCharsets.UTF_8); + this.readerIndex += length; + return value; + } + + /** Bytes written but not yet read, as a copy. */ + public byte[] toByteArray() { + byte[] value = new byte[this.writerIndex]; + System.arraycopy(this.data, 0, value, 0, this.writerIndex); + return value; + } + + /** A reader over a slice of what has been written. */ + public Buf reader() { + Buf view = new Buf(Math.max(8, this.writerIndex - this.readerIndex)); + view.writeBytes(this.data, this.readerIndex, this.writerIndex - this.readerIndex); + view.readerIndex = 0; + return view; + } + + private void require(int count) { + if (this.readerIndex + count > this.writerIndex) { + throw new IncompleteException("wanted " + count + " byte(s) but only " + + readableBytes() + " remain"); + } + } + + @Override + public String toString() { + return "Buf[read=" + this.readerIndex + ", written=" + this.writerIndex + + ", capacity=" + this.data.length + "]"; + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Frame.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Frame.java new file mode 100644 index 0000000..70768ec --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Frame.java @@ -0,0 +1,144 @@ +package net.rustnotmc.protocol; + +/** + * One message: an envelope plus the packet body. + * + * The wire layout is deliberately dull and fixed on both ends: + * + *
+ *   magic         4 bytes   0x52535831, "RSX1"
+ *   version       varint    protocol version, refused if it is not ours
+ *   packetId      varint    what the body means
+ *   bodyLength    varint    bytes of body that follow
+ *   body          bytes
+ * 
+ * + * The magic sits in front so a port can answer immediately instead of waiting + * to be told it is speaking nonsense. The declared body length is checked + * against the maximum before a single byte is reserved, because the only thing + * a malicious length is good for is making the server allocate. + */ +public final class Frame { + + /** "RSX1" in ASCII, as a big endian int. */ + public static final int MAGIC = 0x52535831; + + /** The version this build speaks. */ + public static final int VERSION = 1; + + private final int version; + private final int packetId; + private final byte[] body; + + public Frame(int packetId, byte[] body) { + this(VERSION, packetId, body); + } + + public Frame(int version, int packetId, byte[] body) { + if (body == null) { + throw new IllegalArgumentException("body is null"); + } + this.version = version; + this.packetId = packetId; + this.body = body; + } + + public int version() { + return this.version; + } + + public int packetId() { + return this.packetId; + } + + public byte[] body() { + return this.body; + } + + public int size() { + return 4 + MAX_ENVELOPE + this.body.length; + } + + /** Worst case envelope: magic plus four varints at their longest. */ + public static final int MAX_ENVELOPE = 4 + + Buf.MAX_VAR_INT_BYTES // version + + Buf.MAX_VAR_INT_BYTES // packet id + + Buf.MAX_VAR_INT_BYTES; // body length + + public Buf encode() { + Buf buf = new Buf(size()); + buf.writeInt(MAGIC); + buf.writeVarInt(this.version); + buf.writeVarInt(this.packetId); + buf.writeVarInt(this.body.length); + buf.writeBytes(this.body); + return buf; + } + + /** + * Reads one frame from the front of a buffer. + * + * Returns null when the buffer does not hold a whole frame yet, so a + * caller feeding it a socket can keep the remainder and come back when more + * arrives. Throws as soon as it can tell the stream is impossible, rather + * than waiting forever for bytes that will make it valid. + */ + public static Frame decode(Buf in, int maxBody) { + if (in.readableBytes() < 4) { + return null; + } + int start = in.readerIndex(); + int magic = peekInt(in); + if (magic != MAGIC) { + throw new ProtocolException(String.format( + "not a RustNotMC stream: magic is 0x%08X", Integer.valueOf(magic))); + } + in.readerIndex(start + 4); + + int version; + int packetId; + int length; + try { + version = in.readVarInt(); + packetId = in.readVarInt(); + length = in.readVarInt(); + } catch (IncompleteException more) { + // TCP cut the envelope; the rest is on its way + in.readerIndex(start); + return null; + } catch (ProtocolException malformed) { + throw new ProtocolException("malformed envelope: " + malformed.getMessage(), malformed); + } + + if (version != VERSION) { + throw new ProtocolException("protocol version " + version + + ", this build speaks " + VERSION); + } + if (length < 0 || length > maxBody) { + throw new ProtocolException("declared body of " + length + + " bytes is outside the limit of " + maxBody); + } + if (in.readableBytes() < length) { + in.readerIndex(start); + return null; + } + byte[] body = in.readBytes(length); + return new Frame(version, packetId, body); + } + + private static int peekInt(Buf in) { + int r = in.readerIndex(); + int value = ((in.readUnsignedByte() & 0xFF) << 24) + | ((in.readUnsignedByte() & 0xFF) << 16) + | ((in.readUnsignedByte() & 0xFF) << 8) + | (in.readUnsignedByte() & 0xFF); + in.readerIndex(r); + return value; + } + + @Override + public String toString() { + return "Frame[id=" + this.packetId + ", version=" + this.version + + ", body=" + this.body.length + "B]"; + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/FrameDecoder.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/FrameDecoder.java new file mode 100644 index 0000000..11886e3 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/FrameDecoder.java @@ -0,0 +1,124 @@ +package net.rustnotmc.protocol; + +/** + * Turns a byte stream into frames, one call per whatever the socket had. + * + * TCP hands over bytes, not messages: one read may be half a frame, or nine + * and a half. The decoder keeps whatever it could not use yet and returns only + * whole frames, so the layer above sees a stream of packets and never thinks + * about segmentation again. + * + * The one thing it will not do is buffer without limit. A peer that announces + * a body of a gigabyte and then sends nothing must not be able to hold a + * gigabyte of this process's heap, so a body larger than the configured + * ceiling is refused the moment its length is read. + */ +public final class FrameDecoder { + + private final Buf pending; + private final int maxBody; + + private boolean poisoned; + private int framesRead; + private long bytesRead; + + public FrameDecoder() { + this(Buf.DEFAULT_MAX_PAYLOAD); + } + + public FrameDecoder(int maxBody) { + if (maxBody <= 0) { + throw new IllegalArgumentException("max body must be positive: " + maxBody); + } + this.pending = new Buf(); + this.maxBody = maxBody; + } + + public int maxBody() { + return this.maxBody; + } + + public int bufferedBytes() { + return this.pending.readableBytes(); + } + + public int framesRead() { + return this.framesRead; + } + + public long bytesRead() { + return this.bytesRead; + } + + /** + * Feeds freshly read bytes in. + * + * Returns every frame that is now complete. Throws on a stream that can no + * longer become valid; a caller should treat that as the end of the + * connection rather than something to retry. + */ + public java.util.List offer(byte[] chunk, int offset, int length) { + if (this.poisoned) { + throw new ProtocolException("decoder was poisoned by an earlier failure"); + } + if (chunk == null) { + throw new IllegalArgumentException("chunk is null"); + } + if (length < 0 || offset < 0 || offset + length > chunk.length) { + throw new IllegalArgumentException("chunk range is out of bounds"); + } + if (length == 0) { + return java.util.Collections.emptyList(); + } + this.pending.writeBytes(chunk, offset, length); + this.bytesRead += length; + + // refuse to sit on more than one maximum frame plus a partial header + int ceiling = this.maxBody + Frame.MAX_ENVELOPE; + if (this.pending.readableBytes() > ceiling) { + this.poisoned = true; + throw new ProtocolException("buffered " + this.pending.readableBytes() + + " bytes without a complete frame, limit is " + ceiling); + } + + java.util.List out = new java.util.ArrayList(); + while (true) { + Frame frame; + try { + frame = Frame.decode(this.pending, this.maxBody); + } catch (ProtocolException bad) { + this.poisoned = true; + throw bad; + } + if (frame == null) { + break; + } + this.framesRead++; + out.add(frame); + } + // hand the tail back to the next read instead of growing the buffer + this.compact(); + return out; + } + + private void compact() { + // keep the original object: callers may be holding it, and replacing the + // field under them would be a nasty surprise + int left = this.pending.readableBytes(); + int from = this.pending.readerIndex(); + if (left == 0) { + this.pending.readerIndex(0); + this.pending.writerIndex(0); + return; + } + if (from == 0) { + return; + } + byte[] all = this.pending.toByteArray(); + byte[] tail = new byte[left]; + System.arraycopy(all, from, tail, 0, left); + this.pending.readerIndex(0); + this.pending.writerIndex(0); + this.pending.writeBytes(tail); + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/IncompleteException.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/IncompleteException.java new file mode 100644 index 0000000..e14a5f5 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/IncompleteException.java @@ -0,0 +1,19 @@ +package net.rustnotmc.protocol; + +/** + * Raised when a buffer does not yet hold the bytes a read asked for. + * + * Separate from {@link ProtocolException} because the two mean opposite things. + * Running out of bytes is normal: TCP cut a frame in half and the rest is on + * its way. A malformed varint is not normal and the stream is finished. If the + * decoder treated both as fatal it would drop every connection that happened to + * span two packets, which on a real network is most of them. + */ +public final class IncompleteException extends ProtocolException { + + private static final long serialVersionUID = 1L; + + public IncompleteException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java new file mode 100644 index 0000000..b5a8947 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -0,0 +1,266 @@ +package net.rustnotmc.protocol; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The packets that start a connection, and the table that maps them to ids. + * + * The ids are written down here rather than generated, because they end up in + * every capture and every log line forever. Renumbering one is a protocol + * change and should look like a deliberate act, not a side effect of moving a + * class around. + * + * The order of a connection is fixed and short: + * + *
+ *   client -> Hello        protocol version, what the client is
+ *   server -> HelloAck     accepted version, server name, motd, online count
+ *   client -> Auth         account and proof
+ *   server -> AuthAck      session token, or a reason it was refused
+ *   client -> Login        the session token
+ *   server -> LoginAck     player id and entity id, or a reason
+ *   either -> Disconnect   a human readable reason
+ * 
+ */ +public final class Packets { + + private Packets() { + } + + /** Longest a name, motd or reason may be, in bytes. */ + public static final int MAX_TEXT = 512; + + /** Longest a handshake proof may be, in bytes. */ + public static final int MAX_PROOF = 256; + + /** Longest the handshake body may be, in bytes. */ + public static final int MAX_HANDSHAKE_BODY = 8 * 1024; + + public static final int HELLO = 0x01; + public static final int HELLO_ACK = 0x02; + public static final int AUTH = 0x03; + public static final int AUTH_ACK = 0x04; + public static final int LOGIN = 0x05; + public static final int LOGIN_ACK = 0x06; + public static final int DISCONNECT = 0x7F; + + /** The client introducing itself. */ + public static final class Hello { + public final int protocolVersion; + public final String clientName; + public final int clientVersion; + + public Hello(int protocolVersion, String clientName, int clientVersion) { + this.protocolVersion = protocolVersion; + this.clientName = clientName; + this.clientVersion = clientVersion; + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeVarInt(this.protocolVersion); + buf.writeString(this.clientName); + buf.writeVarInt(this.clientVersion); + return buf; + } + + public static Hello read(Buf in) { + return new Hello(in.readVarInt(), in.readString(MAX_TEXT), in.readVarInt()); + } + } + + /** The server accepting, and describing itself. */ + public static final class HelloAck { + public final int protocolVersion; + public final String serverName; + public final String motd; + public final int onlinePlayers; + public final int maxPlayers; + + public HelloAck(int protocolVersion, String serverName, String motd, + int onlinePlayers, int maxPlayers) { + this.protocolVersion = protocolVersion; + this.serverName = serverName; + this.motd = motd; + this.onlinePlayers = onlinePlayers; + this.maxPlayers = maxPlayers; + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeVarInt(this.protocolVersion); + buf.writeString(this.serverName); + buf.writeString(this.motd); + buf.writeVarInt(this.onlinePlayers); + buf.writeVarInt(this.maxPlayers); + return buf; + } + + public static HelloAck read(Buf in) { + return new HelloAck(in.readVarInt(), in.readString(MAX_TEXT), + in.readString(MAX_TEXT), in.readVarInt(), in.readVarInt()); + } + } + + /** The client claiming an account. */ + public static final class Auth { + public final String account; + public final String proof; + + public Auth(String account, String proof) { + this.account = account; + this.proof = proof; + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeString(this.account); + buf.writeString(this.proof); + return buf; + } + + public static Auth read(Buf in) { + return new Auth(in.readString(MAX_TEXT), in.readString(MAX_PROOF)); + } + } + + /** Whether the account was accepted, and on what terms. */ + public static final class AuthAck { + public final boolean accepted; + public final String reason; + public final String token; + public final long expiresAt; + + private AuthAck(boolean accepted, String reason, String token, long expiresAt) { + this.accepted = accepted; + this.reason = reason; + this.token = token; + this.expiresAt = expiresAt; + } + + public static AuthAck ok(String token, long expiresAt) { + return new AuthAck(true, "", token, expiresAt); + } + + public static AuthAck refused(String reason) { + return new AuthAck(false, reason, "", 0L); + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeBoolean(this.accepted); + buf.writeString(this.reason); + if (this.accepted) { + buf.writeString(this.token); + buf.writeLong(this.expiresAt); + } + return buf; + } + + public static AuthAck read(Buf in) { + if (!in.readBoolean()) { + return refused(in.readString(MAX_TEXT)); + } + return ok(in.readString(MAX_TEXT), in.readLong()); + } + } + + /** The client redeeming the token it was just handed. */ + public static final class Login { + public final String account; + public final String token; + + public Login(String account, String token) { + this.account = account; + this.token = token; + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeString(this.account); + buf.writeString(this.token); + return buf; + } + + public static Login read(Buf in) { + return new Login(in.readString(MAX_TEXT), in.readString(MAX_TEXT)); + } + } + + /** Whether the player is in. */ + public static final class LoginAck { + public final boolean accepted; + public final String reason; + public final int playerId; + public final int entityId; + + private LoginAck(boolean accepted, String reason, int playerId, int entityId) { + this.accepted = accepted; + this.reason = reason; + this.playerId = playerId; + this.entityId = entityId; + } + + public static LoginAck ok(int playerId, int entityId) { + return new LoginAck(true, "", playerId, entityId); + } + + public static LoginAck refused(String reason) { + return new LoginAck(false, reason, -1, -1); + } + + public Buf write() { + Buf buf = new Buf(); + buf.writeBoolean(this.accepted); + buf.writeString(this.reason); + if (this.accepted) { + buf.writeVarInt(this.playerId); + buf.writeVarInt(this.entityId); + } + return buf; + } + + public static LoginAck read(Buf in) { + if (!in.readBoolean()) { + return refused(in.readString(MAX_TEXT)); + } + return ok(in.readVarInt(), in.readVarInt()); + } + } + + /** A reason, said out loud, before the connection goes away. */ + public static final class Disconnect { + public final String reason; + + public Disconnect(String reason) { + this.reason = reason; + } + + public Buf write() { + return new Buf().writeString(this.reason); + } + + public static Disconnect read(Buf in) { + return new Disconnect(in.readString(MAX_TEXT)); + } + } + + /** + * Every packet id this build knows, and what it decodes into. + * + * Written as plain data so that adding a packet is one line and an unknown + * id is a loud error instead of a silent skip. + */ + public static Map names() { + Map map = new LinkedHashMap(); + map.put(Integer.valueOf(HELLO), "Hello"); + map.put(Integer.valueOf(HELLO_ACK), "HelloAck"); + map.put(Integer.valueOf(AUTH), "Auth"); + map.put(Integer.valueOf(AUTH_ACK), "AuthAck"); + map.put(Integer.valueOf(LOGIN), "Login"); + map.put(Integer.valueOf(LOGIN_ACK), "LoginAck"); + map.put(Integer.valueOf(DISCONNECT), "Disconnect"); + return map; + } +} \ No newline at end of file diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/ProtocolException.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ProtocolException.java new file mode 100644 index 0000000..7f207f5 --- /dev/null +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/ProtocolException.java @@ -0,0 +1,21 @@ +package net.rustnotmc.protocol; + +/** + * Raised when bytes on the wire cannot mean what the sender intended. + * + * Unchecked on purpose: every one of these ends the connection, and there is + * nothing a caller could do about it other than close. Making it checked would + * only add a catch block that rethrows. + */ +public class ProtocolException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ProtocolException(String message) { + super(message); + } + + public ProtocolException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java new file mode 100644 index 0000000..78eb20f --- /dev/null +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java @@ -0,0 +1,407 @@ +package net.rustnotmc.protocol; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * Tests for the wire format. + * + * These are not example code. Every case here is something a hostile or simply + * broken peer can actually do, and the point of the test is that the server + * survives it without an OutOfMemoryError, an endless loop or a silent + * misinterpretation. + */ +public final class ProtocolTest { + + private static int checks; + private static int failed; + private static final List failures = new ArrayList(); + + public static void main(String[] args) { + run("varint round trip", new Runnable() { + public void run() { testVarInt(); } + }); + run("varint rejects overlong input", new Runnable() { + public void run() { testVarIntOverlong(); } + }); + run("negative numbers stay small", new Runnable() { + public void run() { testVarIntZigZag(); } + }); + run("varlong round trip", new Runnable() { + public void run() { testVarLong(); } + }); + run("fixed width round trip", new Runnable() { + public void run() { testFixedWidth(); } + }); + run("string round trip incl. non ascii", new Runnable() { + public void run() { testString(); } + }); + run("string length is bounded", new Runnable() { + public void run() { testStringBound(); } + }); + run("reading past the end fails", new Runnable() { + public void run() { testUnderflow(); } + }); + run("position survives the round trip", new Runnable() { + public void run() { testPosition(); } + }); + run("frame round trip", new Runnable() { + public void run() { testFrame(); } + }); + run("partial frame decodes to nothing", new Runnable() { + public void run() { testPartialFrame(); } + }); + run("foreign magic is refused", new Runnable() { + public void run() { testWrongMagic(); } + }); + run("oversized declared body is refused", new Runnable() { + public void run() { testOversizedClaim(); } + }); + run("byte at a time still works", new Runnable() { + public void run() { testSplitEverywhere(); } + }); + run("many frames in one read", new Runnable() { + public void run() { testBatch(); } + }); + run("handshake packets round trip", new Runnable() { + public void run() { testHandshake(); } + }); + run("a lying length cannot stall the heap", new Runnable() { + public void run() { testLyingLength(); } + }); + + System.out.println(); + for (int i = 0; i < failures.size(); i++) { + System.out.println(" FAIL " + failures.get(i)); + } + System.out.println(); + System.out.println("checks: " + checks + ", failed: " + failed); + if (failed != 0) { + System.out.println("FAILED"); + System.exit(1); + } + System.out.println("OK"); + } + + private static void run(String name, Runnable body) { + checks++; + int before = failed; + try { + body.run(); + } catch (Throwable t) { + failed++; + failures.add(name + ": threw " + t); + t.printStackTrace(); + return; + } + if (failed == before) { + System.out.println(" ok " + name); + } else { + failures.add(name + ": assertions failed"); + } + } + + private static void eq(Object what, Object expected, Object actual) { + // arrays compare by reference under equals(), which is never what a + // caller means when it hands over a body + if (expected instanceof byte[] && actual instanceof byte[]) { + if (!java.util.Arrays.equals((byte[]) expected, (byte[]) actual)) { + failed++; + failures.add(what + ": byte arrays differ"); + } + return; + } + if (expected == null ? actual != null : !expected.equals(actual)) { + failed++; + failures.add(what + ": expected " + expected + " but got " + actual); + } + } + + private static void yes(String what, boolean condition) { + if (!condition) { + failed++; + failures.add(what); + } + } + + private static void refuses(String what, Runnable body) { + try { + body.run(); + failed++; + failures.add(what + ": accepted something it should have refused"); + } catch (ProtocolException expected) { + // exactly what we want + } catch (Throwable other) { + failed++; + failures.add(what + ": threw " + other + " instead of ProtocolException"); + } + } + + // ------------------------------------------------------------- the tests + + private static void testVarInt() { + int[] values = {0, 1, -1, 127, 128, -128, 12345, -12345, + Integer.MAX_VALUE, Integer.MIN_VALUE}; + for (int i = 0; i < values.length; i++) { + Buf buf = new Buf(); + buf.writeVarInt(values[i]); + eq("varint " + values[i], Integer.valueOf(values[i]), + Integer.valueOf(new Buf(buf.toByteArray()).readVarInt())); + } + } + + private static void testVarIntOverlong() { + // ten bytes of 0xFF: a five byte value cannot be this long + byte[] evil = new byte[10]; + java.util.Arrays.fill(evil, (byte) 0xFF); + refuses("overlong varint", new Runnable() { + public void run() { new Buf(evil).readVarInt(); } + }); + } + + private static void testVarIntZigZag() { + Buf small = new Buf(); + small.writeVarInt(-1); + yes("minus one is one byte, not five", small.writerIndex() == 1); + + Buf zero = new Buf(); + zero.writeVarInt(0); + yes("zero is one byte", zero.writerIndex() == 1); + + Buf positive = new Buf(); + positive.writeVarInt(63); + Buf negative = new Buf(); + negative.writeVarInt(-63); + eq("63 and -63 cost the same", Integer.valueOf(positive.writerIndex()), + Integer.valueOf(negative.writerIndex())); + } + + private static void testVarLong() { + long[] values = {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, -1234567890123L}; + for (int i = 0; i < values.length; i++) { + Buf buf = new Buf(); + buf.writeVarLong(values[i]); + eq("varlong " + values[i], Long.valueOf(values[i]), + Long.valueOf(new Buf(buf.toByteArray()).readVarLong())); + } + } + + private static void testFixedWidth() { + Buf buf = new Buf(); + buf.writeBoolean(true); + buf.writeByte(0x7F); + buf.writeShort(0xBEEF); + buf.writeInt(-559038737); + buf.writeLong(-1234567890123L); + buf.writeFloat(0.5F); + buf.writeDouble(-2.25D); + + Buf read = new Buf(buf.toByteArray()); + yes("boolean", read.readBoolean()); + eq("byte", Integer.valueOf(0x7F), Integer.valueOf(read.readUnsignedByte())); + eq("short", Integer.valueOf(0xBEEF), Integer.valueOf(read.readShort())); + eq("int", Integer.valueOf(-559038737), Integer.valueOf(read.readInt())); + eq("long", Long.valueOf(-1234567890123L), Long.valueOf(read.readLong())); + eq("float", Float.valueOf(0.5F), Float.valueOf(read.readFloat())); + eq("double", Double.valueOf(-2.25D), Double.valueOf(read.readDouble())); + eq("nothing left", Integer.valueOf(0), Integer.valueOf(read.readableBytes())); + } + + private static void testString() { + String[] values = {"", "player", "Игрок", "a name with spaces", "~!@#$%^&*()"}; + for (int i = 0; i < values.length; i++) { + Buf buf = new Buf(); + buf.writeString(values[i]); + eq("string " + values[i], values[i], + new Buf(buf.toByteArray()).readString(Packets.MAX_TEXT)); + } + } + + private static void testStringBound() { + // announces a huge string and then does not send it + Buf buf = new Buf(); + buf.writeVarInt(0x7FFFFFF0); + refuses("string longer than the limit", new Runnable() { + public void run() { buf.readString(Packets.MAX_TEXT); } + }); + } + + private static void testUnderflow() { + refuses("reading past the end", new Runnable() { + public void run() { new Buf(new byte[2]).readInt(); } + }); + } + + private static void testPosition() { + // Y is deliberately whole blocks: it only has 12 bits, so it is stored + // as a signed block number and a fractional Y is quantised away + double[][] positions = { + {0.0D, 0.0D, 0.0D}, + {1.5D, -64.0D, 300.25D}, + {-1234.5D, 319.0D, -4096.75D}, + {0.03125D, -2048.0D, 0.03125D}, + {0.03125D, 2047.0D, 0.03125D}, + }; + for (int i = 0; i < positions.length; i++) { + double[] p = positions[i]; + Buf buf = new Buf(); + buf.writePosition(p[0], p[1], p[2]); + double[] back = new Buf(buf.toByteArray()).readPosition(); + eq("position x " + p[0], Double.valueOf(p[0]), Double.valueOf(back[0])); + eq("position y " + p[1], Double.valueOf(p[1]), Double.valueOf(back[1])); + eq("position z " + p[2], Double.valueOf(p[2]), Double.valueOf(back[2])); + } + + // and the quantisation is a documented property, not an accident + Buf fractional = new Buf(); + fractional.writePosition(1.0D, 64.5D, 2.0D); + double[] cut = new Buf(fractional.toByteArray()).readPosition(); + eq("fractional Y rounds to a whole block", Double.valueOf(64.0), Double.valueOf(cut[1])); + } + + private static void testFrame() { + byte[] body = new byte[300]; + new Random(7).nextBytes(body); + Frame frame = new Frame(Packets.HELLO, body); + Buf encoded = frame.encode(); + Frame back = Frame.decode(encoded, 1024 * 1024); + yes("frame came back", back != null); + eq("frame id", Integer.valueOf(Packets.HELLO), Integer.valueOf(back.packetId())); + eq("frame version", Integer.valueOf(Frame.VERSION), Integer.valueOf(back.version())); + yes("frame body", java.util.Arrays.equals(body, back.body())); + } + + private static void testPartialFrame() { + Buf encoded = new Frame(Packets.LOGIN, new byte[64]).encode(); + byte[] all = encoded.toByteArray(); + // a fresh decoder per prefix: the question is whether any prefix short of + // the whole frame decodes to nothing, not what happens when prefixes are + // concatenated into one stream + for (int cut = 0; cut < all.length - 1; cut++) { + FrameDecoder decoder = new FrameDecoder(); + List got = decoder.offer(all, 0, cut); + yes("nothing complete after " + cut + " bytes", got.isEmpty()); + } + FrameDecoder whole = new FrameDecoder(); + for (int cut = 0; cut < all.length; cut++) { + whole.offer(all, cut, 1); + } + eq("exactly one frame once it all arrived", Integer.valueOf(1), + Integer.valueOf(whole.framesRead())); + } + + private static void testWrongMagic() { + byte[] notOurs = new byte[] {0x7F, 'E', 'L', 'F', 0x01, 0x00, 0x00, 0x00}; + FrameDecoder decoder = new FrameDecoder(); + refuses("foreign magic", new Runnable() { + public void run() { decoder.offer(notOurs, 0, notOurs.length); } + }); + } + + private static void testOversizedClaim() { + // valid magic and version, but a body of two gigabytes + Buf buf = new Buf(); + buf.writeInt(Frame.MAGIC); + buf.writeVarInt(Frame.VERSION); + buf.writeVarInt(Packets.HELLO); + buf.writeVarInt(Integer.MAX_VALUE); + byte[] bytes = buf.toByteArray(); + FrameDecoder decoder = new FrameDecoder(4096); + refuses("oversized claim", new Runnable() { + public void run() { decoder.offer(bytes, 0, bytes.length); } + }); + } + + private static void testSplitEverywhere() { + List expected = new ArrayList(); + expected.add(new Frame(Packets.HELLO, body("hello"))); + expected.add(new Frame(Packets.HELLO_ACK, body("hello-ack"))); + expected.add(new Frame(Packets.AUTH, body("auth"))); + expected.add(new Frame(Packets.AUTH_ACK, body("auth-ack"))); + expected.add(new Frame(Packets.LOGIN, body("login"))); + expected.add(new Frame(Packets.LOGIN_ACK, body("login-ack"))); + expected.add(new Frame(Packets.DISCONNECT, body("disconnect"))); + + Buf all = new Buf(); + for (int i = 0; i < expected.size(); i++) { + all.writeBytes(expected.get(i).encode().toByteArray()); + } + byte[] wire = all.toByteArray(); + + FrameDecoder decoder = new FrameDecoder(); + List got = new ArrayList(); + for (int i = 0; i < wire.length; i++) { + got.addAll(decoder.offer(wire, i, 1)); + } + eq("every frame arrived", Integer.valueOf(expected.size()), Integer.valueOf(got.size())); + for (int i = 0; i < expected.size() && i < got.size(); i++) { + eq("frame " + i + " id", Integer.valueOf(expected.get(i).packetId()), + Integer.valueOf(got.get(i).packetId())); + yes("frame " + i + " body", + java.util.Arrays.equals(expected.get(i).body(), got.get(i).body())); + } + eq("nothing left buffered", Integer.valueOf(0), Integer.valueOf(decoder.bufferedBytes())); + } + + private static void testBatch() { + FrameDecoder decoder = new FrameDecoder(); + List all = new ArrayList(); + Buf wire = new Buf(); + for (int i = 0; i < 50; i++) { + Frame frame = new Frame(Packets.LOGIN, ("frame-" + i).getBytes()); + all.add(frame); + wire.writeBytes(frame.encode().toByteArray()); + } + byte[] bytes = wire.toByteArray(); + List got = decoder.offer(bytes, 0, bytes.length); + eq("all fifty in one read", Integer.valueOf(50), Integer.valueOf(got.size())); + eq("counter agrees", Integer.valueOf(50), Integer.valueOf(decoder.framesRead())); + for (int i = 0; i < got.size(); i++) { + eq("body " + i, all.get(i).body(), got.get(i).body()); + } + } + + private static byte[] body(String text) { + return new Buf().writeString(text).toByteArray(); + } + + private static void testHandshake() { + // the shapes the handshake relies on, exercised through the buffer + Buf b = new Buf(); + b.writeString("Player"); + eq("account name", "Player", new Buf(b.toByteArray()).readString(Packets.MAX_TEXT)); + + Buf t = new Buf(); + t.writeString("proof-token-value"); + eq("proof", "proof-token-value", new Buf(t.toByteArray()).readString(Packets.MAX_PROOF)); + + Buf n = new Buf(); + n.writeVarInt(200); + n.writeVarInt(500); + Buf nr = new Buf(n.toByteArray()); + eq("online", Integer.valueOf(200), Integer.valueOf(nr.readVarInt())); + eq("max", Integer.valueOf(500), Integer.valueOf(nr.readVarInt())); + } + + private static void testLyingLength() { + // announces a large but legal body, sends nothing, and keeps quiet. + // The decoder must hold it without growing and without pretending the + // frame arrived. + FrameDecoder decoder = new FrameDecoder(64 * 1024); + Buf header = new Buf(); + header.writeInt(Frame.MAGIC); + header.writeVarInt(Frame.VERSION); + header.writeVarInt(Packets.HELLO); + header.writeVarInt(60 * 1024); + byte[] bytes = header.toByteArray(); + List got = decoder.offer(bytes, 0, bytes.length); + yes("no frame yet", got.isEmpty()); + yes("held the header", decoder.bufferedBytes() == bytes.length); + + // and the rest arrives, so it completes + byte[] body = new byte[60 * 1024]; + got = decoder.offer(body, 0, body.length); + eq("completes once the body arrives", Integer.valueOf(1), Integer.valueOf(got.size())); + } +} \ No newline at end of file