Rust · RustRust · Rust

Usar C++ desde Rust: cómo un envoltorio seguro convierte un use-after-free en un error de compilaciónUsing C++ from Rust: how a safe wrapper turns a use-after-free into a compile error

En esta páginaOn this page
  1. El mismo bug, escrito dos veces
  2. Qué es unsafe de verdad
  3. La frontera en C
  4. #[repr(C)] y el relleno
  5. El envoltorio seguro
  6. unsafe impl Send y Sync: el cuarto superpoder
  7. Pánicos y callbacks
  8. Sin sistema operativo debajo: #![no_std]
  9. Lo que me llevo
  1. The same bug, written twice
  2. What unsafe really is
  3. The C boundary
  4. #[repr(C)] and padding
  5. The safe wrapper
  6. unsafe impl Send and Sync: the fourth superpower
  7. Panics and callbacks
  8. No operating system underneath: #![no_std]
  9. What I take away

El mismo bug, escrito dos veces#

Esta entrada gira alrededor de una librería pequeña y real: un almacén clave-valor escrito en C++ sobre std::unordered_map, con una API en C, que se usa desde Rust a través de un envoltorio seguro. Todo el código compila y lo he ejecutado en mi equipo. Empecemos por un programa en C++ de veintiuna líneas:

dangling.cpp
// cl /std:c++20 /EHsc /W4 /Zi /fsanitize=address dangling.cpp kv.cpp
// El bug: guardar el puntero de kv_get y usarlo después de un kv_set.
#include "kv.h"
#include <cstdio>

int main() {
    kv* db = kv_new();
    // 27 caracteres: más de lo que std::string guarda dentro del propio
    // objeto (15 o 22, según la biblioteca), así que va al heap.
    kv_set(db, "email", "ana.garcia.tome@example.com");

    // Apunta dentro del std::string que guarda el valor.
    const char* email = kv_get(db, "email");

    // Un valor más largo no cabe en el búfer actual: std::string reserva
    // uno nuevo y libera el viejo, al que sigue apuntando `email`.
    kv_set(db, "email", "ana.garcia.fernandez.tome@example-company.com");

    std::printf("%s\n", email);  // lectura de memoria ya liberada
    kv_free(db);
}

Compila con cl /W4 sin un solo aviso, y también con g++ -Wall -Wextra -pedantic. Pero al ejecutarlo con AddressSanitizer, que vigila cada acceso a memoria mientras el programa se ejecuta, aparece esto.

Esta es la salida real en mi equipo, un Intel Core i9-14900KF con Windows 11, con MSVC y AddressSanitizer (un extracto: […] marca las líneas quitadas):

salidaoutput
==17532==ERROR: AddressSanitizer: heap-use-after-free on address 0x126bd17a0760 […]
READ of size 2 at 0x126bd17a0760 thread T0
    […]
    #12 0x7ff78e401094 in main dangling.cpp:19
    […]
0x126bd17a0760 is located 0 bytes inside of 32-byte region [0x126bd17a0760,0x126bd17a0780)
freed by thread T0 here:
    […]
    #8 0x7ff78e401926 in `anonymous namespace'::Store::set kv.cpp:22
    #9 0x7ff78e4012b6 in kv_set kv.cpp:53
    #10 0x7ff78e401083 in main dangling.cpp:17
    […]
previously allocated by thread T0 here:
    […]
    #10 0x7ff78e401926 in `anonymous namespace'::Store::set kv.cpp:22
    #11 0x7ff78e4012b6 in kv_set kv.cpp:53
    #12 0x7ff78e401055 in main dangling.cpp:10
    […]
SUMMARY: AddressSanitizer: heap-use-after-free […]

La traza se lee de abajo arriba. La memoria se reservó en la línea 10, con el primer kv_set. Se liberó en la línea 17, con el segundo kv_set, dentro de std::string::operator= (kv.cpp:22). Y se leyó en la línea 19, en el printf: un uso después de liberar (use-after-free). La región de 32 bytes es el búfer donde vivía el primer valor en la biblioteca de MSVC.

El culpable no es la tabla hash. std::unordered_map no invalida los punteros ni las referencias a sus elementos cuando se reorganiza (el rehash); lo dice el estándar en [unord.req.general]. El culpable es el std::string: sus funciones miembro que no son const, como la asignación, pueden invalidar los punteros a su contenido ([string.require]). Si el valor nuevo no cabe en el búfer actual, la asignación reserva uno nuevo y libera el viejo, y email sigue apuntando al viejo.

Por eso el primer valor tiene 27 caracteres. Una cadena corta cabe dentro del propio objeto std::string, sin ir al heap: hasta 15 caracteres en MSVC y en libstdc++, hasta 22 en libc++. Con un valor corto no habría memoria liberada y AddressSanitizer no vería nada.

Ahora, el mismo bug escrito en Rust, con el envoltorio que veremos en esta entrada:

dangling.rs
// cargo build --example dangling --features show-bug
// No compila, a propósito.
// El mismo bug que dangling.cpp, escrito en Rust con el envoltorio.
use kvdemo::Kv;

fn main() {
    let mut db = Kv::new().unwrap();
    db.set("email", "ana.garcia.tome@example.com").unwrap();

    // email es un &str prestado de db
    let email = db.get("email").unwrap();

    db.set("email", "ana.garcia.fernandez.tome@example-company.com")
        .unwrap();

    println!("{email}");
}

Esta es la salida real en mi equipo, con cargo build:

salidaoutput
error[E0502]: cannot borrow `db` as mutable because it is also borrowed as immutable
  --> examples\dangling.rs:13:5
   |
11 |     let email = db.get("email").unwrap();
   |                 -- immutable borrow occurs here
12 |
13 |     db.set("email", "ana.garcia.fernandez.tome@example-company.com")
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
...
16 |     println!("{email}");
   |                ----- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `kvdemo` (example "dangling") due to 1 previous error

No compila. El borrow checker ve que email es un préstamo de db que sigue vivo en la línea 16, y no deja que set lo modifique mientras tanto.

Pero conviene ser precisos, porque es la idea de toda la entrada: Rust no sabe nada de std::string. El compilador no conoce las reglas de invalidación de C++. Lo que hace cumplir es el contrato que escribí en el envoltorio: «el puntero de kv_get vale hasta el siguiente kv_set o kv_free», expresado como un &str cuyo lifetime depende de &self, más un set que pide &mut self. Si el envoltorio estuviera mal escrito, por ejemplo si get devolviera un &'static str, compilaría y el bug volvería.

En Un secreto que ejecutaba código conté que Rust elimina clases enteras de fallos de memoria, pero no los fallos de significado. Aquí se ve la otra cara: un fallo de memoria de C++ que Rust sí impide, porque el envoltorio lo expresa en tipos. El borrow checker está haciendo cumplir una regla de C++ porque alguien la escribió en los tipos de Rust.

Qué es unsafe de verdad#

Para escribir ese envoltorio hace falta unsafe, y es fácil entenderlo mal. unsafe no desactiva el borrow checker ni ninguna otra comprobación del compilador. Según el libro de Rust, desbloquea exactamente cinco superpoderes:

  1. desreferenciar un puntero crudo;
  2. llamar a una función unsafe, incluida cualquiera declarada a través de FFI;
  3. acceder a una variable static mut o modificarla;
  4. implementar un trait unsafe;
  5. acceder a los campos de una union.

Todo lo demás se sigue comprobando igual, dentro y fuera del bloque. Es lo que hace a Rust tan seguro, como contamos en Por qué Rust sigue siendo el lenguaje más seguro en 2026: esas garantías siguen activas, y unsafe solo abre esas cinco puertas.

En una frase

unsafe no apaga el compilador: desbloquea cinco operaciones y te hace responsable de que sean correctas. Todo lo demás se sigue comprobando.

En mi guía de Rust de sistemas lo resumo así: unsafe no es una forma de callar al compilador, sino una promesa tuya. Le dices al compilador que has comprobado a mano lo que él no puede comprobar. Si la promesa es falsa, el resultado es el mismo comportamiento indefinido que en C++.

En el código de esta entrada aparecen tres de los cinco:

Superpoder Dónde aparece
Desreferenciar un puntero crudo &mut *ctx.cast::<Ctx<F>>(), en el trampolín de for_each
Llamar a una función unsafe Cada llamada a ffi::kv_*
Implementar un trait unsafe unsafe impl Send y unsafe impl Sync
static mut y union No se usan

Y una convención que sigue todo el código: cada bloque unsafe lleva encima un comentario // SAFETY: que justifica por qué la promesa se cumple. Es la práctica habitual en Rust, y hace que cada promesa se pueda revisar.

La frontera en C#

Rust y C++ no se hablan directamente. Se hablan a través de C, que es el idioma común de casi todos los lenguajes: la convención de llamada de C y tipos que los dos lados entienden igual.

TU CÓDIGO Rust seguro: new, set, get, for_each ENVOLTORIO lib.rs: todo el unsafe, con su SAFETY extern "C": solo punteros, enteros y structs repr(C) API EN C kv.h: tipo opaco, códigos de error C++ kv.cpp: excepciones, std::unordered_map
Fig. 1 — La frontera. Por encima de la línea, Rust; por debajo, C++. Ni un pánico ni una excepción la cruzan: catch_unwind los detiene arriba y try/catch abajo.

Esta es toda la API, el archivo kv.h:

