Protocolo Binário em Zig: Framing, Endianness e Parsing Seguro

Para implementar um protocolo binário em Zig, não serialize structs diretamente. Defina um formato de bytes explícito, escolha uma endianness fixa, inclua o comprimento do payload e valide cada campo antes de avançar o cursor. Em TCP, mantenha um buffer incremental: uma chamada de leitura pode trazer meio frame ou vários frames de uma vez.

Uma boa implementação separa três responsabilidades: codificar campos, encontrar os limites do frame e interpretar a mensagem. Essa divisão torna o parser testável, evita leituras fora do buffer e permite rejeitar pacotes malformados antes de alocar memória ou executar lógica de negócio.

Este guia constrói um wire format pequeno sem depender de casts de layout nem de APIs experimentais. O padrão serve para agentes, jogos, telemetria, IPC, dispositivos e serviços de baixa latência.

Resposta rápida: o formato recomendado

Vamos usar este frame como exemplo:

OffsetTamanhoCampoRegra
02 bytesMagicSempre 0x5A47 (ZG)
21 byteVersãoInicialmente 1
31 byteTipoCódigo da mensagem
44 bytesPayload lengthu32, big-endian
8N bytesPayloadNo máximo 1 MiB
8 + N4 bytesChecksumOpcional, calculado sobre header + payload

O header fixo tem oito bytes. O tamanho total é 8 + payload_length + 4. Um receptor deve verificar magic, versão, tipo, limite de tamanho e disponibilidade de todos os bytes antes de entregar o payload.

A ordem segura é:

  1. esperar pelo header completo;
  2. validar magic e versão;
  3. decodificar o comprimento;
  4. rejeitar valores acima do limite;
  5. calcular o tamanho total com overflow protegido;
  6. esperar pelo frame completo;
  7. validar checksum ou autenticação;
  8. interpretar o payload;
  9. remover apenas os bytes consumidos.

Por que não enviar uma struct diretamente

Este código parece conveniente, mas cria um protocolo frágil:

const Header = struct {
    magic: u16,
    version: u8,
    kind: u8,
    payload_len: u32,
};

// Não trate os bytes nativos de Header como wire format.

A representação em memória pode depender de:

  • endianness da CPU;
  • padding inserido para alinhamento;
  • tamanho e alinhamento de tipos;
  • alterações futuras na struct;
  • detalhes da ABI ou do compilador.

packed struct reduz algumas ambiguidades de layout, mas não substitui uma especificação de protocolo. O formato externo deve continuar legível campo por campo: “dois bytes big-endian”, “um byte de versão”, “quatro bytes big-endian”.

Serialize explicitamente. Além de portátil, isso deixa os pontos de validação visíveis durante code review.

Helpers de big-endian sem casts

Big-endian armazena o byte mais significativo primeiro. Estes helpers independem da endianness nativa da máquina:

fn readU16Be(bytes: []const u8) !u16 {
    if (bytes.len < 2) return error.Incomplete;

    return (@as(u16, bytes[0]) << 8) |
        @as(u16, bytes[1]);
}

fn readU32Be(bytes: []const u8) !u32 {
    if (bytes.len < 4) return error.Incomplete;

    return (@as(u32, bytes[0]) << 24) |
        (@as(u32, bytes[1]) << 16) |
        (@as(u32, bytes[2]) << 8) |
        @as(u32, bytes[3]);
}

fn writeU16Be(out: []u8, value: u16) !void {
    if (out.len < 2) return error.NoSpace;

    out[0] = @intCast(value >> 8);
    out[1] = @intCast(value & 0xff);
}

fn writeU32Be(out: []u8, value: u32) !void {
    if (out.len < 4) return error.NoSpace;

    out[0] = @intCast(value >> 24);
    out[1] = @intCast((value >> 16) & 0xff);
    out[2] = @intCast((value >> 8) & 0xff);
    out[3] = @intCast(value & 0xff);
}

As checagens de comprimento vêm antes de qualquer índice. Essa regra precisa valer para todos os helpers, mesmo quando o chamador “garante” que há bytes suficientes. Parsers ficam mais seguros quando cada camada protege o próprio contrato.

Modelando o header validado

Não misture bytes ainda não confiáveis com o modelo aceito pela aplicação:

const MAGIC: u16 = 0x5A47;
const VERSION: u8 = 1;
const HEADER_LEN: usize = 8;
const CHECKSUM_LEN: usize = 4;
const MAX_PAYLOAD_LEN: usize = 1024 * 1024;

