Сериализация: кодек, порядок байт и граница доверия
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Сериализация: кодек, порядок байт и граница доверия
Прошлый урок спроектировал формат: varint, биты, теги, дельты. Но приёмы были разбросаны по месту. Сегодня соберём их в аккуратный слой сериализации, кодек, через который проходит каждое сообщение. Две идеи держат весь этот слой. Первая: порядок байт на проводе фиксирован, и мы пишем его явно, не полагаясь на нативный порядок процессора. Вторая, и более важная: всё, что пришло из сети, это враждебный ввод. Длины лгут, теги мусор, буфер обрывается на середине. Поэтому чтение это не зеркало записи, а место, где валидируется недоверенный вход. Пройдём оба правила и посмотрим, как
serdeиbincodeделают то же самое для типичных задач, а где приходится писать кодек руками.
Зачем свой кодек, если есть serde
В экосистеме есть serde плюс bincode, и для большинства задач это правильный ответ: описал тип, вывел derive, получил быструю компактную сериализацию бесплатно. Конфиги, сохранения, обмен между сервисами это всё туда.
Свой кодек мы пишем не из упрямства, а потому что игровой снапшот живёт на уровне бита: здоровье в семи битах, флаг в одном, координата квантована в u16. Это контроль, которого готовый формат не даёт, он пишет каждое поле его полным размером. Поэтому стратегия смешанная: каркас сообщений и идею трейтов берём как у serde, а упаковку чисел держим в руках. И, что важнее любой экономии, кодек это рубеж валидации, и его мы хотим понимать до последней проверки, потому что от него зависит, переживёт ли сервер злонамеренный пакет.
Правило первое: порядок байт фиксирован
Число в памяти лежит в нативном порядке процессора, и он бывает разный. Чтобы машина с любым порядком прочитала пакет одинаково, на проводе всё едет в сетевом порядке (big-endian), и мы зовём это явно, не доверяя порядку платформы. Сторона записи проста, в Vec всегда можно писать, ошибок тут нет:
/// Буфер на запись. Все методы инфаллибельны: писать в Vec всегда можно.
pub struct ByteWriter {
buf: Vec<u8>,
}
impl ByteWriter {
pub fn u8(&mut self, v: u8) {
self.buf.push(v);
}
pub fn u32(&mut self, v: u32) {
self.buf.extend_from_slice(&v.to_be_bytes()); // явный big-endian
}
/// f32 уходит на провод как его big-endian битовое представление
/// (IEEE-754, связь с RU3, вещественные числа).
pub fn f32(&mut self, v: f32) {
self.buf.extend_from_slice(&v.to_bits().to_be_bytes());
}
/// Беззнаковый varint (компактные счётчики и длины).
pub fn varint(&mut self, v: u64) {
bits::write_varint_u64(&mut self.buf, v);
}
}
to_be_bytes это endianness из RU3 в действии. А f32 едет не как «число», а как его битовое представление IEEE-754 через to_bits, то самое, что мы разбирали в RU3: на проводе нет вещественных чисел, есть только байты, и обе стороны должны согласиться, какие именно.
Правило второе: чтение валидирует
Вот сердце урока. Запись безопасна, а чтение это работа с недоверенным буфером. Поэтому читаем не напрямую, а через курсор, который проверяет каждый доступ и возвращает ошибку вместо паники или выхода за границу:
/// Курсор на чтение с проверкой границ. Сердце валидации недоверенного ввода.
pub struct ByteReader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> ByteReader<'a> {
pub fn remaining(&self) -> usize {
self.data.len() - self.pos
}
fn take(&mut self, n: usize) -> Result<&'a [u8], CodecError> {
if self.remaining() < n {
return Err(CodecError::UnexpectedEof { needed: n, remaining: self.remaining() });
}
let slice = &self.data[self.pos..self.pos + n];
self.pos += n;
Ok(slice)
}
pub fn u32(&mut self) -> Result<u32, CodecError> {
let b = self.take(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
}
Весь курсор стоит на take: прежде чем сдвинуть pos, он проверяет, что байт хватает. Это превращает потенциальный выход за границу среза (в C это была бы дыра, в Rust это паника) в типизированную ошибку, которую вызывающий код обработает спокойно. Ошибки кодека это закрытый список того, чем пакет может соврать:
/// Ошибка декодирования из недоверенного буфера.
pub enum CodecError {
/// Запросили больше байт, чем осталось в буфере.
UnexpectedEof { needed: usize, remaining: usize },
/// Тег варианта enum вне допустимого множества.
InvalidTag { kind: &'static str, tag: u8 },
/// Длина коллекции больше разумного предела (защита от бомбы аллокаций).
LengthTooLarge { kind: &'static str, len: u64, max: u64 },
/// Битый varint.
BadVarint,
/// После разбора сообщения в буфере остался мусор.
TrailingBytes(usize),
/// Несовпадение версии или магического числа протокола.
BadProtocol,
}
Бомба длины и хвост
Две атаки заслуживают отдельного внимания, потому что наивный код на них падает.
Первая это бомба длины. Пакет говорит «дальше список из четырёх миллиардов игроков», и наивный декодер послушно зовёт Vec::with_capacity(4_000_000_000) и валится. Защита: проверить длину до любой аллокации, и против разумного потолка, и против остатка буфера.
/// Читает длину-varint и проверяет её против предела до любой аллокации.
/// Это и есть защита от мусора: пакет не может заставить нас выделить гигабайт,
/// просто прислав большое число.
pub fn checked_len(&mut self, kind: &'static str, max: u64) -> Result<usize, CodecError> {
let len = self.varint()?;
if len > max {
return Err(CodecError::LengthTooLarge { kind, len, max });
}
// Длина не может превышать остаток буфера, иначе это заведомо обрыв.
if len as usize > self.remaining() {
return Err(CodecError::UnexpectedEof { needed: len as usize, remaining: self.remaining() });
}
Ok(len as usize)
}
Вторая это молчаливый хвост. Сообщение разобралось, но в буфере остались лишние байты. Это либо битый пакет, либо попытка что-то подсунуть, и пропускать это нельзя. Поэтому полный разбор требует, чтобы буфер кончился ровно на конце сообщения. Это место удобно собрать в трейт, как делает serde, и заодно зафиксировать каркас всех сообщений:
/// Тип, который умеет сериализоваться на провод.
pub trait Encode {
fn encode(&self, w: &mut ByteWriter);
/// Удобный шорткат: сериализовать в свежий Vec.
fn to_bytes(&self) -> Vec<u8> {
let mut w = ByteWriter::new();
self.encode(&mut w);
w.into_vec()
}
}
/// Тип, который умеет разобраться из недоверенного буфера.
pub trait Decode: Sized {
fn decode(r: &mut ByteReader) -> Result<Self, CodecError>;
/// Разобрать из целого буфера и потребовать, чтобы хвоста не осталось.
/// Так молчаливо битый пакет не проедет.
fn from_bytes(bytes: &[u8]) -> Result<Self, CodecError> {
let mut r = ByteReader::new(bytes);
let value = Self::decode(&mut r)?;
if !r.is_done() {
return Err(CodecError::TrailingBytes(r.remaining()));
}
Ok(value)
}
}
Пара трейтов Encode и Decode это наш serde::Serialize и Deserialize в миниатюре, трейт как набор поведения с законами. Метод по умолчанию from_bytes встроил проверку хвоста раз и навсегда: любой тип, реализующий Decode, получает её бесплатно и не может забыть.
Неизвестный тег это не паника
И последний рубеж, разбор тега. Сообщение начинается с однобайтового дискриминанта, и match по нему обязан быть исчерпывающим, включая ветку «всё остальное»:
impl Decode for ClientMessage {
fn decode(r: &mut ByteReader) -> Result<Self, CodecError> {
let tag = r.u8()?;
match tag {
Self::TAG_HELLO => Ok(ClientMessage::Hello { version: r.u8()? }),
Self::TAG_INPUTS => { /* ... разбор списка вводов ... */ }
Self::TAG_ACK => Ok(ClientMessage::AckSnapshot { tick: r.varint()? as u32 }),
other => Err(CodecError::InvalidTag { kind: "ClientMessage", tag: other }),
}
}
}
Ветка other это разница между сервером, который пожимает плечами на мусорный байт, и сервером, который от него падает. Исчерпывающий match тут не про полноту ради красоты, а про то, что злоумышленник не выберет за нас необработанный случай.
Что унести из урока
Кодек стоит на двух правилах. Порядок байт на проводе фиксирован (big-endian) и пишется явно через to_be_bytes, а f32 едет как своё битовое представление IEEE-754: на проводе нет чисел, есть байты, и обе стороны должны согласиться. Чтение это рубеж валидации: ByteReader проверяет каждый доступ через take и превращает выход за границу в типизированную CodecError. Две атаки лечатся прицельно: бомба длины это проверка длины против потолка и остатка буфера до аллокации (checked_len), молчаливый хвост это требование is_done в from_bytes, а мусорный тег это ветка other с InvalidTag вместо паники. Трейты Encode и Decode это serde в миниатюре, и метод по умолчанию встраивает проверку хвоста так, что её нельзя забыть. Для обычных задач берут serde плюс bincode, свой кодек оправдан там, где нужен контроль до бита и полный контроль над валидацией.
Дальше сведём всё построенное (сокеты, протокол, кодек) с настоящим рантаймом: соберём авторитетный сервер на tokio, который крутит симуляцию серверным тиком и рассылает снапшоты.