kv.h
// kv.h: API en C de un almacén clave-valor escrito en C++.
// Todo lo que cruza esta frontera es C: punteros, enteros y structs
// planos.
#pragma once
#include <stddef.h>
#include <stdint.h>

#ifdef __cplusplus
#define KV_NOEXCEPT noexcept
extern "C" {
#else
#define KV_NOEXCEPT
#endif

// Opaco: quien use la API solo maneja punteros, nunca ve el interior.
typedef struct kv kv;

// Códigos de error: las excepciones de C++ no cruzan la frontera.
enum { KV_OK = 0, KV_EINVAL = 1, KV_ENOMEM = 2, KV_EINTERNAL = 3 };

// Struct compartido por valor: el diseño en memoria tiene que coincidir
// byte a byte en los dos lados.
typedef struct kv_stats {
    uint8_t  version;  // versión del struct
    uint64_t entries;  // número de entradas
    uint32_t buckets;  // cubetas de la tabla hash
} kv_stats;

// Devuelve 0 para seguir recorriendo, cualquier otro valor para parar.
typedef int (*kv_visit_fn)(const char* key, const char* value, void* ctx);

// NULL si no hay memoria. kv_free acepta NULL.
kv*         kv_new(void) KV_NOEXCEPT;
void        kv_free(kv* self) KV_NOEXCEPT;
int         kv_set(kv* self, const char* key,
                   const char* value) KV_NOEXCEPT;
// El puntero devuelto es válido hasta el siguiente kv_set o kv_free.
const char* kv_get(const kv* self, const char* key) KV_NOEXCEPT;
void        kv_stats_get(const kv* self, kv_stats* out) KV_NOEXCEPT;
void        kv_for_each(const kv* self, kv_visit_fn visit,
                        void* ctx) KV_NOEXCEPT;

#ifdef __cplusplus
}
#endif

Cuatro decisiones que merece la pena mirar:

  • Tipo opaco. typedef struct kv kv; declara el tipo sin mostrar su contenido. Quien usa la API solo maneja punteros a kv y nunca ve lo que hay dentro, así que el interior puede cambiar sin romper a nadie.
  • Códigos de error en vez de excepciones. C no tiene excepciones, y Rust tampoco sabe qué hacer con ellas. Cada error se convierte en un número.
  • extern "C". Pide a C++ que use la convención de llamada de C y que no decore los nombres, así que desde fuera se ven como kv_new, kv_set…
  • La macro KV_NOEXCEPT. En C++17, noexcept forma parte del tipo de la función, así que la declaración del header tiene que coincidir con la definición. En C la macro no pone nada.

Por debajo de la frontera, la librería es C++ normal. Este fragmento de kv.cpp es la clase que guarda los datos, que lanza std::invalid_argument si la clave está vacía, y la función kv_set, que traduce cada excepción a un código de error:

kv.cpp
// C++ normal: lanza excepciones y usa la biblioteca estándar.
class Store {
public:
    void set(const char* key, const char* value) {
        if (key == nullptr || *key == '\0')
            throw std::invalid_argument("empty key");
        map_[key] = value;
    }
    const std::string* get(const char* key) const {
        auto it = map_.find(key);
        return it == map_.end() ? nullptr : &it->second;
    }
    const auto& items() const { return map_; }

private:
    std::unordered_map<std::string, std::string> map_;
};
kv.cpp
// Ninguna excepción sale de aquí: cada una se traduce a un código de
// error.
int kv_set(kv* self, const char* key, const char* value) noexcept {
    try {
        self->set(key, value);
        return KV_OK;
    } catch (const std::invalid_argument&) {
        return KV_EINVAL;
    } catch (const std::bad_alloc&) {
        return KV_ENOMEM;
    } catch (...) {
        return KV_EINTERNAL;
    }
}

kv_set está marcada noexcept, y eso no hace magia. Si una excepción intentara salir de una función noexcept, C++ llamaría a std::terminate ([except.handle]): el programa abortaría igual, aunque de forma definida, en vez de desenrollar la pila hacia Rust. Por eso kv_set las captura todas y las convierte en códigos. Lo mismo vale para kv_for_each, que llama a un callback que no controla:

kv.cpp
// noexcept: si el callback lanzara, std::terminate en vez de desenrollar
// la pila hacia quien nos llamó.
void kv_for_each(const kv* self, kv_visit_fn visit, void* ctx) noexcept {
    for (const auto& [key, value] : self->items())
        if (visit(key.c_str(), value.c_str(), ctx) != 0)
            break;
}
La regla de oro

Por la frontera en C no cruza nada que se desenrolle: ni excepciones de C++ ni pánicos de Rust. Se capturan en su lado y se convierten en datos.

Y hay una línea del header que es la más importante de todas, aunque parezca un comentario más: «El puntero devuelto es válido hasta el siguiente kv_set o kv_free». Es el contrato de kv_get, y es la regla que el envoltorio va a convertir en un lifetime.

Así se usa la librería desde Rust, con el envoltorio seguro:

main.rs
// cargo run
// Usa la librería C++ desde Rust a través del envoltorio seguro.
use kvdemo::{Kv, KvError, ffi::KvStats};

// Los mismos campos que KvStats, con el diseño por defecto de Rust.
#[allow(dead_code)]
struct StatsRustLayout {
    version: u8,
    entries: u64,
    buckets: u32,
}

fn main() -> Result<(), KvError> {
    println!("size_of KvStats (repr C) = {}", size_of::<KvStats>());
    println!(
        "size_of same fields (repr Rust) = {}",
        size_of::<StatsRustLayout>()
    );

    let mut db = Kv::new()?;
    db.set("lang", "rust")?;
    db.set("ffi", "c++")?;
    println!("get(lang) = {:?}", db.get("lang"));
    println!("get(nope) = {:?}", db.get("nope"));

    // Errores de C++ (una excepción) y de Rust (un NUL dentro de la
    // cadena)
    println!("set(\"\") = {:?}", db.set("", "x"));
    println!("set(\"a\\0b\") = {:?}", db.set("a\0b", "x"));

    let s = db.stats();
    println!(
        "stats: version={} entries={} buckets={}",
        s.version, s.entries, s.buckets
    );

    let mut keys = Vec::new();
    db.for_each(|k, _| {
        keys.push(k.to_owned());
        true
    });
    keys.sort();
    println!("keys = {keys:?}");
    Ok(())
}

Esta es la salida real en mi equipo, con cargo run:

salidaoutput
size_of KvStats (repr C) = 24
size_of same fields (repr Rust) = 16
get(lang) = Some("rust")
get(nope) = None
set("") = Err(InvalidKey)
set("a\0b") = Err(InteriorNul)
stats: version=1 entries=2 buckets=8
keys = ["ffi", "lang"]

Los errores llegan desde los dos lados de la frontera. set("") devuelve Err(InvalidKey): es una excepción de C++, std::invalid_argument, convertida en código de error por kv_set. set("a\0b") devuelve Err(InteriorNul) sin llegar a C++: una cadena de C termina en el primer NUL, así que no puede contener uno dentro, y CString::new la rechaza antes de cruzar.

Un detalle de la salida: buckets=8 es lo que hace la biblioteca estándar de MSVC. Otra biblioteca estándar da otro número de cubetas.

#[repr(C)] y el relleno#

Además de punteros y enteros, por la frontera cruza un struct por valor: kv_stats. Para eso, los dos lados tienen que estar de acuerdo en su diseño en memoria, byte a byte. Con tres campos, uint8_t version, uint64_t entries y uint32_t buckets, las reglas de alineación de C dan:

Campo Bytes
version 1
Relleno, para alinear entries a 8 7
entries 8
buckets 4
Relleno final, para que el tamaño sea múltiplo de 8 4
Total 24

Los dos lados lo comprueban al compilar. En C++, en kv.cpp:

kv.cpp
// El diseño de kv_stats es parte de la API: si cambia, el build falla
// aquí. 1 + 7 (relleno) + 8 + 4 + 4 (relleno) = 24 bytes.
static_assert(sizeof(kv_stats) == 24);
static_assert(alignof(kv_stats) == 8);

Y en Rust, en lib.rs, con el mismo struct marcado #[repr(C)] y una aserción que se evalúa en tiempo de compilación:

lib.rs
    /// El mismo diseño en memoria que `kv_stats` en C.
    #[repr(C)]
    #[derive(Debug, Default, Clone, Copy)]
    pub struct KvStats {
        pub version: u8,
        pub entries: u64,
        pub buckets: u32,
    }
lib.rs
// Si el diseño en memoria no coincide con el de C, no compila.
const _: () = assert!(size_of::<ffi::KvStats>() == 24);
const _: () = assert!(align_of::<ffi::KvStats>() == 8);

Si algún lado cambia, no compila. Las dos primeras líneas de la salida de cargo run muestran por qué hace falta #[repr(C)]: 24 bytes con él, y 16 con los mismos campos y el diseño por defecto de Rust (el struct StatsRustLayout de main.rs).

Esos 16 bytes merecen un matiz: no están garantizados. La Referencia de Rust dice que, con el diseño por defecto, el orden de los campos no está especificado. 16 es lo que hace este compilador, rustc 1.98.1, que coloca los campos de otra forma para ahorrar relleno. Lo único garantizado es #[repr(C)].