const MessageType = enum(u8) {
    ping = 1,
    data = 2,
    close = 3,
};

const Header = struct {
    kind: MessageType,
    payload_len: usize,
};

fn parseHeader(bytes: []const u8) !Header {
    if (bytes.len < HEADER_LEN) return error.Incomplete;

    if (try readU16Be(bytes[0..2]) != MAGIC) {
        return error.InvalidMagic;
    }
    if (bytes[2] != VERSION) return error.UnsupportedVersion;

    const kind: MessageType = std.meta.intToEnum(
        MessageType,
        bytes[3],
    ) catch return error.UnknownMessageType;

    const payload_len: usize = @intCast(
        try readU32Be(bytes[4..8]),
    );
    if (payload_len > MAX_PAYLOAD_LEN) {
        return error.PayloadTooLarge;
    }

    return .{
        .kind = kind,
        .payload_len = payload_len,
    };
}

O enum fechado rejeita tipos desconhecidos. Isso é apropriado quando a versão define exatamente quais mensagens existem. Se o protocolo precisa permitir extensões, preserve o valor como u8 e decida na camada de despacho se tipos desconhecidos devem ser ignorados, encaminhados ou rejeitados.

A política de compatibilidade faz parte do protocolo; não deve surgir por acidente em um switch else.

Parsing de um frame completo

O parser pode informar “ainda faltam bytes” sem confundir isso com pacote inválido:

const Frame = struct {
    kind: MessageType,
    payload: []const u8,
    consumed: usize,
};

fn parseFrame(bytes: []const u8) !Frame {
    if (bytes.len < HEADER_LEN) return error.Incomplete;

    const header = try parseHeader(bytes[0..HEADER_LEN]);

    const body_end = std.math.add(
        usize,
        HEADER_LEN,
        header.payload_len,
    ) catch return error.InvalidLength;

    const frame_end = std.math.add(
        usize,
        body_end,
        CHECKSUM_LEN,
    ) catch return error.InvalidLength;

    if (bytes.len < frame_end) return error.Incomplete;

    const payload = bytes[HEADER_LEN..body_end];
    const expected = try readU32Be(bytes[body_end..frame_end]);
    const actual = checksum(bytes[0..body_end]);
    if (actual != expected) return error.ChecksumMismatch;

    return .{
        .kind = header.kind,
        .payload = payload,
        .consumed = frame_end,
    };
}

std.math.add evita que uma soma de tamanhos faça wrap e produza um limite aparentemente pequeno. O exemplo já limita o payload a 1 MiB, mas proteger a aritmética continua sendo uma defesa útil e documenta a intenção.

O slice de payload aponta para o buffer de entrada. Ele deixa de ser válido quando esse buffer é movido, reutilizado ou liberado. A camada que consome o frame deve processá-lo antes da compactação ou copiar apenas os dados que precisam sobreviver.

TCP é um fluxo, não uma fila de mensagens

Se o emissor chama write uma vez por frame, o receptor não ganha a mesma divisão em read. TCP pode entregar:

  • somente parte do header;
  • header mais parte do payload;
  • um frame completo;
  • dois frames e metade do terceiro.

Por isso, o loop receptor mantém bytes pendentes e extrai tudo que estiver completo:

fn processBuffered(input: []const u8) !usize {
    var offset: usize = 0;

    while (offset < input.len) {
        const frame = parseFrame(input[offset..]) catch |err| switch (err) {
            error.Incomplete => break,
            else => return err,
        };

        try dispatch(frame.kind, frame.payload);
        offset += frame.consumed;
    }

    return offset;
}

O retorno indica quantos bytes podem ser descartados. O chamador move o restante para o início do buffer, lê mais dados depois dele e chama o parser novamente.

Para conexões reais, adicione:

  • capacidade máxima do buffer;
  • timeout para header e payload;
  • limite de frames por iteração, evitando monopolizar o event loop;
  • política de fechamento para erros fatais;
  • métricas de frames inválidos, sem registrar payload sensível.

O guia de sockets TCP e UDP em Zig cobre a camada de transporte. Para conexões longas, veja também TCP keepalive e graceful shutdown.

Codificando um frame

O encoder deve calcular o tamanho antes de escrever e nunca truncar usize para u32 silenciosamente:

