Segurança de Memória em Zig: Como Zig Previne Bugs

Bugs de memória são a causa número um de vulnerabilidades de segurança em software de sistemas. Buffer overflows, use-after-free, dangling pointers e undefined behavior assombram programadores C e C++ há décadas. A Zig lang foi projetada desde o início para atacar esse problema de frente, oferecendo verificações de segurança em tempo de execução que detectam esses bugs no momento em que ocorrem, sem exigir a complexidade de um borrow checker. A abordagem da linguagem Zig é pragmática: máxima segurança durante o desenvolvimento, com a opção de desativar verificações em builds de produção quando a performance é crítica.

Neste tutorial, vamos explorar em profundidade como Zig previne cada categoria de bug de memória, comparar a abordagem com C e Rust, e mostrar exemplos concretos de bugs que Zig detecta e que C deixaria passar silenciosamente.

O Problema: Bugs de Memória em C

Antes de entender a solução de Zig, precisamos entender o problema. Em C, erros de memória são silenciosos e devastadores: o programa compila sem avisos, parece funcionar na maioria dos casos, e explode em produção com dados corrompidos ou exploits de segurança.

Os bugs mais comuns são:

// Bug 1: Buffer overflow — acesso além dos limites do array
int arr[10];
arr[15] = 42; // Undefined behavior! Corrompe memória adjacente

// Bug 2: Use-after-free — usar memória já liberada
int *ptr = malloc(sizeof(int));
*ptr = 10;
free(ptr);
printf("%d\n", *ptr); // UB! Pode imprimir 10, lixo, ou crashar

// Bug 3: Null pointer dereference
int *p = NULL;
*p = 5; // Segfault (quando você tem sorte)

// Bug 4: Integer overflow
int x = INT_MAX;
x = x + 1; // UB em C! Pode ser negativo, zero, qualquer coisa

// Bug 5: Double free
free(ptr);
free(ptr); // Corrupção do heap — exploitável

Em C, todos esses bugs compilam sem warnings. O compilador confia que o programador sabe o que está fazendo. Zig toma a posição oposta: verificar tudo por padrão, e só desativar quando explicitamente solicitado. Para uma analise mais detalhada dessas diferencas, veja a comparacao entre Zig e C.

A Abordagem de Zig: Safety Checks Configuráveis

Zig implementa verificações de segurança em tempo de execução que estão ativas por padrão nos modos Debug e ReleaseSafe, e podem ser desativadas nos modos ReleaseFast e ReleaseSmall para máxima performance.

VerificaçãoDebugReleaseSafeReleaseFastReleaseSmall
Bounds checkingSimSimNaoNao
Integer overflowSimSimNaoNao
Null pointer checkSimSimNaoNao
Alignment checkSimSimNaoNao
Stack protectorSimSimNaoNao

A filosofia e: detectar bugs durante o desenvolvimento (Debug/ReleaseSafe) e confiar no codigo testado em producao (ReleaseFast), se necessario.

Bounds Checking: Arrays e Slices Sempre Verificados

Em Zig, todo acesso a arrays e slices é verificado contra os limites. Tentar acessar um indice fora dos limites causa um panic imediato em vez de corromper memoria silenciosamente.

const std = @import("std");

pub fn main() void {
    var array = [_]i32{ 10, 20, 30, 40, 50 };
    const slice: []i32 = &array;

    // Acesso normal — funciona perfeitamente
    std.debug.print("Elemento 2: {}\n", .{slice[2]}); // 30

    // Acesso fora dos limites — panic em Debug/ReleaseSafe!
    // Em C, isso simplesmente corromperia memória
    // Em Zig: "index out of bounds: index 10 is out of range 0..5"
    // const invalido = slice[10]; // Descomente para ver o panic
}

fn exemploOverflow() void {
    var buf: [4]u8 = .{ 'Z', 'i', 'g', '!' };

    // Tentativa de escrever além do buffer
    for (0..10) |i| {
        // Em C: buffer overflow silencioso
        // Em Zig: panic quando i >= 4
        if (i < buf.len) {
            buf[i] = 'X';
        }
    }

    std.debug.print("{s}\n", .{&buf});
}

