Rust

Unsafe Rust — Quando e Como Usar Código Inseguro com Responsabilidade Já leu

14 min de leitura

Unsafe Rust — Quando e Como Usar Código Inseguro com Responsabilidade
Rust é famoso por suas garantias de segurança de memória em tempo de compilação. O borrow checker elimina use-after-free, double-free, data races e dangling pointers sem precisar de garbage collector. Mas existe uma saíd

Rust é famoso por suas garantias de segurança de memória em tempo de compilação. O borrow checker elimina use-after-free, double-free, data races e dangling pointers sem precisar de garbage collector. Mas existe uma saída deliberada desse sistema: o bloco unsafe.

unsafe não desativa o compilador. Ele transfere para você a responsabilidade de provar que certas invariantes são respeitadas — invariantes que o compilador não consegue verificar sozinho. Usado corretamente, é a ferramenta que permite construir abstrações seguras sobre código de baixo nível. Usado de forma irresponsável, é a porta de entrada para os mesmos bugs que Rust promete eliminar.

Neste artigo exploramos o que unsafe habilita, quando seu uso é justificado, como contê-lo com o padrão de abstração segura, e as armadilhas mais comuns.


O que unsafe realmente habilita

Ao entrar em um bloco unsafe, você ganha acesso a cinco capacidades adicionais que o Rust normalmente proíbe:

  1. Desreferenciar ponteiros raw (*const T e *mut T)
  2. Chamar funções unsafe — incluindo funções extern de FFI
  3. Acessar ou modificar variáveis estáticas mutáveis (static mut)
  4. Implementar traits unsafe (Send, Sync, GlobalAlloc)
  5. Acessar campos de unions

É só isso. O borrow checker continua ativo. O sistema de tipos continua intacto. Você não pode criar referências inválidas "magicamente" apenas por estar em um bloco unsafe — você continua sujeito às regras de lifetime e mutabilidade para referências seguras.

fn main() {
    let x = 42i32;
    let ponteiro: *const i32 = &x;

    // Fora do unsafe: não é possível desreferenciar ponteiros raw
    // let valor = *ponteiro; // erro de compilação

    // Dentro do unsafe: você afirma que o ponteiro é válido
    let valor = unsafe { *ponteiro };
    println!("Valor: {valor}"); // 42
}

Quando usar unsafe

A resposta honesta é: com a menor frequência possível, e apenas quando não há alternativa segura viável. Os casos legítimos recorrentes são quatro.

1. FFI — Foreign Function Interface

Chamar código C, C++ ou qualquer biblioteca externa exige unsafe porque o compilador Rust não pode verificar o comportamento desse código:

use std::ffi::CString;
use std::os::raw::c_char;

// Declaração de função externa (C)
extern "C" {
    fn strlen(s: *const c_char) -> usize;
}

fn comprimento_string_c(s: &str) -> usize {
    let c_string = CString::new(s).expect("string contém nul byte");

    // unsafe porque:
    // 1. chamamos código externo cujo comportamento não verificamos
    // 2. passamos um ponteiro raw
    unsafe { strlen(c_string.as_ptr()) }
}

fn main() {
    println!("{}", comprimento_string_c("hello")); // 5
}

2. Operações com ponteiros raw para performance

Algoritmos que manipulam memória diretamente — como parsers de alto desempenho, codecs, ou implementações de estruturas de dados — às vezes precisam de aritmética de ponteiros:

/// Copia `n` bytes de `src` para `dst`.
/// # Safety
/// - `src` e `dst` devem ser válidos para leitura e escrita de `n` bytes
/// - As regiões não devem se sobrepor (use `copy` se puderem se sobrepor)
unsafe fn copiar_bytes(dst: *mut u8, src: *const u8, n: usize) {
    for i in 0..n {
        *dst.add(i) = *src.add(i);
    }
}

3. Construir abstrações que o borrow checker não consegue verificar

O exemplo clássico é dividir um slice em duas partes mutáveis. O borrow checker proibiria duas referências mutáveis ao mesmo slice, mas em regiões não sobrepostas isso é seguro:

/// Divide um slice em duas metades mutáveis sem sobreposição.
fn dividir_em_dois(slice: &mut [i32]) -> (&mut [i32], &mut [i32]) {
    let meio = slice.len() / 2;
    let ptr = slice.as_mut_ptr();

    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, meio),
            std::slice::from_raw_parts_mut(ptr.add(meio), slice.len() - meio),
        )
    }
}

fn main() {
    let mut numeros = vec![1, 2, 3, 4, 5, 6];
    let (esquerda, direita) = dividir_em_dois(&mut numeros);

    esquerda[0] = 10;
    direita[0] = 40;

    println!("{:?}", numeros); // [10, 2, 3, 40, 5, 6]
}

