wandres.dev
ABSTRACCIONES SEGURAS · envolver unsafe

Documentar la seguridad: # Safety, // SAFETY y la cultura del kernel

La disciplina que hace auditable un núcleo `unsafe`: la sección `# Safety` que declara las precondiciones que una función `unsafe` exige, y el comentario `// SAFETY:` que justifica por qué cada bloque `unsafe` las cumple. Exigir y saldar, deuda y pago. En Rust 2024, `unsafe_op_in_unsafe_fn` lo vuelve explícito; en el kernel de Linux, obligatorio.

⏱ 20 min

Un núcleo unsafe sin documentar es intratable: nadie —ni tú dentro de seis meses— puede verificar que sea correcto, porque el razonamiento que lo justificaba vivía solo en tu cabeza el día que lo escribiste. Por eso la comunidad de Rust ha erigido una disciplina que no es opcional en el código serio: cada función unsafe lleva una sección # Safety que declara las precondiciones que exige a quien la llame, y cada bloque unsafe lleva encima un comentario // SAFETY: que justifica por qué, en ese punto, esas precondiciones se cumplen. Exigir y saldar. Deuda y pago. Un libro de contabilidad donde toda obligación tiene su justificación escrita al lado. Esto no es burocracia: es lo único que vuelve auditable una gran superficie de unsafe, y por eso el kernel de Linux lo impone sin excepciones.

🎯 Al terminar esta lección sabrás
  • Redactar la sección # Safety que documenta las precondiciones de una función unsafe.
  • Escribir el comentario // SAFETY: que justifica cada bloque unsafe.
  • Entender la simetría deuda/pago entre lo que se exige y lo que se salda.
  • Aprovechar unsafe_op_in_unsafe_fn de Rust 2024 y los lints de Clippy que lo imponen.

# Safety: la deuda que crea una función unsafe

Marcar una función como unsafe no significa “esta función hace cosas peligrosas por dentro”: significa “esta función tiene precondiciones que el compilador no puede comprobar, y el llamador debe garantizarlas”. Esa distinción es capital, y su corolario es una obligación de documentación: toda función unsafe pública debe llevar una sección # Safety en su doc-comentario que enumere exactamente qué debe cumplir el llamador.

/// Devuelve una referencia al elemento en `indice` sin comprobar los limites.
///
/// # Safety
///
/// El llamador debe garantizar que `indice < self.len`. En caso contrario el
/// acceso cae fuera de rango y el comportamiento es indefinido.
pub unsafe fn en_bruto(&self, indice: usize) -> &T {
    // SAFETY: el contrato de esta funcion exige indice < len, luego el puntero
    // calculado apunta a un elemento inicializado y dentro de la asignacion
    unsafe { &*self.ptr.as_ptr().add(indice) }
}

La sección # Safety es un contrato público: describe la deuda que contrae quien llame a en_bruto. Sin ella, la función es inusable con criterio, porque nadie sabe qué debe garantizar para no invocar comportamiento indefinido.

// SAFETY: el pago que salda cada bloque

ℹ️
En Rust 2024 el cuerpo de una unsafe fn ya no es un bloque unsafe implícito