A verificacao de limites e particularmente importante em funcoes que recebem slices de tamanho desconhecido:

const std = @import("std");

// Em C, esta funcao precisaria de um parametro 'size' separado
// e o programador poderia facilmente passar o tamanho errado.
// Em Zig, o slice carrega seu tamanho — impossível errar.
fn somaSegura(dados: []const i32) i64 {
    var soma: i64 = 0;
    for (dados) |valor| {
        soma += valor;
    }
    return soma;
}

// Slicing tambem e verificado
fn primeirosN(dados: []const i32, n: usize) []const i32 {
    // Se n > dados.len, panic em vez de retornar dados invalidos
    return dados[0..n];
}

pub fn main() void {
    const nums = [_]i32{ 1, 2, 3, 4, 5 };
    std.debug.print("Soma: {}\n", .{somaSegura(&nums)});

    const tres = primeirosN(&nums, 3);
    std.debug.print("Primeiros 3: {any}\n", .{tres});

    // Isto causaria panic:
    // const invalido = primeirosN(&nums, 100);
}

Null Safety: Optional Types em Vez de Null Pointers

Em C, qualquer ponteiro pode ser NULL, e desreferenciar um ponteiro NULL e undefined behavior. Zig elimina esse problema na raiz: ponteiros regulares nunca podem ser null. Quando nullabilidade e necessaria, voce usa um optional type explicito.

const std = @import("std");

const Node = struct {
    value: i32,
    // Em C: struct Node* next; — pode ser NULL a qualquer momento
    // Em Zig: explicitamente marcado como optional
    next: ?*Node,
};

fn buscar(head: ?*Node, valor: i32) ?*Node {
    var current = head;
    while (current) |node| {
        if (node.value == valor) return node;
        current = node.next;
    }
    return null;
}

pub fn main() void {
    var n3 = Node{ .value = 30, .next = null };
    var n2 = Node{ .value = 20, .next = &n3 };
    var n1 = Node{ .value = 10, .next = &n2 };

    // Busca segura — o compilador OBRIGA o tratamento de null
    if (buscar(&n1, 20)) |encontrado| {
        std.debug.print("Encontrado: {}\n", .{encontrado.value});
    } else {
        std.debug.print("Não encontrado.\n", .{});
    }

    // Isto NÃO compila — voce nao pode ignorar o optional:
    // const node = buscar(&n1, 20);
    // std.debug.print("{}\n", .{node.value}); // ERRO de compilação!

    // Voce deve usar if, orelse, ou .?
    const valor = if (buscar(&n1, 20)) |n| n.value else -1;
    std.debug.print("Valor: {}\n", .{valor});
}

A diferenca fundamental e que em C, esquecimentos sao silenciosos. Em Zig, o compilador rejeita codigo que nao trata null explicitamente.

const std = @import("std");

// Ponteiros regulares NUNCA sao null — garantia do compilador
fn processarSempre(ptr: *const i32) i32 {
    // Nao precisa verificar null — e impossivel ser null
    return ptr.* * 2;
}

// Optional so quando realmente precisa
fn processarTalvez(ptr: ?*const i32) i32 {
    // DEVE tratar o caso null
    return if (ptr) |p| p.* * 2 else 0;
}

pub fn main() void {
    var x: i32 = 21;
    std.debug.print("Sempre: {}\n", .{processarSempre(&x)});
    std.debug.print("Talvez (com valor): {}\n", .{processarTalvez(&x)});
    std.debug.print("Talvez (null): {}\n", .{processarTalvez(null)});
}

Deteccao de Integer Overflow

Em C, integer overflow em tipos signed e undefined behavior. O compilador pode literalmente fazer qualquer coisa, incluindo remover verificacoes de seguranca baseadas na suposicao de que overflow nao ocorre. Em Zig, overflow e detectado e causa panic.

const std = @import("std");

