Este martes se abrió en el repositorio de Rust una incidencia que en pocas horas se convirtió en lo más comentado de Hacker News. Con un truco tan simple como crear un struct vacío y asignar después uno de sus campos, el compilador deja pasar una referencia que no cumple las reglas de vida del tipo. El resultado es un programa sin una sola línea unsafe que lee memoria ya liberada. El fallo está en el Rust actual, y en Compiler Explorer el mismo código compila desde Rust 1.4, publicado el 29 de octubre de 2015.
La promesa central de Rust es que el código seguro, el que no usa unsafe, no puede provocar errores de memoria: ni accesos a memoria liberada ni carreras de datos. Quien lo vigila es el borrow checker, la parte del compilador que comprueba que ninguna referencia viva más que el valor al que apunta. Cuando alguien encuentra una forma de engañarlo, el equipo lo llama un fallo de soundness y lo marca con la etiqueta I-unsound. No es la primera vez que ocurre: hay 140 incidencias abiertas con esa etiqueta, y la más famosa, la #25860, lleva abierta desde mayo de 2015.
La nueva, la #164250, la publicó un usuario a las 08:45 (hora UTC) con un ejemplo que guarda una referencia a un texto, lo libera y después imprime otro texto distinto que acabó en la misma memoria. En menos de una hora varios colaboradores habituales del compilador habían publicado variantes más cortas, una de ellas sin variables globales, y tras discutirlo en el chat del proyecto recibió prioridad alta. A las 13:41, los moderadores bloquearon los comentarios para evitar el spam. Todavía no hay ninguna corrección propuesta.
Claves del fallo
- Una restricción que se comprueba al crear, pero no al asignar. Un struct puede exigir que lo que guarda viva el tiempo suficiente; por ejemplo,
struct Guarda<'a: 'static>solo debería admitir referencias que vivan durante todo el programa. Si se crea con el valor dentro,Guarda(Some(&x)), el compilador lo rechaza. Si se crea vacío y el campo se asigna después,g.0 = Some(&x), la vida de la referencia se deduce de esa asignación y la restricción del struct ya no se vuelve a comprobar. - El daño lo hace un
Drop. En todos los ejemplos publicados, el struct implementaDrop, el código que se ejecuta al destruir el valor. Ese código confía en la restricción del tipo, por ejemplo guardando la referencia en una lista global como si fuera eterna, y cuando se ejecuta la referencia ya apunta a algo que no existe. - Desde 2015. El autor lo comprobó en las versiones estables hasta la 1.85, como mínimo; un colaborador encontró una variante que compila desde la 1.18, de 2017, y otro llevó la suya hasta la 1.4. En Compiler Explorer, esa última la rechaza Rust 1.3 y la acepta Rust 1.4, la 1.18 y las actuales.
- No depende de la asignación de campos. Otra variante llega al mismo sitio mezclando en un array dos valores del mismo struct y luego intercambiando sus campos, lo que apunta a un problema más general en cómo el compilador unifica tipos con restricciones de vida. Un colaborador lo relaciona con otra incidencia abierta desde julio de 2024.
- Sigue en las versiones actuales. Lo hemos reproducido en Rust 1.99, la versión estable publicada el 1 de octubre, y en las ramas beta y nightly.
Este es el fallo reducido a lo esencial, con nombres en español. La lista global solo admite referencias 'static, y el Drop de Guarda mete en ella la suya porque su tipo promete que lo es:
use std::sync::Mutex;
// Una lista global: solo admite referencias que vivan para siempre ('static)
static GUARDADAS: Mutex<Vec<&'static String>> = Mutex::new(Vec::new());
struct Guarda<'a: 'static>(Option<&'a String>);
impl<'a: 'static> Drop for Guarda<'a> {
fn drop(&mut self) {
if let Some(r) = self.0 {
GUARDADAS.lock().unwrap().push(r); // el compilador cree que r es 'static
}
}
}
fn main() {
{
let nombre = String::from("Ana García");
let mut g = Guarda(None);
g.0 = Some(&nombre); // &nombre vive solo hasta la llave: debería ser un error
} // aquí se libera `nombre`... y su referencia sigue en GUARDADAS
let otra = String::from("¡memoria reutilizada!");
println!("{}", GUARDADAS.lock().unwrap()[0]);
drop(otra);
}rustc --edition 2021 guarda.rs && ./guarda¡memoria r
Compila sin un solo aviso. Al ejecutarlo, en lugar de «Ana García», imprime el principio de otro texto. El texto de nombre ya se había liberado, el programa reutilizó esa memoria para otra, y la referencia guardada lee los 11 bytes que medía «Ana García». Es un comportamiento indefinido: según el sistema y el asignador de memoria podría imprimir basura o hacer que el programa falle, que es lo que pasó en otra de las variantes. Basta con cambiar una línea para que el compilador haga su trabajo:
let g = Guarda(Some(&nombre)); // error[E0597]: `nombre` does not live long enough
Drop que dependa de ellas y crear el valor vacío para rellenar el campo después: una combinación rara en código normal y que nadie escribe por accidente. No hay que actualizar nada ni hay forma de evitarlo con una opción del compilador, porque todavía no existe la corrección. Lo que sí rompe es la garantía: hasta que se arregle, «compila sin unsafe» no basta para asegurar que un programa no lee memoria liberada.Por qué importa
Rust ha ganado su sitio en el núcleo de Linux, en Android, en Windows y en buena parte de la infraestructura de la nube gracias a esa garantía. Cada fallo de soundness no suele ser peligroso en la práctica, pero sí demuestra que la garantía depende de que el compilador no tenga errores, y un error que ha pasado casi once años sin que nadie lo viera recuerda que el borrow checker también es software.
Además, los ejemplos de hoy no son rebuscados. A diferencia de la #25860, que requiere combinar varias reglas avanzadas del sistema de tipos, este se reproduce con unas pocas líneas y una asignación que cualquier programador escribiría. Eso lo hace más fácil de aprovechar a propósito, por ejemplo en una dependencia maliciosa que quisiera parecer segura.
La corrección tampoco será trivial. Exigir que el compilador vuelva a comprobar las restricciones de vida cada vez que se asigna un campo o se unifican dos tipos puede hacer que deje de compilar código correcto que ya existe, así que el equipo de tipos tendrá que medir el efecto en el ecosistema, normalmente con una pasada de crater sobre todos los paquetes publicados, antes de cambiar nada. Hasta entonces, la incidencia #164250 se queda como la última prueba de que ningún compilador es perfecto.
Datos contrastados con la propia incidencia y su historial en la API de GitHub, la incidencia #25860, las notas de versión de Rust y Compiler Explorer, donde probamos el código en Rust 1.3, 1.4, 1.18 y 1.99, beta y nightly. Portada: módulos de memoria DDR, foto de Mixabest, Wikimedia Commons, dominio público (recortada y teñida).