Históricamente, el cuerpo entero de una unsafe fn era un bloque unsafe tácito: podías desreferenciar punteros sin más. La edición 2024 activa por defecto el lint unsafe_op_in_unsafe_fn, que exige un bloque unsafe explícito incluso dentro de una unsafe fn. Es una mejora profunda: separa dos cosas que antes se confundían. Que una función exija precondiciones (su # Safety) es independiente de que su cuerpo realice operaciones inseguras (sus bloques unsafe). Ahora cada operación insegura del cuerpo pide su propio // SAFETY:, y así ninguna se cuela sin justificación por el hecho de estar dentro de una unsafe fn.

Del otro lado del contrato está el bloque unsafe, que salda la deuda: por cada operación insegura, un comentario // SAFETY: justo encima explica por qué las precondiciones se cumplen aquí y ahora. Cuando quien salda es una función segura, el pago suele consistir en señalar la comprobación que acaba de hacer:

pub fn get(&self, indice: usize) -> Option<&T> {
    if indice < self.len {
        // SAFETY: acabamos de comprobar que indice < len, de modo que la
        // precondicion de en_bruto queda satisfecha en esta rama
        Some(unsafe { self.en_bruto(indice) })
    } else {
        None
    }
}

Observa la simetría perfecta. en_bruto exige indice < len en su # Safety; get salda esa exigencia con un if y lo hace constar en su // SAFETY:. La deuda declarada en un sitio se paga, visiblemente, en otro. Un revisor puede seguir el hilo sin ejecutar nada: lee la exigencia, lee el pago, comprueba que encajan.

El mismo rito gobierna los traits. Un unsafe trait es el que impone obligaciones a quien lo implementa, y su # Safety las declara; cada unsafe impl es entonces un pago que un // SAFETY: justifica:

/// # Safety
///
/// El implementador debe garantizar que `tamano` devuelve exactamente el numero
/// de bytes que `escribir_en` escribira. Un valor menor causa una escritura
/// fuera de rango en el buffer del llamador y es comportamiento indefinido.
pub unsafe trait Serializable {
    fn tamano(&self) -> usize;
    fn escribir_en(&self, destino: *mut u8);
}

// SAFETY: escribimos 4 bytes en little-endian y tamano devuelve 4; el contrato
// del trait queda satisfecho porque ambos numeros coinciden por construccion
unsafe impl Serializable for u32 {
    fn tamano(&self) -> usize { 4 }
    fn escribir_en(&self, destino: *mut u8) {
        // SAFETY: el contrato de escribir_en promete un destino con tamano() bytes
        unsafe { destino.copy_from_nonoverlapping(self.to_le_bytes().as_ptr(), 4); }
    }
}

Los tipos marcadores del núcleo —Send, Sync— son la versión más famosa de esto: unsafe impl<T: Send> Send for Buffer<T> {} afirma, con su // SAFETY:, que trasladar el Buffer a otro hilo no crea aliasing. En los tres casos —función, bloque, trait— la gramática es la misma: quien exige lo declara en # Safety, quien salda lo justifica en // SAFETY:.

unsafe en la firma es un contrato, no una confesión

Un error extendido es marcar una función unsafe solo porque su cuerpo contiene operaciones inseguras. Es justo al revés: que una función use unsafe por dentro no la vuelve unsafe; la vuelve segura si, como en el patrón central, ella misma garantiza las precondiciones. La palabra unsafe en la firma se reserva para las funciones que empujan una obligación hacia el llamador. La librería estándar lo ejemplifica con dos vecinas de Vec:

impl<T> Vec<T> {
    // Segura: comprueba la capacidad y mantiene la invariante por si misma
    pub fn push(&mut self, valor: T) { /* ... unsafe por dentro ... */ }

    /// # Safety
    ///
    /// Los elementos en `0..nuevo_largo` deben estar inicializados y
    /// `nuevo_largo <= capacity()`. El compilador no lo comprueba: lo juras tu.
    pub unsafe fn set_len(&mut self, nuevo_largo: usize) { /* ... */ }
}

Fíjate en la paradoja aparente: push, que realiza la operación insegura, es segura; set_len, que solo ajusta un contador, es insegura. Porque unsafe fn no describe lo que la función hace por dentro, sino la deuda que contrae quien la llama. Marcar unsafe una función sin precondiciones reales es ruido: obliga a bloques unsafe en sitios de uso que no lo merecen y diluye la señal de dónde vive el peligro. El criterio es único y nítido: una función es unsafe si, y solo si, existe alguna forma de llamarla —con argumentos que el tipo admite— que provoque comportamiento indefinido salvo que el llamador garantice algo que el compilador no ve.

La cultura: por qué es obligatoria, no opcional

💡
Clippy convierte la disciplina en una regla mecánica

Dos lints de Clippy vuelven esta cultura verificable por la máquina. clippy::missing_safety_doc avisa si una función unsafe pública no tiene sección # Safety. clippy::undocumented_unsafe_blocks avisa si un bloque unsafe no lleva encima su // SAFETY:. Actívalos con #![deny(...)] en la raíz del crate y la compilación fallará ante cualquier unsafe sin documentar. La disciplina deja de depender de la memoria del autor y pasa a ser una condición de compilación, igual que en el kernel.

En el proyecto Rust for Linux, todo bloque unsafe sin su comentario // SAFETY: y toda unsafe fn sin su # Safety es rechazado en revisión, sin discusión. La misma norma rige en la librería estándar. La razón no es ceremonial: cuando la superficie de unsafe crece a miles de bloques, la única forma de mantenerla correcta es que cada bloque cargue, junto a sí, la prueba de su propia solidez. Sin ese hábito, revisar unsafe sería reconstruir en cada lectura un razonamiento que nadie escribió; con él, revisar es comprobar que cada pago corresponde a una deuda. Un buen // SAFETY: no dice “esto es seguro” —eso no informa de nada—, sino que nombra la precondición concreta que salda y de dónde proviene la garantía: qué assert, qué comprobación previa, qué invariante del tipo. Así el comentario es refutable: un revisor puede señalar exactamente en qué línea la premisa deja de sostenerse. Ese es el rasero del kernel y de std, y es lo que separa un unsafe auditable de una plegaria escrita en verde.

flowchart LR
D[unsafe fn con seccion Safety] -->|declara la deuda| L[Libro de obligaciones]
B[bloque unsafe con comentario SAFETY] -->|registra el pago| L
L --> A[Codigo auditable cada deuda tiene su justificacion escrita]
style D fill:#f38ba8,color:#11111b
style B fill:#89b4fa,color:#11111b
style A fill:#a6e3a1,color:#11111b
Cada comentario SAFETY es un lema, y el conjunto es la demostración de que tu abstracción es sólida

Conviene ver esta disciplina por lo que realmente es: la materialización, en prosa, de una demostración matemática distribuida por todo el código. Un sistema con unsafe es un sistema de proposiciones y obligaciones. Una unsafe fn es un teorema condicional: “bajo estas hipótesis —mi # Safety—, ejecutarme no provoca comportamiento indefinido”. Un bloque unsafe es una aplicación de teoremas: invoca operaciones cuyas hipótesis debe satisfacer, y el comentario // SAFETY: es la línea de la demostración que verifica que se satisfacen. Leído así, un crate bien documentado no es código con anotaciones: es una prueba escrita en dos idiomas, el ejecutable y el humano, uno al lado del otro. Y aquí está lo que casi nadie entiende al principio: el compilador de Rust verifica el idioma ejecutable, pero no puede verificar el humano. La corrección de un bloque unsafe no es una propiedad que la máquina compruebe; es un teorema que un ser humano demuestra y otro ser humano revisa. Por eso el comentario // SAFETY: no es documentación en el sentido decorativo: es la prueba misma, y omitirlo no es dejar el código “sin comentar”, es dejar el teorema sin demostrar mientras se afirma que es cierto. Esa es exactamente la clase de deuda que estalla a las tres de la madrugada, porque una afirmación de solidez sin su justificación es indistinguible de un error hasta que alguien la ejerce. La cultura del kernel —rechazar todo unsafe sin su razonamiento— no es rigidez de ingenieros maniáticos: es el reconocimiento de que en un sistema donde una sola premisa falsa corrompe la memoria de todo el proceso, la única defensa escalable es exigir que cada premisa venga con su prueba adjunta, legible, revisable, refutable. Documentar la seguridad es, en el fondo, aceptar que en la frontera del unsafe el compilador se aparta y la responsabilidad de la demostración recae en ti; y escribir # Safety y // SAFETY: es honrar esa responsabilidad en lugar de fingir que la máquina la asumió. Un programador de sistemas maduro no se distingue por escribir unsafe con soltura, sino por no dejar jamás un bloque unsafe cuya prueba no pueda poner por escrito.

📝
Lo esencial de documentar la seguridad

unsafe fn significa “tengo precondiciones”, no “hago cosas peligrosas”. Toda función unsafe pública lleva una sección # Safety que declara la deuda: qué debe garantizar el llamador. Todo bloque unsafe lleva encima un // SAFETY: que registra el pago: por qué esas precondiciones se cumplen aquí. En Rust 2024, unsafe_op_in_unsafe_fn exige bloques unsafe explícitos incluso dentro de una unsafe fn, separando exigir de realizar. Clippy (missing_safety_doc, undocumented_unsafe_blocks) lo vuelve mecánico. Cada // SAFETY: es un lema de la demostración de solidez; por eso el kernel lo hace obligatorio.

⚔️ Escribe la prueba junto al código
  1. Explica por qué unsafe fn significa “tiene precondiciones” y no “hace operaciones peligrosas”. Da un ejemplo de función que hace unsafe por dentro pero cuya firma es segura.
  2. Redacta la sección # Safety de una función en_bruto(&self, i: usize) -> &T y el // SAFETY: del bloque que la usa desde un get que comprueba los límites.
  3. Muestra cómo unsafe_op_in_unsafe_fn de Rust 2024 cambia el cuerpo de una unsafe fn. Escribe la versión que compila sin avisos.
  4. Activa #![deny(clippy::undocumented_unsafe_blocks)] en un crate con un bloque unsafe sin comentar y describe qué ocurre al compilar.
  5. Argumenta, con la metáfora de la deuda y el pago, por qué un // SAFETY: ausente equivale a un teorema afirmado sin demostrar, y por qué eso es más grave que un simple unwrap.