pub fn main() void {
    // Overflow detectado em Debug/ReleaseSafe
    var x: u8 = 250;
    x += 10; // PANIC: integer overflow
    // Em C: silenciosamente vira 4 (wraparound)
    // Em Zig: "integer overflow" panic

    // Para overflow intencional, use operadores wrapping
    var y: u8 = 250;
    y +%= 10; // Explicitamente wrapping: resultado e 4
    std.debug.print("Wrapping: {}\n", .{y});

    // Ou operadores saturating
    var z: u8 = 250;
    z +|= 10; // Saturating: resultado e 255 (maximo do u8)
    std.debug.print("Saturating: {}\n", .{z});
}

Zig oferece tres semanticas de aritmetica para cada operacao:

const std = @import("std");

pub fn demonstrarAritmetica() void {
    const a: u8 = 200;
    const b: u8 = 100;

    // Operador normal: detecta overflow
    // const normal = a + b; // PANIC! 200 + 100 = 300, nao cabe em u8

    // Operador wrapping (%): wrap around
    const wrapping = a +% b; // 300 % 256 = 44
    std.debug.print("Wrapping: {}\n", .{wrapping});

    // Operador saturating (|): limita ao maximo
    const saturating = a +| b; // min(300, 255) = 255
    std.debug.print("Saturating: {}\n", .{saturating});

    // Overflow checking com retorno de erro
    const checked = @addWithOverflow(a, b);
    if (checked[1] != 0) {
        std.debug.print("Overflow detectado! Resultado seria: {}\n", .{checked[0]});
    }
}

Prevencao de Use-After-Free: Padroes de Allocator

Zig nao tem garbage collector nem borrow checker, mas seu sistema de allocators torna use-after-free muito mais dificil. O GeneralPurposeAllocator em modo debug detecta use-after-free e double-free.

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Alocar memoria
    const dados = try allocator.alloc(u8, 100);

    // Usar normalmente
    @memset(dados, 'A');
    std.debug.print("Dados: {s}\n", .{dados[0..10]});

    // Liberar
    allocator.free(dados);

    // Use-after-free: em C, isso funcionaria "por acaso"
    // Em Zig com GPA em Debug: panic com stack trace!
    // @memset(dados, 'B'); // Descomente para ver o panic

    // Double-free: em C, corrupção de heap
    // Em Zig com GPA: panic
    // allocator.free(dados); // Descomente para ver o panic
}

O padrao idiomatico de Zig para prevenir use-after-free e usar defer para alinhar alocacao e liberacao no mesmo escopo:

const std = @import("std");

fn processarDados(allocator: std.mem.Allocator) !void {
    // Padrao seguro: alocacao + defer free no mesmo escopo
    const buffer = try allocator.alloc(u8, 4096);
    defer allocator.free(buffer);
    // 'buffer' e valido ate o final desta funcao
    // 'defer' garante que sera liberado mesmo em caso de erro

    const temp = try allocator.alloc(u8, 1024);
    defer allocator.free(temp);

    // Trabalhar com buffer e temp...
    @memset(buffer, 0);
    @memset(temp, 0);

    // Se qualquer operacao abaixo falhar com 'try',
    // ambos os defers executam e a memoria e liberada
    try fazerAlgo(buffer, temp);
}

fn fazerAlgo(buf: []u8, tmp: []u8) !void {
    _ = buf;
    _ = tmp;
}

Seguranca de Stack vs Heap

Zig protege contra diferentes categorias de erros em ambos os tipos de memoria.

const std = @import("std");

// Stack: retornar ponteiro para variavel local e IMPOSSIVEL
// Em C, isso compila sem warnings mas causa undefined behavior.
// Em Zig, o compilador rejeita o codigo.

// Isto NAO compila em Zig:
// fn perigoso() *i32 {
//     var x: i32 = 42;
//     return &x; // ERRO: pointer to local variable
// }

// A forma correta:
fn seguro(allocator: std.mem.Allocator) !*i32 {
    const ptr = try allocator.create(i32);
    ptr.* = 42;
    return ptr; // OK: memoria no heap, gerenciada pelo allocator
}

