Раздел 23 · Rust

Сериализация: кодек, порядок байт и граница доверия

middle-senior~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Сериализация: кодек, порядок байт и граница доверия

Прошлый урок спроектировал формат: 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, который крутит симуляцию серверным тиком и рассылает снапшоты.

Домашка