Каркас протокола: кадрирование, буфер и рукопожатие
Это первый настоящий кусок сервера. Пока он ничего не умеет, кроме того что умеет правильно: принимать байты от чужого человека и не дать ему ничего сделать с нами. Формат кадра — четыре байта магии 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,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