fn encodeFrame(
    out: []u8,
    kind: MessageType,
    payload: []const u8,
) ![]u8 {
    if (payload.len > MAX_PAYLOAD_LEN) {
        return error.PayloadTooLarge;
    }
    if (payload.len > std.math.maxInt(u32)) {
        return error.PayloadTooLarge;
    }

    const body_end = try std.math.add(
        usize,
        HEADER_LEN,
        payload.len,
    );
    const total = try std.math.add(
        usize,
        body_end,
        CHECKSUM_LEN,
    );
    if (out.len < total) return error.NoSpace;

    try writeU16Be(out[0..2], MAGIC);
    out[2] = VERSION;
    out[3] = @intFromEnum(kind);
    try writeU32Be(out[4..8], @intCast(payload.len));
    @memcpy(out[HEADER_LEN..body_end], payload);

    const sum = checksum(out[0..body_end]);
    try writeU32Be(out[body_end..total], sum);

    return out[0..total];
}

Em produção, escolha deliberadamente se o encoder recebe um buffer, escreve em um writer ou aloca. Receber memória do chamador oferece controle de alocação e funciona bem em hot paths. Um writer é conveniente para mensagens compostas ou streaming. Alocar internamente simplifica chamadas, mas deve exigir um Allocator explícito.

Para revisar estratégias de memória, consulte alocação de memória em Zig e gerenciamento de memória.

Checksum, CRC, MAC e TLS não são a mesma coisa

O checksum do exemplo é um ponto de extensão, não uma recomendação criptográfica. Escolha o mecanismo conforme o risco:

MecanismoDetecta corrupção acidentalImpede alteração intencionalProtege confidencialidade
Soma simplesFracamenteNãoNão
CRC32SimNãoNão
HMACSimSim, com chave secretaNão
TLSSimSimSim

CRC é útil em arquivo, serial e transporte que pode corromper dados sem já oferecer integridade. Em TCP moderno, ele pode continuar útil para detectar bugs internos ou dados armazenados, mas não autentica o remetente.

Se um atacante pode alterar mensagens, use uma construção criptográfica consolidada ou TLS. Não invente um “checksum secreto”. Para HTTP e serviços, veja TLS, HTTPS e mTLS em Zig.

Payload com campos variáveis

Dentro do payload, repita a mesma disciplina. Uma string pode ser representada por length + bytes, mas o comprimento precisa caber no payload restante:

const Cursor = struct {
    bytes: []const u8,
    pos: usize = 0,

    fn readU16(self: *Cursor) !u16 {
        const end = std.math.add(usize, self.pos, 2) catch
            return error.InvalidLength;
        if (end > self.bytes.len) return error.Incomplete;

        const value = try readU16Be(self.bytes[self.pos..end]);
        self.pos = end;
        return value;
    }

    fn readBytes(self: *Cursor, len: usize) ![]const u8 {
        const end = std.math.add(usize, self.pos, len) catch
            return error.InvalidLength;
        if (end > self.bytes.len) return error.Incomplete;

        const value = self.bytes[self.pos..end];
        self.pos = end;
        return value;
    }
};

O cursor centraliza aritmética e limites. Ao terminar uma mensagem, decida se cursor.pos deve ser exatamente igual ao tamanho do payload. Rejeitar bytes extras detecta versões incompatíveis e entradas ambíguas; permitir extensões pode ajudar evolução futura. Documente a escolha.

Versionamento sem quebrar clientes

Um byte de versão não resolve compatibilidade sozinho. Defina as regras antes da segunda versão:

  • a versão se aplica ao frame inteiro ou a cada tipo de mensagem?;
  • campos novos só podem ser adicionados no final?;
  • receptores ignoram campos desconhecidos?;
  • tipos desconhecidos encerram a conexão?;
  • existe negociação de capacidades?;
  • versões antigas têm prazo de suporte?;

Para protocolos internos simples, versões inteiras e explícitas são fáceis de operar: cliente e servidor aceitam um conjunto pequeno e rejeitam o restante com erro claro. Para ecossistemas distribuídos, campos tagueados ou TLV (type-length-value) permitem evolução mais granular, ao custo de bytes e complexidade.

Evite usar tamanho de struct como versão. O significado dos bytes deve vir da especificação, não do layout atual do código.

Segurança contra entradas hostis

Um parser de rede trabalha em uma fronteira de confiança. Verifique pelo menos:

  • magic e versão antes de interpretar o corpo;
  • limite máximo antes de alocar pelo comprimento declarado;
  • overflow em todas as somas e multiplicações de tamanhos;
  • tipo da mensagem antes do dispatch;
  • tamanho mínimo e máximo por tipo, não apenas o máximo global;
  • quantidade de elementos antes de criar arrays;
  • profundidade se o formato permitir estruturas aninhadas;
  • timeout contra clientes slowloris;
  • integridade ou autenticação antes de executar efeitos;
  • tempo de vida de slices que apontam para o buffer reutilizável.