// Protecao contra stack overflow
fn recursaoInfinita(n: usize) usize {
    // Em Debug, Zig detecta stack overflow e panic
    // Em vez de corromper memoria silenciosamente
    return recursaoInfinita(n + 1);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const ptr = try seguro(gpa.allocator());
    defer gpa.allocator().destroy(ptr);

    std.debug.print("Valor: {}\n", .{ptr.*});

    // Descomente para ver protecao contra stack overflow:
    // _ = recursaoInfinita(0);
}

Seguranca em Comptime: Detectando Bugs na Compilacao

Uma das vantagens unicas de Zig e a capacidade de executar codigo em tempo de compilacao, onde erros sao detectados antes mesmo de o programa rodar.

const std = @import("std");

// Validacao em comptime — erros aparecem DURANTE a compilacao
fn criarBuffer(comptime tamanho: usize) type {
    if (tamanho == 0) {
        @compileError("Buffer não pode ter tamanho zero!");
    }
    if (tamanho > 1024 * 1024) {
        @compileError("Buffer muito grande para stack! Use heap.");
    }
    return struct {
        data: [tamanho]u8 = undefined,
        len: usize = 0,

        pub fn push(self: *@This(), byte: u8) !void {
            if (self.len >= tamanho) return error.BufferFull;
            self.data[self.len] = byte;
            self.len += 1;
        }
    };
}

// Verificacao de formato em comptime
fn formatSeguro(comptime fmt: []const u8, args: anytype) void {
    // std.fmt.format verifica em COMPTIME se o formato
    // e compativel com os argumentos fornecidos
    std.debug.print(fmt, args);
}

pub fn main() void {
    // Isto funciona: tamanho valido
    var buf = criarBuffer(256){};
    buf.push('Z') catch {};
    std.debug.print("Buffer ok: {}\n", .{buf.len});

    // Isto NAO compilaria:
    // var bad = criarBuffer(0){}; // @compileError!

    // Verificacao de formato em comptime:
    formatSeguro("Nome: {s}, Idade: {}\n", .{ "Ana", @as(u32, 28) });

    // Isto NAO compilaria — tipo incompativel com formato:
    // formatSeguro("Nome: {s}\n", .{42}); // ERRO de compilacao!
}

A verificacao em comptime e especialmente poderosa para APIs genericas, onde erros de tipo que em C seriam mensagens crípticas de template se tornam mensagens claras com @compileError.

Comparacao: Zig vs Rust vs C

Cada linguagem aborda seguranca de memoria de forma diferente:

C: Confianca Total no Programador

// C permite TUDO — cabe ao programador evitar erros
char* get_name() {
    char buf[32];           // Stack local
    strcpy(buf, "Zig");     // OK
    return buf;             // BUG! Retorna ponteiro para stack invalida
}                           // Compila sem warnings com -Wall!

Rust: Verificacao Estatica com Borrow Checker

// Rust IMPEDE em tempo de compilacao
fn get_name() -> &str {
    let buf = String::from("Zig");
    &buf  // ERRO DE COMPILACAO: buf nao vive o suficiente
}

// Seguro, mas com custo de complexidade:
// lifetimes, borrowing rules, fighting the borrow checker

Zig: Verificacao em Runtime com Simplicidade

const std = @import("std");

// Zig: mais simples que Rust, mais seguro que C
fn getName(allocator: std.mem.Allocator) ![]u8 {
    // Explicito: alocacao no heap, chamador gerencia a memoria
    const buf = try allocator.alloc(u8, 32);
    @memcpy(buf[0..3], "Zig");
    return buf[0..3];
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const nome = try getName(gpa.allocator());
    defer gpa.allocator().free(nome);

    std.debug.print("Nome: {s}\n", .{nome});
    // GPA detecta vazamentos ao final do programa
}

A posicao de Zig no espectro:

  • C: zero verificacoes, maximo controle, maximo risco.
  • Zig: verificacoes em runtime (Debug/Safe), controle total, risco mitigado.
  • Rust: verificacoes em compilacao (borrow checker), controle alto, risco minimo, complexidade maior.