4. Implementar alocadores e coleções personalizadas

Estruturas como Vec, HashMap e Box da stdlib são internamente construídas sobre unsafe. Se você precisar de uma estrutura de dados com semântica de memória diferente — como um arena allocator ou um ring buffer lock-free — precisará do mesmo.


O padrão fundamental: abstrações seguras sobre código unsafe

A regra de ouro do unsafe em Rust é: isole o código unsafe na menor região possível e exponha uma interface completamente segura para o exterior. Isso é o que a stdlib inteira faz.

/// Um buffer circular de tamanho fixo.
/// Internamente usa aritmética de ponteiros, mas a API é completamente segura.
pub struct RingBuffer<T, const N: usize> {
    dados: [std::mem::MaybeUninit<T>; N],
    leitura: usize,
    escrita: usize,
    tamanho: usize,
}

impl<T, const N: usize> RingBuffer<T, N> {
    pub fn novo() -> Self {
        RingBuffer {
            dados: unsafe { std::mem::MaybeUninit::uninit().assume_init() },
            leitura: 0,
            escrita: 0,
            tamanho: 0,
        }
    }

    /// Insere um elemento. Retorna false se o buffer estiver cheio.
    pub fn push(&mut self, valor: T) -> bool {
        if self.tamanho == N {
            return false;
        }

        // Safety: escrita está sempre dentro dos limites [0, N)
        // e a posição não está inicializada ou já foi lida.
        unsafe {
            self.dados[self.escrita].write(valor);
        }

        self.escrita = (self.escrita + 1) % N;
        self.tamanho += 1;
        true
    }

    /// Remove e retorna o próximo elemento, ou None se vazio.
    pub fn pop(&mut self) -> Option<T> {
        if self.tamanho == 0 {
            return None;
        }

        // Safety: leitura aponta para um elemento inicializado,
        // garantido pelo invariante: tamanho > 0.
        let valor = unsafe {
            self.dados[self.leitura].assume_init_read()
        };

        self.leitura = (self.leitura + 1) % N;
        self.tamanho -= 1;
        Some(valor)
    }

    pub fn tamanho(&self) -> usize {
        self.tamanho
    }
}

// Drop correto para elementos inicializados
impl<T, const N: usize> Drop for RingBuffer<T, N> {
    fn drop(&mut self) {
        while self.pop().is_some() {}
    }
}

Quem usa RingBuffer não vê nenhum unsafe. O contrato com o compilador é interno ao módulo.


Documentando safety com # Safety

Toda função unsafe que você escreve deve ter uma seção # Safety no doc comment explicando:

  • Quais são os pré-requisitos para chamá-la corretamente
  • O que acontece se eles forem violados (undefined behavior)
/// Retorna uma referência ao elemento no índice sem checar limites.
///
/// # Safety
///
/// O chamador deve garantir que:
/// - `indice` é menor que o comprimento do slice
/// - O slice não é modificado enquanto a referência estiver em uso
///
/// Violar essas condições resulta em undefined behavior.
pub unsafe fn get_unchecked_manual<T>(slice: &[T], indice: usize) -> &T {
    &*slice.as_ptr().add(indice)
}

Esta documentação serve dois propósitos: ajuda quem chama a usar a função corretamente, e força você a articular explicitamente o que está assumindo — o que frequentemente revela bugs de lógica antes mesmo de executar o código.


Unsafe traits: Send e Sync

Alguns traits são marcados como unsafe porque implementá-los incorretamente pode causar undefined behavior mesmo em código seguro que os usa. Os mais importantes são Send e Sync:

use std::sync::atomic::{AtomicUsize, Ordering};

/// Contador atômico que pode ser compartilhado entre threads.
pub struct ContadorAtomico {
    valor: AtomicUsize,
}

impl ContadorAtomico {
    pub fn novo(inicial: usize) -> Self {
        ContadorAtomico {
            valor: AtomicUsize::new(inicial),
        }
    }

    pub fn incrementar(&self) -> usize {
        self.valor.fetch_add(1, Ordering::SeqCst)
    }

    pub fn ler(&self) -> usize {
        self.valor.load(Ordering::SeqCst)
    }
}

// Safety: AtomicUsize é thread-safe por design.
// Não há estado não-atômico nesta struct.
unsafe impl Send for ContadorAtomico {}
unsafe impl Sync for ContadorAtomico {}

Normalmente você não precisa implementar Send e Sync manualmente — o compilador os deriva automaticamente quando todos os campos os implementam. A implementação manual é necessária quando você usa ponteiros raw ou tipos explicitamente não-Send/Sync internamente mas pode provar a segurança de thread por outros meios.