En mi guía de C++ de sistemas enseño lo mismo desde el otro lado. struct Bad { char a; int x; char b; } ocupa unos 12 bytes, y struct Good { int x; char a; char b; }, con los mismos campos en otro orden, unos 8. En C++ reordenas tú a mano. Rust reordena solo cuando no le pides repr(C), y precisamente por eso un struct que cruza la frontera tiene que llevarlo. En mi guía de Rust lo dejé escrito así: sin #[repr(C)], un struct compartido con C no tiene ninguna garantía de diseño.

El envoltorio seguro#

Todo el unsafe del crate vive en un solo archivo, lib.rs. Vamos por partes.

Primero, la declaración de las funciones de C:

lib.rs
    unsafe extern "C" {
        // No recibe punteros: llamarla no puede romper nada.
        pub safe fn kv_new() -> *mut Kv;
        pub fn kv_free(this: *mut Kv);
        pub fn kv_set(
            this: *mut Kv,
            key: *const c_char,
            value: *const c_char,
        ) -> c_int;
        pub fn kv_get(
            this: *const Kv,
            key: *const c_char,
        ) -> *const c_char;
        pub fn kv_stats_get(this: *const Kv, out: *mut KvStats);
        pub fn kv_for_each(
            this: *const Kv,
            visit: KvVisitFn,
            ctx: *mut c_void,
        );
    }

Desde la edición 2024, los bloques extern tienen que llevar unsafe: declarar una función externa ya es una promesa sobre su firma, que el compilador no puede comprobar. Dentro del bloque, cada función se puede marcar safe, estable desde Rust 1.82. Aquí kv_new es safe fn porque no recibe punteros: llamarla no puede romper nada. Las demás siguen siendo unsafe de llamar.

Después, el tipo opaco del lado de Rust:

lib.rs
    /// Tipo opaco: no tiene campos accesibles ni tamaño conocido, y el
    /// marcador lo deja sin Send, Sync ni Unpin automáticos.
    #[repr(C)]
    pub struct Kv {
        _data: (),
        _marker: PhantomData<(*mut u8, PhantomPinned)>,
    }

Es la forma que recomienda el Nomicon: un struct sin campos útiles, con un marcador PhantomData<(*mut u8, PhantomPinned)> que lo deja sin Send, Sync ni Unpin automáticos. Un puntero crudo no es ni Send ni Sync, y PhantomPinned quita Unpin, así que Rust no supone nada que no sepamos. Mi guía usaba _private: [u8; 0]; el Nomicon añade el marcador.

Luego, el tipo público: un puntero no nulo al objeto de C++, y un Drop que lo libera.

lib.rs
/// El almacén, visto desde Rust: dueño único del objeto C++.
pub struct Kv {
    ptr: NonNull<ffi::Kv>,
}
lib.rs
impl Drop for Kv {
    fn drop(&mut self) {
        // SAFETY: el puntero salió de kv_new y se libera una sola vez.
        unsafe { ffi::kv_free(self.ptr.as_ptr()) };
    }
}

Esto es RAII, lo mismo que en C++. En mi guía de Rust lo describo así: Drop es el destructor de Rust, se llama automáticamente, exactamente una vez. En la de C++ lo explico con una clase File que abre el archivo con fopen en el constructor y lo cierra con fclose en el destructor. Aquí, el objeto de C++ se libera con kv_free cuando el Kv sale de ámbito, pase lo que pase.

Y ahora, el corazón de la entrada, set y get:

lib.rs
    pub fn set(&mut self, key: &str, value: &str) -> Result<(), KvError> {
        let key = CString::new(key).map_err(|_| KvError::InteriorNul)?;
        let value =
            CString::new(value).map_err(|_| KvError::InteriorNul)?;
        // SAFETY: el puntero es válido mientras exista self, y &mut self
        // garantiza que nadie más lo usa. Las dos cadenas acaban en NUL y
        // viven hasta el final de la llamada.
        let code = unsafe {
            ffi::kv_set(self.ptr.as_ptr(), key.as_ptr(), value.as_ptr())
        };
        match code {
            ffi::KV_OK => Ok(()),
            ffi::KV_EINVAL => Err(KvError::InvalidKey),
            ffi::KV_ENOMEM => Err(KvError::OutOfMemory),
            _ => Err(KvError::Internal),
        }
    }

    /// El &str devuelto apunta a memoria de C++, y su lifetime está
    /// atado a &self: mientras exista, nadie puede llamar a set, que pide
    /// &mut self.
    ///
    /// ```compile_fail,E0502
    /// let mut db = kvdemo::Kv::new().unwrap();
    /// db.set("k", "ana.garcia.tome@example.com").unwrap();
    /// let v = db.get("k").unwrap();
    /// db.set("k", "ana.garcia.fernandez.tome@example-company.com")
    ///     .unwrap();
    /// println!("{v}");
    /// ```
    pub fn get(&self, key: &str) -> Option<&str> {
        let key = CString::new(key).ok()?;
        // SAFETY: puntero válido y clave acabada en NUL.
        let value =
            unsafe { ffi::kv_get(self.ptr.as_ptr(), key.as_ptr()) };
        if value.is_null() {
            return None;
        }
        // SAFETY: kv_get devuelve una cadena acabada en NUL que vive
        // hasta el siguiente kv_set o kv_free, y ninguno de los dos puede
        // ocurrir mientras viva el préstamo de self del que sale este
        // &str.
        unsafe { CStr::from_ptr(value) }.to_str().ok()
    }

Aquí está el contrato del header, traducido a tipos. set pide &mut self: un préstamo exclusivo. get pide &self y devuelve un Option<&str> cuyo lifetime está atado a ese &self. Mientras el &str exista, el préstamo de db sigue vivo, y nadie puede conseguir el &mut self que pide set. El último comentario SAFETY de get es la clave del bug del principio: el &str puede apuntar a memoria de C++ porque el contrato de kv_get y las reglas de préstamos de Rust dicen lo mismo.

Encima de get hay un doctest marcado compile_fail,E0502, con el mismo bug. cargo test lo compila en cada ejecución y el test pasa solo si no compila, y con ese error. Si alguien cambiara el envoltorio y el bug volviera a compilar, el test fallaría.

Curiosidad

Un doctest compile_fail es un test que pasa si el código no compila. Convierte «esto no debería compilar» en algo que se comprueba cada vez, en vez de una promesa en un comentario.

Esta es la salida real en mi equipo, con cargo test:

salidaoutput
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test a_panic_in_the_callback_comes_back_to_rust ... ok
test errors_from_both_sides_of_the_boundary ... ok
test for_each_visits_every_entry_and_can_stop ... ok
test set_get_and_overwrite ... ok
test stats_cross_the_boundary_by_value ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test src\lib.rs - Kv::get (line 119) - compile fail ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s

La última línea con ok es ese doctest: compile fail ... ok.

El envoltorio impide otro bug más. Este programa intenta modificar el almacén mientras lo recorre:

reentrant.rs
// cargo build --example reentrant --features show-bug
// No compila, a propósito: modificar el almacén mientras se recorre.
// En C++, un kv_set dentro del callback de kv_for_each podría provocar
// un rehash e invalidar el iterador.
use kvdemo::Kv;

fn main() {
    let mut db = Kv::new().unwrap();
    db.set("a", "1").unwrap();
    db.for_each(|k, _| {
        db.set(&format!("{k}-copy"), "x").unwrap();
        true
    });
}

Esta es la salida real en mi equipo, con cargo build:

salidaoutput
error[E0502]: cannot borrow `db` as mutable because it is also borrowed as immutable
  --> examples\reentrant.rs:10:17
   |