Zig escolhe conscientemente nao ter um borrow checker porque a complexidade adicional no modelo mental do programador nem sempre compensa. Em vez disso, Zig aposta em verificacoes de runtime configuráveis, boas praticas com allocators e defer, e deteccao em testes.

Exemplos Praticos: Bugs que Zig Detecta e C Nao

Vamos ver cenarios reais onde Zig salva o programador:

const std = @import("std");

// Cenario 1: Off-by-one em loop
fn copiarDados(destino: []u8, origem: []const u8) void {
    // Em C: for(i = 0; i <= len; i++) — off-by-one silencioso
    // Em Zig: bounds checking detecta imediatamente
    const len = @min(destino.len, origem.len);
    @memcpy(destino[0..len], origem[0..len]);
}

// Cenario 2: Divisao por zero
fn media(valores: []const i32) !f64 {
    if (valores.len == 0) return error.ListaVazia;
    var soma: i64 = 0;
    for (valores) |v| soma += v;
    // Em Zig: divisao por zero em inteiros causa panic
    // (neste caso estamos protegidos pelo check acima)
    return @as(f64, @floatFromInt(soma)) / @as(f64, @floatFromInt(valores.len));
}

// Cenario 3: Leitura de memoria nao inicializada
fn exemploInicializacao() void {
    // Em C: int arr[10]; printf("%d", arr[0]); — lixo silencioso
    // Em Zig: variavel 'undefined' causa panic ao ser lida em Debug

    var valor: i32 = undefined;
    // std.debug.print("{}\n", .{valor}); // PANIC em Debug!
    // Para evitar: sempre inicialize
    valor = 42;
    std.debug.print("Valor inicializado: {}\n", .{valor});
}

// Cenario 4: Truncamento de tipo perigoso
fn conversaoSegura() void {
    const grande: u32 = 300;

    // Em C: (char)300 = 44, silenciosamente truncado
    // Em Zig: intCast verifica se o valor cabe no tipo destino
    const resultado = std.math.cast(u8, grande);
    if (resultado) |val| {
        std.debug.print("Conversao ok: {}\n", .{val});
    } else {
        std.debug.print("AVISO: {} nao cabe em u8!\n", .{grande});
    }
}

pub fn main() !void {
    var dest: [10]u8 = undefined;
    copiarDados(&dest, "Zig!");
    std.debug.print("Copiado: {s}\n", .{dest[0..4]});

    const nums = [_]i32{ 10, 20, 30 };
    const m = try media(&nums);
    std.debug.print("Media: {d:.1}\n", .{m});

    exemploInicializacao();
    conversaoSegura();
}

Boas Praticas de Seguranca em Zig

Para maximizar a seguranca do seu codigo Zig:

  1. Desenvolva em Debug, teste em ReleaseSafe: todas as verificacoes ativas. Consulte o guia de debugging em Zig para aproveitar ao maximo essas verificacoes.
  2. Use GPA em testes: detecta vazamentos e uso apos liberacao.
  3. Prefira slices a ponteiros raw: slices carregam tamanho, ponteiros nao.
  4. Use defer para todo recurso: arquivo, memoria, mutex, socket.
  5. Prefira errdefer para rollback: limpar recursos parciais em caso de erro.
  6. Valide inputs com std.math.cast: em vez de @intCast que pode panic.
  7. Teste com fuzzing: zig build-exe -O Debug + fuzzing encontra bugs que testes unitarios perdem.

Conclusao

Zig ocupa um espaco unico no espectro de seguranca de memoria: mais seguro que C sem a complexidade do borrow checker de Rust. A filosofia e pragmatica: verificacoes de seguranca em runtime que detectam bugs durante desenvolvimento e testes, com a opcao de desativa-las em producao quando a performance exige. O sistema de allocators, optional types, bounds checking e deteccao de overflow formam um conjunto robusto de defesas que torna bugs de memoria muito mais dificeis de introduzir e muito mais faceis de detectar.

A melhor parte e que essas protecoes nao exigem sintaxe extra nem modelos mentais complexos. Voce escreve Zig simples e direto, e a linguagem cuida da seguranca por padrao.

Leia Tambem

Continue aprendendo Zig

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