FFI na prática: integrando com uma biblioteca C

Um cenário real de FFI envolve criar bindings para uma biblioteca C existente. Veja o padrão completo:

// build.rs — diz ao Rust para linkar com a biblioteca
fn main() {
    println!("cargo:rustc-link-lib=crypto"); // -lcrypto (OpenSSL)
    println!("cargo:rustc-link-search=native=/usr/lib");
}
use std::ffi::CStr;
use std::os::raw::{c_char, c_int, c_uchar, c_uint};

// Tipos opacos da biblioteca C
#[repr(C)]
pub struct EvpMdCtx {
    _privado: [u8; 0],
}

// Declarações FFI
extern "C" {
    fn EVP_MD_CTX_new() -> *mut EvpMdCtx;
    fn EVP_MD_CTX_free(ctx: *mut EvpMdCtx);
    fn EVP_DigestInit_ex(ctx: *mut EvpMdCtx, tipo: *const u8, engine: *const u8) -> c_int;
    fn EVP_DigestUpdate(ctx: *mut EvpMdCtx, dados: *const u8, len: usize) -> c_int;
    fn EVP_DigestFinal_ex(ctx: *mut EvpMdCtx, md: *mut c_uchar, s: *mut c_uint) -> c_int;
    fn EVP_sha256() -> *const u8;
}

/// Wrapper seguro em torno do contexto de digest OpenSSL.
/// O Drop garante que EVP_MD_CTX_free seja sempre chamado.
pub struct Sha256 {
    ctx: *mut EvpMdCtx,
}

impl Sha256 {
    pub fn novo() -> Result<Self, String> {
        let ctx = unsafe { EVP_MD_CTX_new() };

        if ctx.is_null() {
            return Err("falha ao alocar contexto EVP".into());
        }

        let resultado = unsafe {
            EVP_DigestInit_ex(ctx, EVP_sha256(), std::ptr::null())
        };

        if resultado != 1 {
            unsafe { EVP_MD_CTX_free(ctx) };
            return Err("falha ao inicializar digest SHA-256".into());
        }

        Ok(Sha256 { ctx })
    }

    pub fn atualizar(&mut self, dados: &[u8]) -> Result<(), String> {
        let resultado = unsafe {
            EVP_DigestUpdate(self.ctx, dados.as_ptr(), dados.len())
        };

        if resultado != 1 {
            Err("falha ao atualizar digest".into())
        } else {
            Ok(())
        }
    }

    pub fn finalizar(self) -> Result<[u8; 32], String> {
        let mut hash = [0u8; 32];
        let mut tamanho: c_uint = 32;

        let resultado = unsafe {
            EVP_DigestFinal_ex(self.ctx, hash.as_mut_ptr(), &mut tamanho)
        };

        if resultado != 1 {
            Err("falha ao finalizar digest".into())
        } else {
            Ok(hash)
        }
    }
}

impl Drop for Sha256 {
    fn drop(&mut self) {
        if !self.ctx.is_null() {
            unsafe { EVP_MD_CTX_free(self.ctx) };
        }
    }
}

O usuário desta API nunca vê um ponteiro raw. O Drop garante que vazamentos de memória sejam impossíveis mesmo em casos de erro.


Armadilhas comuns em código unsafe

Undefined Behavior — o inimigo invisível

Ao contrário de C, onde UB frequentemente "funciona" em compilações debug, o Rust com otimizações pode produzir comportamentos completamente inesperados diante de UB. As causas mais comuns:

fn exemplos_de_ub() {
    // 1. Desreferenciar ponteiro nulo
    let ptr: *const i32 = std::ptr::null();
    // unsafe { let _ = *ptr; } // UB: segfault ou pior

    // 2. Use-after-free
    let referencia: &i32;
    {
        let x = 42i32;
        referencia = unsafe {
            // UB: referência sobrevive ao valor
            &*((&x) as *const i32)
        };
    }
    // println!("{}", referencia); // UB: x foi dropado

    // 3. Criar referência não-alinhada
    let dados = [0u8; 8];
    let ptr_u32 = dados.as_ptr().wrapping_add(1) as *const u32;
    // unsafe { let _ = *ptr_u32; } // UB em arquiteturas que exigem alinhamento

    // 4. Transmute com tamanhos diferentes
    // unsafe { let _: u64 = std::mem::transmute(42u32); } // UB: tamanhos diferentes
}

transmute — a ferramenta mais perigosa

std::mem::transmute reinterpreta os bytes de um tipo como outro. É útil em casos específicos mas extremamente perigoso:

// USO LEGÍTIMO: converter entre representações numéricas compatíveis
fn f32_para_bits(f: f32) -> u32 {
    // Seguro: f32 e u32 têm o mesmo tamanho e qualquer padrão de bits é válido para u32
    unsafe { std::mem::transmute(f) }
}

// Prefira quando disponível:
fn f32_para_bits_seguro(f: f32) -> u32 {
    f.to_bits() // equivalente, sem unsafe
}

// USO PERIGOSO: converter lifetime de referências
fn estender_lifetime_errado<'a>(r: &'a str) -> &'static str {
    // NUNCA FAÇA ISSO — UB se o valor original for dropado
    unsafe { std::mem::transmute(r) }
}

Data races com static mut

// PERIGOSO: acesso concorrente a static mut é UB
static mut CONTADOR: u32 = 0;

fn incrementar_inseguro() {
    unsafe {
        CONTADOR += 1; // data race se chamado de múltiplas threads
    }
}

// CORRETO: use AtomicU32
use std::sync::atomic::{AtomicU32, Ordering};

static CONTADOR_SEGURO: AtomicU32 = AtomicU32::new(0);

fn incrementar_seguro() {
    CONTADOR_SEGURO.fetch_add(1, Ordering::Relaxed);
}

Ferramentas para detectar bugs em código unsafe

Miri — interpretador de MIR

Miri é a ferramenta mais poderosa para detectar UB em código unsafe. Ele executa seu programa em um ambiente interpretado que detecta acessos inválidos, use-after-free, data races e outros problemas:

# Instalar Miri
rustup component add miri

# Executar testes com Miri
cargo miri test

# Executar o programa com Miri
cargo miri run
#[cfg(test)]
mod testes {
    #[test]
    fn teste_ring_buffer() {
        let mut buf: super::RingBuffer<i32, 4> = super::RingBuffer::novo();

        assert!(buf.push(1));
        assert!(buf.push(2));
        assert!(buf.push(3));
        assert!(buf.push(4));
        assert!(!buf.push(5)); // cheio

        assert_eq!(buf.pop(), Some(1));
        assert_eq!(buf.pop(), Some(2));
        assert!(buf.push(5)); // agora há espaço
        assert_eq!(buf.pop(), Some(3));
    }
}
// Execute: cargo miri test
// Miri detectaria qualquer acesso inválido ao MaybeUninit

AddressSanitizer e Valgrind

# AddressSanitizer — detecta buffer overflows e use-after-free
RUSTFLAGS="-Z sanitizer=address" cargo +nightly test --target x86_64-unknown-linux-gnu

# ThreadSanitizer — detecta data races
RUSTFLAGS="-Z sanitizer=thread" cargo +nightly test --target x86_64-unknown-linux-gnu

Checklist antes de escrever código unsafe

Antes de adicionar qualquer bloco unsafe, percorra esta lista:

  1. Existe uma alternativa segura? Verifique se a stdlib ou crates como bytemuck, zerocopy, ou memmap2 já resolvem o problema com segurança.
  2. O código unsafe está no menor escopo possível? Prefira blocos unsafe pequenos e precisos a funções inteiras marcadas como unsafe.
  3. Você documentou as invariantes de safety? Escreva a seção # Safety antes de implementar, não depois.
  4. Você tem testes que cobrem o código unsafe? Execute com Miri.
  5. A API exposta é completamente segura? Quem usa sua abstração não deveria precisar de unsafe.
  6. O Drop está correto? Recursos alocados manualmente precisam ser liberados mesmo em pânico.

unsafe na prática: o que a stdlib revela

Vale examinar como a stdlib usa unsafe para entender o padrão em escala real. Vec::push, por exemplo, usa internamente unsafe para escrever no buffer alocado, mas toda a lógica de bounds checking e realocação está no código seguro ao redor. O total de linhas unsafe na stdlib Rust é uma fração pequena do total — o resto é código seguro construído sobre essa fundação.

Esta é a filosofia: unsafe é o custo de construir primitivas. O objetivo é manter esse custo localizado, auditável e documentado — para que toda a camada de código de aplicação acima possa operar em segurança total.


Fontes e leituras recomendadas

Comentários

Mais em Rust

Ownership — A Ideia que Muda Tudo
Ownership — A Ideia que Muda Tudo

Chegamos ao artigo mais importante da s&eacute;rie. Tudo que aprendemos at&ea...

Programação de Sistemas — Arquivos, Processos e Sinais
Programação de Sistemas — Arquivos, Processos e Sinais

Rust nasceu como linguagem de sistemas. Não é por acaso que ela substituiu C+...

Performance e Otimização — Profiling, SIMD e Técnicas Avançadas
Performance e Otimização — Profiling, SIMD e Técnicas Avançadas

Rust é frequentemente descrito como uma linguagem que permite escrever código...