10 |     db.for_each(|k, _| {
   |     -- -------- ^^^^^^ mutable borrow occurs here
   |     |  |
   |     |  immutable borrow later used by call
   |     immutable borrow occurs here
11 |         db.set(&format!("{k}-copy"), "x").unwrap();
   |         -- second borrow occurs due to use of `db` in closure

For more information about this error, try `rustc --explain E0502`.
error: could not compile `kvdemo` (example "reentrant") due to 1 previous error

for_each pide &self y el closure necesita &mut db para llamar a set: los dos préstamos chocan. En C++ el equivalente, llamar a kv_set dentro del callback de kv_for_each, compila sin problema. Y si la inserción provoca un rehash, invalida el iterador que está usando el recorrido ([unord.req.general]).

unsafe impl Send y Sync: el cuarto superpoder#

El tipo opaco no es ni Send ni Sync, así que Kv tampoco lo sería, y no se podría usar desde varios hilos. Para permitirlo hacen falta dos promesas explícitas:

lib.rs
// SAFETY: Kv es el único dueño del objeto C++, y el objeto no depende del
// hilo que lo creó: puede pasar de un hilo a otro.
unsafe impl Send for Kv {}

// SAFETY: con &Kv solo se llega a kv_get, kv_stats_get y kv_for_each, que
// solo llaman a funciones miembro const de std::unordered_map. La
// biblioteca estándar de C++ garantiza que esas llamadas, hechas a la vez
// desde varios hilos, no provocan carreras de datos ([res.on.data.races]).
unsafe impl Sync for Kv {}

Send es fácil: Kv es el único dueño del objeto de C++, y el objeto no depende del hilo que lo creó. Sync es más delicado, porque significa que varios hilos pueden usar &Kv a la vez. La justificación está en el estándar de C++, en [res.on.data.races]: una función de la biblioteca estándar solo modifica los objetos a los que accede a través de argumentos que no son const, incluido this. Por eso varias llamadas a funciones const del mismo unordered_map, desde varios hilos a la vez, no provocan carreras de datos. Y con un &Kv solo se llega a kv_get, kv_stats_get y kv_for_each, que solo llaman a funciones miembro const.

Ojo, porque esa promesa depende del código de hoy. Si mañana alguien añade a kv_get una caché que se modifica en cada lectura, la promesa se rompe y el compilador no lo notará. Es exactamente lo que significa unsafe: el compilador te cree.

Con la promesa hecha, cuatro hilos pueden leer a la vez:

threads.rs
// cargo run --example threads
// Cuatro hilos leen a la vez del mismo almacén C++. Compila porque Kv es
// Sync: la promesa de `unsafe impl Sync` en lib.rs.
use kvdemo::Kv;
use std::thread;

fn main() {
    let mut db = Kv::new().unwrap();
    for i in 0..1000 {
        db.set(&format!("key{i}"), &format!("value{i}")).unwrap();
    }

    let db = &db; // a partir de aquí, solo lectura
    let found: usize = thread::scope(|s| {
        let workers: Vec<_> = (0..4)
            .map(|t| {
                s.spawn(move || {
                    (0..1000)
                        .filter(|i| i % 4 == t)
                        .filter(|i| db.get(&format!("key{i}")).is_some())
                        .count()
                })
            })
            .collect();
        workers.into_iter().map(|w| w.join().unwrap()).sum()
    });

    println!("found {found} of 1000 keys from 4 threads");
}

Esta es la salida real en mi equipo, con cargo run:

salidaoutput
found 1000 of 1000 keys from 4 threads

Y para ver que la promesa es necesaria, comenté temporalmente unsafe impl Sync for Kv {}. Esta es la salida real en mi equipo, con cargo build (las primeras 15 líneas):

salidaoutput
error[E0277]: `NonNull<kvdemo::ffi::Kv>` cannot be shared between threads safely
   --> examples\threads.rs:17:25
    |
 17 |                   s.spawn(move || {
    |  ___________________-----_^
    | |                   |
    | |                   required by a bound introduced by this call
 18 | |                     (0..1000)
 19 | |                         .filter(|i| i % 4 == t)
 20 | |                         .filter(|i| db.get(&format!("key{i}")).is_some())
 21 | |                         .count()
 22 | |                 })
    | |_________________^ `NonNull<kvdemo::ffi::Kv>` cannot be shared between threads safely
    |
    = help: within `kvdemo::Kv`, the trait `Sync` is not implemented for `NonNull<kvdemo::ffi::Kv>`
[…]

Rust no deja compartir un puntero crudo entre hilos hasta que alguien se hace responsable.

Pánicos y callbacks#

kv_for_each llama a una función de Rust desde C++. ¿Qué pasa si esa función hace panic!? Este programa lo prueba sin el envoltorio, llamando directamente a la API en C:

panic_abort.rs
// cargo run --example panic_abort
// Un callback extern "C" que hace panic, sin el envoltorio: el pánico
// intenta salir de una función extern "C" y el proceso aborta.
use kvdemo::ffi;
use std::ffi::{c_char, c_int, c_void};

extern "C" fn visit(
    _key: *const c_char,
    _value: *const c_char,
    _ctx: *mut c_void,
) -> c_int {
    panic!("something went wrong in the callback");
}

fn main() {
    let db = ffi::kv_new();
    // SAFETY: db sale de kv_new; los literales c"..." acaban en NUL.
    unsafe {
        ffi::kv_set(db, c"lang".as_ptr(), c"rust".as_ptr());
        ffi::kv_for_each(db, visit, std::ptr::null_mut());
        ffi::kv_free(db);
    }
    println!("never printed");
}

(Los literales c"lang" son cadenas de C con su NUL final, escritas directamente en Rust; son estables desde Rust 1.77.)

Esta es la salida real en mi equipo, con cargo run (un extracto, sin la traza):

salidaoutput
thread 'main' (14964) panicked at examples\panic_abort.rs:12:5:
something went wrong in the callback
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'main' (14964) panicked at /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library\core\src\panicking.rs:225:5:
panic in a function that cannot unwind
stack backtrace:
   […]
thread caused non-unwinding panic. aborting.

El pánico intenta salir de una función extern "C", que no puede desenrollar la pila, y el proceso aborta. En la traza completa aparece kv_for_each (kv.cpp:83): el pánico ocurrió dentro de una llamada desde C++. Abortar es lo que hace Rust desde la versión 1.81; antes, un pánico que salía de una función extern "C" era comportamiento indefinido. El caso contrario no ha cambiado: una excepción de C++ que cruza una frontera extern "C" hacia Rust sigue siendo comportamiento indefinido, según el Nomicon.

La solución está en Kv::for_each: un trampolín, una función extern "C" genérica que hace de puente entre C++ y el closure:

lib.rs
    /// Recorre las entradas hasta que `visit` devuelve false. Si `visit`
    /// hace panic, el pánico no atraviesa el código C++: se captura en el
    /// trampolín y se relanza aquí, ya de vuelta en Rust.
    pub fn for_each<F: FnMut(&str, &str) -> bool>(&self, visit: F) {
        struct Ctx<F> {
            visit: F,
            panic: Option<Box<dyn Any + Send>>,
        }

        extern "C" fn trampoline<F: FnMut(&str, &str) -> bool>(
            key: *const c_char,
            value: *const c_char,
            ctx: *mut c_void,
        ) -> c_int {
            // SAFETY: ctx es el &mut Ctx<F> que pasamos a kv_for_each
            // abajo, y sigue vivo mientras dura la llamada.
            let ctx = unsafe { &mut *ctx.cast::<Ctx<F>>() };
            // SAFETY: kv_for_each pasa cadenas acabadas en NUL que viven
            // durante la llamada al callback.
            let (key, value) =
                unsafe { (CStr::from_ptr(key), CStr::from_ptr(value)) };
            let (Ok(key), Ok(value)) = (key.to_str(), value.to_str())
            else {
                return 0; // no es UTF-8: se salta
            };
            // AssertUnwindSafe: si hay pánico, se relanza sin volver a
            // usar ctx.visit.
            match panic::catch_unwind(AssertUnwindSafe(|| {
                (ctx.visit)(key, value)
            })) {
                Ok(true) => 0,
                Ok(false) => 1,
                Err(payload) => {
                    ctx.panic = Some(payload);
                    1 // que C++ pare de recorrer
                }
            }
        }

        let mut ctx = Ctx { visit, panic: None };
        // SAFETY: puntero válido; ctx vive hasta que kv_for_each vuelve.
        unsafe {
            ffi::kv_for_each(
                self.ptr.as_ptr(),
                trampoline::<F>,
                (&raw mut ctx).cast::<c_void>(),
            )
        };
        if let Some(payload) = ctx.panic {
            panic::resume_unwind(payload);
        }
    }

El trampolín llama al closure dentro de panic::catch_unwind. Si hay un pánico, guarda el payload, devuelve 1 para que C++ pare de recorrer y, ya de vuelta en Rust, lo relanza con panic::resume_unwind. El pánico rodea el código C++ en vez de atravesarlo. Dos detalles más:

  • &raw mut ctx crea un puntero crudo sin pasar por una referencia; es sintaxis estable desde Rust 1.82.
  • El AssertUnwindSafe está justificado porque, si hay pánico, se relanza sin volver a usar ctx.visit.

El mismo pánico, a través del envoltorio:

panic_safe.rs
// cargo run --example panic_safe
// El mismo pánico, a través del envoltorio: vuelve a Rust como un pánico
// normal, que se puede capturar, y el almacén sigue funcionando.
use kvdemo::Kv;
use std::panic;

fn main() {
    let mut db = Kv::new().unwrap();
    db.set("lang", "rust").unwrap();

    let result = panic::catch_unwind(|| {
        db.for_each(|_, _| panic!("something went wrong in the callback"));
    });

    println!("panic caught back in Rust: {}", result.is_err());
    println!("get(lang) = {:?}", db.get("lang"));
}

Esta es la salida real en mi equipo, con cargo run:

salidaoutput
thread 'main' (81384) panicked at examples\panic_safe.rs:12:28:
something went wrong in the callback
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
panic caught back in Rust: true
get(lang) = Some("rust")

El pánico vuelve a Rust, se captura como cualquier otro y el almacén sigue funcionando.

Hay otra opción cuando sí quieres que el desenrollado cruce la frontera: la ABI "C-unwind", estable desde Rust 1.71. En esta entrada no se usa, porque la frontera está diseñada para que no la cruce nada.

Sin sistema operativo debajo: #![no_std]#

Todo lo anterior usa std, la biblioteca estándar de Rust. Y std asume que hay un sistema operativo debajo: heap, hilos, archivos. Como explico en mi guía, un driver o un kernel es lo que ese sistema necesita, así que no puede apoyarse en std.

Con #![no_std] solo queda core, la parte de la biblioteca que no depende de nada:

  • no hay heap por defecto: hace falta el crate alloc y un #[global_allocator] propio;
  • no hay manejo de pánicos: hay que definir un #[panic_handler].

Rust for Linux se apoya en esa base. El soporte para Rust entró en el kernel con Linux 6.1, publicado el 11 de diciembre de 2022. Si quieres ver qué hay «debajo», la serie de los anillos empieza en De Ring 3 a Ring 0.

Lo que me llevo#

  • unsafe es una promesa, no un interruptor. Desbloquea cinco operaciones; todo lo demás se sigue comprobando.
  • Diseña la frontera en C para que no cruce nada que se desenrolle: ni excepciones ni pánicos.
  • Convierte los contratos de la documentación en tipos y lifetimes. Un comentario no lo comprueba nadie; un lifetime, el compilador.
  • Cada unsafe con su SAFETY, y todos en un solo archivo. Así se pueden revisar.
  • Comprueba el diseño en memoria en los dos lados, con aserciones en tiempo de compilación.
  • Los tests compile_fail convierten «esto no debería compilar» en algo que se verifica.
Nota personal

Escribí una guía de Rust de sistemas cuya sección 2 trata justo esto: unsafe, FFI, el patrón del envoltorio seguro y no_std. El ejemplo de mi guía usaba extern "C" {, que la edición 2024 ya no acepta sin unsafe, así que escribir esta entrada me sirvió para ponerla al día. Todo el código de la entrada compila sin avisos, con clippy y -D warnings, y lo he ejecutado en mi equipo con Rust 1.98 y MSVC.

The same bug, written twice#

This post revolves around a small, real library: a key-value store written in C++ on top of std::unordered_map, with a C API, used from Rust through a safe wrapper. All the code compiles, and I've run it on my machine. Let's start with a twenty-one-line C++ program:

dangling.cpp
// cl /std:c++20 /EHsc /W4 /Zi /fsanitize=address dangling.cpp kv.cpp
// The bug: keep the pointer from kv_get and use it after a kv_set.
#include "kv.h"
#include <cstdio>

int main() {
    kv* db = kv_new();
    // 27 characters: more than std::string keeps inside the object
    // itself (15 or 22, depending on the library), so it goes on the heap.
    kv_set(db, "email", "ana.garcia.tome@example.com");

    // Points inside the std::string that holds the value.
    const char* email = kv_get(db, "email");

    // A longer value doesn't fit in the current buffer: std::string gets
    // a new one and frees the old one, which `email` still points to.
    kv_set(db, "email", "ana.garcia.fernandez.tome@example-company.com");

    std::printf("%s\n", email);  // reads memory that was freed
    kv_free(db);
}

It compiles with cl /W4 without a single warning, and also with g++ -Wall -Wextra -pedantic. But run it with AddressSanitizer, which watches every memory access while the program runs, and this shows up.

This is the real output on my machine, an Intel Core i9-14900KF with Windows 11, with MSVC and AddressSanitizer (an extract: […] marks the lines removed):

salidaoutput
==17532==ERROR: AddressSanitizer: heap-use-after-free on address 0x126bd17a0760 […]
READ of size 2 at 0x126bd17a0760 thread T0
    […]
    #12 0x7ff78e401094 in main dangling.cpp:19
    […]
0x126bd17a0760 is located 0 bytes inside of 32-byte region [0x126bd17a0760,0x126bd17a0780)
freed by thread T0 here:
    […]
    #8 0x7ff78e401926 in `anonymous namespace'::Store::set kv.cpp:22
    #9 0x7ff78e4012b6 in kv_set kv.cpp:53
    #10 0x7ff78e401083 in main dangling.cpp:17
    […]
previously allocated by thread T0 here:
    […]
    #10 0x7ff78e401926 in `anonymous namespace'::Store::set kv.cpp:22
    #11 0x7ff78e4012b6 in kv_set kv.cpp:53
    #12 0x7ff78e401055 in main dangling.cpp:10
    […]
SUMMARY: AddressSanitizer: heap-use-after-free […]

The trace reads from the bottom up. The memory was allocated at line 10, by the first kv_set. It was freed at line 17, by the second kv_set, inside std::string::operator= (kv.cpp:22). And it was read at line 19, in the printf: a use-after-free. The 32-byte region is the buffer where the first value lived in MSVC's library.

The culprit isn't the hash table. std::unordered_map doesn't invalidate pointers or references to its elements when it reorganises itself (the rehash); the standard says so in [unord.req.general]. The culprit is the std::string: its non-const member functions, such as assignment, may invalidate pointers to its contents ([string.require]). If the new value doesn't fit in the current buffer, the assignment allocates a new one and frees the old one, and email still points to the old one.

That's why the first value is 27 characters long. A short string fits inside the std::string object itself, without going to the heap: up to 15 characters in MSVC and in libstdc++, up to 22 in libc++. With a short value no memory would be freed, and AddressSanitizer would see nothing.

Now, the same bug written in Rust, with the wrapper we'll look at in this post:

dangling.rs
// cargo build --example dangling --features show-bug
// Doesn't compile, on purpose.
// The same bug as dangling.cpp, written in Rust with the wrapper.
use kvdemo::Kv;

fn main() {
    let mut db = Kv::new().unwrap();
    db.set("email", "ana.garcia.tome@example.com").unwrap();

    // email is a &str borrowed from db
    let email = db.get("email").unwrap();

    db.set("email", "ana.garcia.fernandez.tome@example-company.com")
        .unwrap();

    println!("{email}");
}

This is the real output on my machine, with cargo build:

salidaoutput
error[E0502]: cannot borrow `db` as mutable because it is also borrowed as immutable
  --> examples\dangling.rs:13:5
   |
11 |     let email = db.get("email").unwrap();
   |                 -- immutable borrow occurs here
12 |
13 |     db.set("email", "ana.garcia.fernandez.tome@example-company.com")
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
...
16 |     println!("{email}");
   |                ----- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `kvdemo` (example "dangling") due to 1 previous error

It doesn't compile. The borrow checker sees that email is a borrow of db that is still alive at line 16, and doesn't let set modify it in the meantime.

But it pays to be precise here, because this is the idea behind the whole post: Rust knows nothing about std::string. The compiler doesn't know C++'s invalidation rules. What it enforces is the contract I wrote in the wrapper: "the pointer from kv_get is valid until the next kv_set or kv_free", expressed as a &str whose lifetime depends on &self, plus a set that takes &mut self. If the wrapper were written wrong, for example if get returned a &'static str, it would compile and the bug would be back.

In A secret that ran code I said that Rust removes whole classes of memory bugs, but not bugs of meaning. This is the other side of it: a C++ memory bug that Rust does prevent, because the wrapper expresses it in types. The borrow checker is enforcing a C++ rule because someone wrote it into Rust's types.

What unsafe really is#

Writing that wrapper takes unsafe, and it's easy to misunderstand. unsafe doesn't turn off the borrow checker or any other compiler check. According to the Rust book, it unlocks exactly five superpowers:

  1. dereferencing a raw pointer;
  2. calling an unsafe function, including any declared through FFI;
  3. accessing or modifying a static mut variable;
  4. implementing an unsafe trait;
  5. accessing the fields of a union.

Everything else is checked the same way, inside and outside the block. It's what makes Rust so safe, as we covered in Why Rust remains the safest language in 2026: those guarantees stay on, and unsafe only opens those five doors.

In one sentence

unsafe doesn't switch the compiler off: it unlocks five operations and makes you responsible for getting them right. Everything else is still checked.

In my Rust systems guide I sum it up like this: unsafe isn't a way to silence the compiler, it's a promise you make. You're telling the compiler you've checked by hand what it can't check. If the promise is false, the result is the same undefined behaviour as in C++.

Three of the five show up in this post's code:

Superpower Where it shows up
Dereferencing a raw pointer &mut *ctx.cast::<Ctx<F>>(), in for_each's trampoline
Calling an unsafe function Every call to ffi::kv_*
Implementing an unsafe trait unsafe impl Send and unsafe impl Sync
static mut and union Not used

And one convention the whole code follows: every unsafe block has a // SAFETY: comment above it that justifies why the promise holds. It's the usual practice in Rust, and it makes every promise reviewable.

The C boundary#

Rust and C++ don't talk to each other directly. They talk through C, the common language of almost every language: C's calling convention and types both sides understand the same way.

YOUR CODE safe Rust: new, set, get, for_each WRAPPER lib.rs: all the unsafe, each with its SAFETY extern "C": only pointers, integers and repr(C) structs C API kv.h: opaque type, error codes C++ kv.cpp: exceptions, std::unordered_map
Fig. 1 — The boundary. Rust above the line, C++ below it. Neither a panic nor an exception crosses it: catch_unwind stops them above and try/catch below.

This is the whole API, the file kv.h:

kv.h
// kv.h: C API of a key-value store written in C++.
// Everything that crosses this boundary is C: pointers, integers and
// plain structs.
#pragma once
#include <stddef.h>
#include <stdint.h>

#ifdef __cplusplus
#define KV_NOEXCEPT noexcept
extern "C" {
#else
#define KV_NOEXCEPT
#endif

// Opaque: API users only handle pointers, never the inside.
typedef struct kv kv;

// Error codes: C++ exceptions don't cross the boundary.
enum { KV_OK = 0, KV_EINVAL = 1, KV_ENOMEM = 2, KV_EINTERNAL = 3 };

// Struct shared by value: its memory layout has to match byte for
// byte on both sides.
typedef struct kv_stats {
    uint8_t  version;  // struct version
    uint64_t entries;  // number of entries
    uint32_t buckets;  // buckets of the hash table
} kv_stats;

// Returns 0 to keep iterating, any other value to stop.
typedef int (*kv_visit_fn)(const char* key, const char* value, void* ctx);

// NULL if out of memory. kv_free accepts NULL.
kv*         kv_new(void) KV_NOEXCEPT;
void        kv_free(kv* self) KV_NOEXCEPT;
int         kv_set(kv* self, const char* key,
                   const char* value) KV_NOEXCEPT;
// The returned pointer is valid until the next kv_set or kv_free.
const char* kv_get(const kv* self, const char* key) KV_NOEXCEPT;
void        kv_stats_get(const kv* self, kv_stats* out) KV_NOEXCEPT;
void        kv_for_each(const kv* self, kv_visit_fn visit,
                        void* ctx) KV_NOEXCEPT;

#ifdef __cplusplus
}
#endif

Four decisions worth a look:

  • Opaque type. typedef struct kv kv; declares the type without showing what's inside. API users only handle pointers to kv and never see the inside, so the inside can change without breaking anyone.
  • Error codes instead of exceptions. C has no exceptions, and Rust doesn't know what to do with them either. Every error becomes a number.
  • extern "C". It asks C++ to use C's calling convention and not to decorate the names, so from outside they show up as kv_new, kv_set…
  • The KV_NOEXCEPT macro. In C++17, noexcept is part of the function's type, so the header's declaration has to match the definition. In C, the macro expands to nothing.

Below the boundary, the library is ordinary C++. This fragment of kv.cpp is the class that holds the data, which throws std::invalid_argument if the key is empty, and the kv_set function, which translates each exception into an error code:

kv.cpp
// Plain C++: throws exceptions and uses the standard library.
class Store {
public:
    void set(const char* key, const char* value) {
        if (key == nullptr || *key == '\0')
            throw std::invalid_argument("empty key");
        map_[key] = value;
    }
    const std::string* get(const char* key) const {
        auto it = map_.find(key);
        return it == map_.end() ? nullptr : &it->second;
    }
    const auto& items() const { return map_; }

private:
    std::unordered_map<std::string, std::string> map_;
};
kv.cpp
// No exception leaves this function: each one becomes an error
// code.
int kv_set(kv* self, const char* key, const char* value) noexcept {
    try {
        self->set(key, value);
        return KV_OK;
    } catch (const std::invalid_argument&) {
        return KV_EINVAL;
    } catch (const std::bad_alloc&) {
        return KV_ENOMEM;
    } catch (...) {
        return KV_EINTERNAL;
    }
}

kv_set is marked noexcept, and that isn't magic. If an exception tried to leave a noexcept function, C++ would call std::terminate ([except.handle]): the program would abort anyway, although in a defined way, instead of unwinding the stack into Rust. That's why kv_set catches them all and turns them into codes. The same goes for kv_for_each, which calls a callback it doesn't control:

kv.cpp
// noexcept: if the callback threw, std::terminate instead of
// unwinding the stack into whoever called us.
void kv_for_each(const kv* self, kv_visit_fn visit, void* ctx) noexcept {
    for (const auto& [key, value] : self->items())
        if (visit(key.c_str(), value.c_str(), ctx) != 0)
            break;
}
The golden rule

Nothing that unwinds crosses the C boundary: neither C++ exceptions nor Rust panics. They're caught on their own side and turned into data.

And there's one line in the header that matters more than any other, even though it looks like just another comment: "The returned pointer is valid until the next kv_set or kv_free". It's kv_get's contract, and it's the rule the wrapper is going to turn into a lifetime.

This is how the library is used from Rust, with the safe wrapper:

main.rs
// cargo run
// Uses the C++ library from Rust through the safe wrapper.
use kvdemo::{Kv, KvError, ffi::KvStats};

// The same fields as KvStats, with Rust's default layout.
#[allow(dead_code)]
struct StatsRustLayout {
    version: u8,
    entries: u64,
    buckets: u32,
}

fn main() -> Result<(), KvError> {
    println!("size_of KvStats (repr C) = {}", size_of::<KvStats>());
    println!(
        "size_of same fields (repr Rust) = {}",
        size_of::<StatsRustLayout>()
    );

    let mut db = Kv::new()?;
    db.set("lang", "rust")?;
    db.set("ffi", "c++")?;
    println!("get(lang) = {:?}", db.get("lang"));
    println!("get(nope) = {:?}", db.get("nope"));

    // Errors from C++ (an exception) and from Rust (a NUL inside the
    // string)
    println!("set(\"\") = {:?}", db.set("", "x"));
    println!("set(\"a\\0b\") = {:?}", db.set("a\0b", "x"));

    let s = db.stats();
    println!(
        "stats: version={} entries={} buckets={}",
        s.version, s.entries, s.buckets
    );

    let mut keys = Vec::new();
    db.for_each(|k, _| {
        keys.push(k.to_owned());
        true
    });
    keys.sort();
    println!("keys = {keys:?}");
    Ok(())
}

This is the real output on my machine, with cargo run:

salidaoutput
size_of KvStats (repr C) = 24
size_of same fields (repr Rust) = 16
get(lang) = Some("rust")
get(nope) = None
set("") = Err(InvalidKey)
set("a\0b") = Err(InteriorNul)
stats: version=1 entries=2 buckets=8
keys = ["ffi", "lang"]

Errors come from both sides of the boundary. set("") returns Err(InvalidKey): it's a C++ exception, std::invalid_argument, turned into an error code by kv_set. set("a\0b") returns Err(InteriorNul) without ever reaching C++: a C string ends at the first NUL, so it can't contain one inside, and CString::new rejects it before it crosses.

One detail in the output: buckets=8 is what MSVC's standard library does. A different standard library gives a different number of buckets.

#[repr(C)] and padding#

Besides pointers and integers, one struct crosses the boundary by value: kv_stats. For that, both sides have to agree on its memory layout, byte for byte. With three fields, uint8_t version, uint64_t entries and uint32_t buckets, C's alignment rules give:

Field Bytes
version 1
Padding, to align entries to 8 7
entries 8
buckets 4
Trailing padding, so the size is a multiple of 8 4
Total 24

Both sides check it at compile time. In C++, in kv.cpp:

kv.cpp
// kv_stats's layout is part of the API: if it changes, the build
// fails here. 1 + 7 (padding) + 8 + 4 + 4 (padding) = 24 bytes.
static_assert(sizeof(kv_stats) == 24);
static_assert(alignof(kv_stats) == 8);

And in Rust, in lib.rs, with the same struct marked #[repr(C)] and an assertion evaluated at compile time:

lib.rs
    /// The same memory layout as `kv_stats` in C.
    #[repr(C)]
    #[derive(Debug, Default, Clone, Copy)]
    pub struct KvStats {
        pub version: u8,
        pub entries: u64,
        pub buckets: u32,
    }
lib.rs
// If the memory layout doesn't match C's, it doesn't compile.
const _: () = assert!(size_of::<ffi::KvStats>() == 24);
const _: () = assert!(align_of::<ffi::KvStats>() == 8);

If either side changes, it doesn't compile. The first two lines of the cargo run output show why #[repr(C)] is needed: 24 bytes with it, and 16 with the same fields and Rust's default layout (the StatsRustLayout struct in main.rs).

Those 16 bytes deserve a caveat: they're not guaranteed. The Rust Reference says that, with the default layout, the order of the fields is unspecified. 16 is what this compiler, rustc 1.98.1, does: it arranges the fields differently to save padding. The only thing guaranteed is #[repr(C)].

In my C++ systems guide I teach the same thing from the other side. struct Bad { char a; int x; char b; } takes about 12 bytes, and struct Good { int x; char a; char b; }, with the same fields in a different order, about 8. In C++ you reorder by hand. Rust reorders on its own when you don't ask for repr(C), and that's exactly why a struct that crosses the boundary has to carry it. In my Rust guide I put it like this: without #[repr(C)], a struct shared with C has no layout guarantee at all.

The safe wrapper#

All of the crate's unsafe lives in a single file, lib.rs. Let's go through it piece by piece.

First, the declaration of the C functions:

lib.rs
    unsafe extern "C" {
        // Takes no pointers: calling it can't break anything.
        pub safe fn kv_new() -> *mut Kv;
        pub fn kv_free(this: *mut Kv);
        pub fn kv_set(
            this: *mut Kv,
            key: *const c_char,
            value: *const c_char,
        ) -> c_int;
        pub fn kv_get(
            this: *const Kv,
            key: *const c_char,
        ) -> *const c_char;
        pub fn kv_stats_get(this: *const Kv, out: *mut KvStats);
        pub fn kv_for_each(
            this: *const Kv,
            visit: KvVisitFn,
            ctx: *mut c_void,
        );
    }

Since the 2024 edition, extern blocks have to carry unsafe: declaring an external function is already a promise about its signature, one the compiler can't check. Inside the block, each function can be marked safe, stable since Rust 1.82. Here kv_new is a safe fn because it takes no pointers: calling it can't break anything. The rest are still unsafe to call.

Next, the opaque type on the Rust side:

lib.rs
    /// Opaque type: no accessible fields and no known size, and the
    /// marker leaves it without automatic Send, Sync or Unpin.
    #[repr(C)]
    pub struct Kv {
        _data: (),
        _marker: PhantomData<(*mut u8, PhantomPinned)>,
    }

It's the form the Nomicon recommends: a struct with no useful fields, with a PhantomData<(*mut u8, PhantomPinned)> marker that leaves it without automatic Send, Sync or Unpin. A raw pointer is neither Send nor Sync, and PhantomPinned removes Unpin, so Rust assumes nothing we don't know. My guide used _private: [u8; 0]; the Nomicon adds the marker.

Then, the public type: a non-null pointer to the C++ object, and a Drop that frees it.

lib.rs
/// The store, seen from Rust: sole owner of the C++ object.
pub struct Kv {
    ptr: NonNull<ffi::Kv>,
}
lib.rs
impl Drop for Kv {
    fn drop(&mut self) {
        // SAFETY: the pointer came from kv_new and is freed once.
        unsafe { ffi::kv_free(self.ptr.as_ptr()) };
    }
}

This is RAII, the same as in C++. In my Rust guide I describe it like this: Drop is Rust's destructor, called automatically, exactly once. In the C++ one I explain it with a File class that opens the file with fopen in the constructor and closes it with fclose in the destructor. Here, the C++ object is freed with kv_free when the Kv goes out of scope, no matter what.

And now, the heart of the post, set and get:

lib.rs
    pub fn set(&mut self, key: &str, value: &str) -> Result<(), KvError> {
        let key = CString::new(key).map_err(|_| KvError::InteriorNul)?;
        let value =
            CString::new(value).map_err(|_| KvError::InteriorNul)?;
        // SAFETY: the pointer is valid while self exists, and &mut self
        // guarantees nobody else uses it. Both strings end in NUL and
        // live until the end of the call.
        let code = unsafe {
            ffi::kv_set(self.ptr.as_ptr(), key.as_ptr(), value.as_ptr())
        };
        match code {
            ffi::KV_OK => Ok(()),
            ffi::KV_EINVAL => Err(KvError::InvalidKey),
            ffi::KV_ENOMEM => Err(KvError::OutOfMemory),
            _ => Err(KvError::Internal),
        }
    }

    /// The returned &str points into C++ memory, and its lifetime is
    /// tied to &self: while it exists, nobody can call set, which
    /// takes &mut self.
    ///
    /// ```compile_fail,E0502
    /// let mut db = kvdemo::Kv::new().unwrap();
    /// db.set("k", "ana.garcia.tome@example.com").unwrap();
    /// let v = db.get("k").unwrap();
    /// db.set("k", "ana.garcia.fernandez.tome@example-company.com")
    ///     .unwrap();
    /// println!("{v}");
    /// ```
    pub fn get(&self, key: &str) -> Option<&str> {
        let key = CString::new(key).ok()?;
        // SAFETY: valid pointer and NUL-terminated key.
        let value =
            unsafe { ffi::kv_get(self.ptr.as_ptr(), key.as_ptr()) };
        if value.is_null() {
            return None;
        }
        // SAFETY: kv_get returns a NUL-terminated string that lives
        // until the next kv_set or kv_free, and neither of them can
        // happen while the borrow of self that this &str comes from
        // is alive.
        unsafe { CStr::from_ptr(value) }.to_str().ok()
    }

Here's the header's contract, translated into types. set takes &mut self: an exclusive borrow. get takes &self and returns an Option<&str> whose lifetime is tied to that &self. While the &str exists, the borrow of db is still alive, and nobody can get the &mut self that set needs. The last SAFETY comment in get is the key to the bug at the start: the &str can point into C++ memory because kv_get's contract and Rust's borrowing rules say the same thing.

Above get there's a doctest marked compile_fail,E0502, with the same bug. cargo test compiles it on every run, and the test passes only if it does not compile, and with that error. If someone changed the wrapper and the bug compiled again, the test would fail.

Fun fact

A compile_fail doctest is a test that passes if the code does not compile. It turns "this shouldn't compile" into something checked every time, instead of a promise in a comment.

This is the real output on my machine, with cargo test:

salidaoutput
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test a_panic_in_the_callback_comes_back_to_rust ... ok
test errors_from_both_sides_of_the_boundary ... ok
test for_each_visits_every_entry_and_can_stop ... ok
test set_get_and_overwrite ... ok
test stats_cross_the_boundary_by_value ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test src\lib.rs - Kv::get (line 119) - compile fail ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s

The last line with ok is that doctest: compile fail ... ok.

The wrapper prevents one more bug. This program tries to modify the store while iterating over it:

reentrant.rs
// cargo build --example reentrant --features show-bug
// Doesn't compile, on purpose: modifying the store while iterating.
// In C++, a kv_set inside kv_for_each's callback could trigger a
// rehash and invalidate the iterator.
use kvdemo::Kv;

fn main() {
    let mut db = Kv::new().unwrap();
    db.set("a", "1").unwrap();
    db.for_each(|k, _| {
        db.set(&format!("{k}-copy"), "x").unwrap();
        true
    });
}

This is the real output on my machine, with cargo build:

salidaoutput
error[E0502]: cannot borrow `db` as mutable because it is also borrowed as immutable
  --> examples\reentrant.rs:10:17
   |
10 |     db.for_each(|k, _| {
   |     -- -------- ^^^^^^ mutable borrow occurs here
   |     |  |
   |     |  immutable borrow later used by call
   |     immutable borrow occurs here
11 |         db.set(&format!("{k}-copy"), "x").unwrap();
   |         -- second borrow occurs due to use of `db` in closure

For more information about this error, try `rustc --explain E0502`.
error: could not compile `kvdemo` (example "reentrant") due to 1 previous error

for_each takes &self and the closure needs &mut db to call set: the two borrows clash. In C++ the equivalent, calling kv_set inside kv_for_each's callback, compiles without complaint. And if the insertion triggers a rehash, it invalidates the iterator the loop is using ([unord.req.general]).

unsafe impl Send and Sync: the fourth superpower#

The opaque type is neither Send nor Sync, so Kv wouldn't be either, and it couldn't be used from several threads. Allowing that takes two explicit promises:

lib.rs
// SAFETY: Kv is the sole owner of the C++ object, and the object
// doesn't depend on the thread that created it: it can move threads.
unsafe impl Send for Kv {}

// SAFETY: with &Kv you only reach kv_get, kv_stats_get and
// kv_for_each, which only call const member functions of
// std::unordered_map. The C++ standard library guarantees that those
// calls, made at once from several threads, cause no data races.
unsafe impl Sync for Kv {}

Send is easy: Kv is the sole owner of the C++ object, and the object doesn't depend on the thread that created it. Sync is more delicate, because it means several threads can use &Kv at the same time. The justification is in the C++ standard, in [res.on.data.races]: a standard library function only modifies the objects it accesses through non-const arguments, including this. That's why several calls to const functions of the same unordered_map, from several threads at once, cause no data races. And with a &Kv you only reach kv_get, kv_stats_get and kv_for_each, which only call const member functions.

Careful, though, because that promise depends on today's code. If someone adds a cache to kv_get tomorrow that gets modified on every read, the promise breaks and the compiler won't notice. That's exactly what unsafe means: the compiler takes your word for it.

With the promise made, four threads can read at once:

threads.rs
// cargo run --example threads
// Four threads read the same C++ store at once. It compiles because
// Kv is Sync: the promise of `unsafe impl Sync` in lib.rs.
use kvdemo::Kv;
use std::thread;

fn main() {
    let mut db = Kv::new().unwrap();
    for i in 0..1000 {
        db.set(&format!("key{i}"), &format!("value{i}")).unwrap();
    }

    let db = &db; // read-only from here on
    let found: usize = thread::scope(|s| {
        let workers: Vec<_> = (0..4)
            .map(|t| {
                s.spawn(move || {
                    (0..1000)
                        .filter(|i| i % 4 == t)
                        .filter(|i| db.get(&format!("key{i}")).is_some())
                        .count()
                })
            })
            .collect();
        workers.into_iter().map(|w| w.join().unwrap()).sum()
    });

    println!("found {found} of 1000 keys from 4 threads");
}

This is the real output on my machine, with cargo run:

salidaoutput
found 1000 of 1000 keys from 4 threads

And to see that the promise is needed, I temporarily commented out unsafe impl Sync for Kv {}. This is the real output on my machine, with cargo build (the first 15 lines):

salidaoutput
error[E0277]: `NonNull<kvdemo::ffi::Kv>` cannot be shared between threads safely
   --> examples\threads.rs:17:25
    |
 17 |                   s.spawn(move || {
    |  ___________________-----_^
    | |                   |
    | |                   required by a bound introduced by this call
 18 | |                     (0..1000)
 19 | |                         .filter(|i| i % 4 == t)
 20 | |                         .filter(|i| db.get(&format!("key{i}")).is_some())
 21 | |                         .count()
 22 | |                 })
    | |_________________^ `NonNull<kvdemo::ffi::Kv>` cannot be shared between threads safely
    |
    = help: within `kvdemo::Kv`, the trait `Sync` is not implemented for `NonNull<kvdemo::ffi::Kv>`
[…]

Rust won't let you share a raw pointer between threads until someone takes responsibility for it.

Panics and callbacks#

kv_for_each calls a Rust function from C++. What happens if that function calls panic!? This program tries it without the wrapper, calling the C API directly:

panic_abort.rs
// cargo run --example panic_abort
// An extern "C" callback that panics, without the wrapper: the
// panic tries to leave an extern "C" function and the process aborts.
use kvdemo::ffi;
use std::ffi::{c_char, c_int, c_void};

extern "C" fn visit(
    _key: *const c_char,
    _value: *const c_char,
    _ctx: *mut c_void,
) -> c_int {
    panic!("something went wrong in the callback");
}

fn main() {
    let db = ffi::kv_new();
    // SAFETY: db comes from kv_new; the c"..." literals end in NUL.
    unsafe {
        ffi::kv_set(db, c"lang".as_ptr(), c"rust".as_ptr());
        ffi::kv_for_each(db, visit, std::ptr::null_mut());
        ffi::kv_free(db);
    }
    println!("never printed");
}

(The c"lang" literals are C strings with their trailing NUL, written directly in Rust; they've been stable since Rust 1.77.)

This is the real output on my machine, with cargo run (an extract, without the backtrace):

salidaoutput
thread 'main' (14964) panicked at examples\panic_abort.rs:12:5:
something went wrong in the callback
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'main' (14964) panicked at /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library\core\src\panicking.rs:225:5:
panic in a function that cannot unwind
stack backtrace:
   […]
thread caused non-unwinding panic. aborting.

The panic tries to leave an extern "C" function, which can't unwind the stack, and the process aborts. The full backtrace shows kv_for_each (kv.cpp:83): the panic happened inside a call from C++. Aborting is what Rust has done since version 1.81; before that, a panic leaving an extern "C" function was undefined behaviour. The opposite case hasn't changed: a C++ exception that crosses an extern "C" boundary into Rust is still undefined behaviour, according to the Nomicon.

The solution is in Kv::for_each: a trampoline, a generic extern "C" function that acts as a bridge between C++ and the closure:

lib.rs
    /// Iterates over the entries until `visit` returns false. If
    /// `visit` panics, the panic doesn't cross the C++ code: it's
    /// caught in the trampoline and resumed here, back in Rust.
    pub fn for_each<F: FnMut(&str, &str) -> bool>(&self, visit: F) {
        struct Ctx<F> {
            visit: F,
            panic: Option<Box<dyn Any + Send>>,
        }

        extern "C" fn trampoline<F: FnMut(&str, &str) -> bool>(
            key: *const c_char,
            value: *const c_char,
            ctx: *mut c_void,
        ) -> c_int {
            // SAFETY: ctx is the &mut Ctx<F> we pass to kv_for_each
            // below, and it stays alive for the whole call.
            let ctx = unsafe { &mut *ctx.cast::<Ctx<F>>() };
            // SAFETY: kv_for_each passes NUL-terminated strings that
            // live for the duration of the callback call.
            let (key, value) =
                unsafe { (CStr::from_ptr(key), CStr::from_ptr(value)) };
            let (Ok(key), Ok(value)) = (key.to_str(), value.to_str())
            else {
                return 0; // not UTF-8: skipped
            };
            // AssertUnwindSafe: if there's a panic, it's resumed
            // without using ctx.visit again.
            match panic::catch_unwind(AssertUnwindSafe(|| {
                (ctx.visit)(key, value)
            })) {
                Ok(true) => 0,
                Ok(false) => 1,
                Err(payload) => {
                    ctx.panic = Some(payload);
                    1 // tell C++ to stop iterating
                }
            }
        }

        let mut ctx = Ctx { visit, panic: None };
        // SAFETY: valid pointer; ctx lives until kv_for_each returns.
        unsafe {
            ffi::kv_for_each(
                self.ptr.as_ptr(),
                trampoline::<F>,
                (&raw mut ctx).cast::<c_void>(),
            )
        };
        if let Some(payload) = ctx.panic {
            panic::resume_unwind(payload);
        }
    }

The trampoline calls the closure inside panic::catch_unwind. If there's a panic, it stores the payload, returns 1 so C++ stops iterating and, back in Rust, resumes it with panic::resume_unwind. The panic goes around the C++ code instead of through it. Two more details:

  • &raw mut ctx creates a raw pointer without going through a reference; it's syntax that has been stable since Rust 1.82.
  • The AssertUnwindSafe is justified because, if there's a panic, it's resumed without using ctx.visit again.

The same panic, through the wrapper:

panic_safe.rs
// cargo run --example panic_safe
// The same panic, through the wrapper: it comes back to Rust as a
// normal panic, which can be caught, and the store keeps working.
use kvdemo::Kv;
use std::panic;

fn main() {
    let mut db = Kv::new().unwrap();
    db.set("lang", "rust").unwrap();

    let result = panic::catch_unwind(|| {
        db.for_each(|_, _| panic!("something went wrong in the callback"));
    });

    println!("panic caught back in Rust: {}", result.is_err());
    println!("get(lang) = {:?}", db.get("lang"));
}

This is the real output on my machine, with cargo run:

salidaoutput
thread 'main' (81384) panicked at examples\panic_safe.rs:12:28:
something went wrong in the callback
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
panic caught back in Rust: true
get(lang) = Some("rust")

The panic comes back to Rust, gets caught like any other, and the store keeps working.

There's another option for when you do want unwinding to cross the boundary: the "C-unwind" ABI, stable since Rust 1.71. It isn't used in this post, because the boundary is designed so that nothing crosses it.

No operating system underneath: #![no_std]#

Everything above uses std, Rust's standard library. And std assumes there's an operating system underneath: a heap, threads, files. As I explain in my guide, a driver or a kernel is what that system needs, so it can't lean on std.

With #![no_std] only core is left, the part of the library that depends on nothing:

  • there's no heap by default: you need the alloc crate and your own #[global_allocator];
  • there's no panic handling: you have to define a #[panic_handler].

Rust for Linux builds on that base. Rust support landed in the kernel with Linux 6.1, released on 11 December 2022. If you want to see what's "underneath", the rings series starts at From Ring 3 to Ring 0.

What I take away#

  • unsafe is a promise, not a switch. It unlocks five operations; everything else is still checked.
  • Design the C boundary so nothing that unwinds crosses it: neither exceptions nor panics.
  • Turn the contracts in the documentation into types and lifetimes. Nobody checks a comment; the compiler checks a lifetime.
  • Every unsafe with its SAFETY, and all of them in a single file. That way they can be reviewed.
  • Check the memory layout on both sides, with compile-time assertions.
  • compile_fail tests turn "this shouldn't compile" into something that gets verified.
Personal note

I wrote a Rust systems guide whose section 2 covers exactly this: unsafe, FFI, the safe-wrapper pattern and no_std. The example in my guide used extern "C" {, which the 2024 edition no longer accepts without unsafe, so writing this post was a chance to bring it up to date. All the code in this post compiles without warnings, with clippy and -D warnings, and I've run it on my machine with Rust 1.98 and MSVC.

ReferenciasReferences

  1. The Rust Programming Language, 20.1: Unsafe RustRust Project · doc.rust-lang.org
  2. The Rustonomicon: Foreign Function InterfaceRust Project · doc.rust-lang.org
  3. Rust Edition Guide: Unsafe extern blocksRust Project · doc.rust-lang.org
  4. The Rust Reference: Type layoutRust Project · doc.rust-lang.org
  5. Announcing Rust 1.81.0Rust Blog · blog.rust-lang.org
  6. Announcing Rust 1.82.0Rust Blog · blog.rust-lang.org
  7. std::panic::catch_unwindRust Project · doc.rust-lang.org
  8. std::ffi::CStringRust Project · doc.rust-lang.org
  9. C++ draft: [string.require]eel.is · eel.is
  10. C++ draft: [unord.req.general]eel.is · eel.is
  11. An informal comparison of the three major implementations of std::stringThe Old New Thing (Microsoft) · devblogs.microsoft.com
  12. C++ draft: [res.on.data.races]eel.is · eel.is
  13. C++ draft: [except.handle]eel.is · eel.is
  14. AddressSanitizer (MSVC)Microsoft · learn.microsoft.com
  15. Linux 6.1kernelnewbies · kernelnewbies.org
  16. cc cratedocs.rs · docs.rs

CompartirShare

¿Necesitas desarrollo en Rust?Need Rust development?

Desarrollo software seguro y de alto rendimiento con Rust para tu empresa. Consultoría, formación y proyectos a medida.Secure, high-performance software development with Rust for your business. Consulting, training, and custom projects.

HablemosLet's talk→

ComentariosComments

Para comentar necesitas una cuenta de GitHub. Los comentarios se guardan en GitHub Discussions (SoftDryzz/blog-comments) y se moderan: no se publican enlaces promocionales.Commenting requires a GitHub account. Comments live in GitHub Discussions (SoftDryzz/blog-comments) and are moderated: no promotional links.

Sigue leyendoKeep reading

RustRust

Por qué Rust sigue siendo el lenguaje más seguro en 2026Why Rust remains the safest language in 2026

Análisis de las características de seguridad de memoria de Rust y por qué domina en sistemas críticos en 2026. Ownership, borrowing, y adopción en Linux, Android y AWS.Analysis of Rust's memory safety features and why it dominates in critical systems.

· 5 min

SeguridadSecurity

Un secreto que ejecutaba código: postmortem de una vulnerabilidad en mi gestor de secretosA secret that ran code: post-mortem of a vulnerability in my own secrets manager

Cómo el valor de un secreto podía ejecutar comandos en un pipeline de CI a través de Vaultic, reproducido con los binarios reales: el fallo, quién podía explotarlo, el arreglo, los fallos pequeños que vinieron detrás y cómo se publicó.How a secret's value could run commands in a CI pipeline through Vaultic, reproduced with the real binaries: the bug, who could exploit it, the fix, the smaller bugs behind it and how it was disclosed.

· 9 min