Каркас протокола: кадрирование, буфер и рукопожатие

Это первый настоящий кусок сервера. Пока он ничего не умеет, кроме того что
умеет правильно: принимать байты от чужого человека и не дать ему ничего
сделать с нами.

Формат кадра — четыре байта магии 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:
sasheg dev
2026-10-19 01:10:26 +03:00
parent 736ef36a44
commit 7fbb26092f
9 changed files with 1429 additions and 0 deletions
+63
View File
@@ -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"
+42
View File
@@ -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, &lt;&lt; binds tighter than
* &amp;, so "a &amp; 0x3FFFFFF &lt;&lt; 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()));
}
}