Nunca reserve 4 GiB porque o pacote declarou payload_len = 0xffffffff. O limite deve refletir o produto: talvez 64 KiB para comandos e 1 MiB para lotes. Mensagens grandes podem usar chunks ou streaming em vez de um frame monolítico.

Testes que encontram bugs reais

Comece com round-trip, mas não pare nele:

test "u32 big-endian round trip" {
    var bytes: [4]u8 = undefined;
    try writeU32Be(&bytes, 0x12345678);

    try std.testing.expectEqualSlices(
        u8,
        &[_]u8{ 0x12, 0x34, 0x56, 0x78 },
        &bytes,
    );
    try std.testing.expectEqual(
        @as(u32, 0x12345678),
        try readU32Be(&bytes),
    );
}

A suíte também deve cobrir:

  1. todos os cortes possíveis de um frame válido, esperando Incomplete;
  2. dois ou mais frames concatenados;
  3. magic, versão e tipo desconhecidos;
  4. payload com zero bytes e com o limite máximo;
  5. comprimento acima do limite;
  6. comprimento cujo frame ainda não chegou por completo;
  7. checksum incorreto;
  8. campos internos truncados;
  9. bytes extras no payload;
  10. entrada aleatória via fuzzing.

O teste de cortes é especialmente valioso para TCP: para um frame de 100 bytes, passe os prefixos de tamanho 0 a 99 e confirme que nenhum provoca panic nem leitura fora do slice. Depois passe os 100 bytes e espere sucesso.

Use o fuzzer nativo do Zig para alimentar parseFrame com entradas arbitrárias. O contrato mínimo é: o parser retorna um frame ou um erro conhecido, mas nunca entra em loop infinito, acessa memória inválida ou aloca sem limite.

Checklist de code review

Antes de publicar um protocolo binário em Zig, confirme:

  • o wire format está documentado por offset, tamanho e endianness;
  • nenhuma struct é enviada pela representação nativa;
  • todo índice é precedido por checagem de comprimento;
  • somas de tamanho detectam overflow;
  • o payload tem limite global e limite por tipo;
  • Incomplete é diferente de frame inválido;
  • o parser aceita frames fragmentados e concatenados;
  • slices não sobrevivem à reutilização do buffer;
  • checksum não é descrito como autenticação;
  • regras de versionamento e tipos desconhecidos estão explícitas;
  • testes cobrem truncamento em cada posição;
  • fuzzing não encontra panic nem consumo ilimitado.

Conclusão

Um protocolo binário seguro não começa pela função de socket. Ele começa por um contrato de bytes preciso. Em Zig, serialize campos explicitamente, use uma ordem fixa, valide antes de indexar e mantenha o parser incremental separado do dispatch.

A combinação mais robusta é header fixo + tamanho limitado + parser por cursor + integridade validada + testes de truncamento e fuzzing. Ela funciona tanto para um daemon local quanto para um serviço TCP exposto, sem depender da representação de memória da máquina.

Comece com um wire format pequeno e regras conservadoras. Adicione TLV, compressão, streaming ou negociação apenas quando o caso de uso exigir. Em protocolos, cada flexibilidade vira mais um estado que o parser precisa provar seguro.

Para continuar no cluster de sistemas, leia parsing e serialização em Zig, sockets TCP/UDP e code review para Zig em produção.

Perguntas frequentes

TCP entrega um frame por chamada de leitura?

Não. TCP só preserva ordem dos bytes. O receptor precisa acumular dados e usar o campo de tamanho para separar mensagens completas.

Big-endian é obrigatório?

Não, mas a ordem deve ser fixa e documentada. Big-endian é uma convenção comum para protocolos. Little-endian também funciona quando todos os participantes seguem a mesma especificação.

packed struct resolve serialização?

Não completamente. Ainda existem decisões sobre endianness, evolução, validação e compatibilidade. Codificar cada campo explicitamente produz um contrato mais estável e auditável.

Devo copiar o payload depois do parsing?

Somente se ele precisar sobreviver à reutilização do buffer. Processar o slice imediatamente evita alocação. Enfileirar ou guardar a mensagem exige ownership claro, normalmente por cópia ou buffer ref-counted.

Qual tamanho máximo escolher?

O menor limite que atende ao produto. Use limites por tipo de mensagem e prefira chunks ou streaming para dados grandes. Um u32 no wire format não obriga o servidor a aceitar 4 GiB.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.