Каркас протокола: кадрирование, буфер и рукопожатие
Это первый настоящий кусок сервера. Пока он ничего не умеет, кроме того что умеет правильно: принимать байты от чужого человека и не дать ему ничего сделать с нами. Формат кадра — четыре байта магии 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 и без сети, как и всё остальное в проекте.
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 + "]";
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
*
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
*
|
||||
* 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]";
|
||||
}
|
||||
}
|
||||
@@ -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<Frame> 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<Frame> out = new java.util.ArrayList<Frame>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
*
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
*/
|
||||
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<Integer, String> names() {
|
||||
Map<Integer, String> map = new LinkedHashMap<Integer, String>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> failures = new ArrayList<String>();
|
||||
|
||||
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<Frame> 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<Frame> expected = new ArrayList<Frame>();
|
||||
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<Frame> got = new ArrayList<Frame>();
|
||||
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<Frame> all = new ArrayList<Frame>();
|
||||
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<Frame> 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<Frame> 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user