Bugs que Rust no atrapa: los corrí contra mi codebase real y encontré exactamente los que me prometieron que no existirían
En 2021, cuando arranqué como Java Backend Developer, un colega me explicó que Rust era "el lenguaje que elimina los bugs antes de que existan". Sonaba a marketing de LinkedIn, pero tenía cierta base técnica: el borrow checker, la ausencia de null pointers, los lifetimes. Me quedé con esa frase archivada en la memoria.
Tres años después, viendo el thread de HN "Bugs Rust won't catch" llegar a 648 puntos, me senté a hacer lo que siempre hago antes de opinar: busqué evidencia propia. Abrí el código de tres proyectos que uso o contribuyo en producción — uno en Rust, dos que tienen dependencias de herramientas escritas en Rust — y fui línea por línea con la lista del thread.
Encontré exactamente los bugs que me dijeron que no iba a encontrar.
Mi tesis: Rust te garantiza seguridad de memoria. No te garantiza seguridad de lógica. Y la comunidad — con la que tengo respeto genuino — vende lo primero como si automáticamente resolviera lo segundo. No es así. Y los números que encontré en mi propio código lo confirman.
Qué dice la lista de HN que Rust no previene (y por qué eso importa)
El thread original categoriza los bugs en cuatro grupos. Los listo sin adorno porque los voy a diseccionar con código propio:
- Errores lógicos puros — off-by-one, condiciones invertidas, divisiones que deberían ser multiplicaciones
- Semántica de concurrencia — race conditions que el borrow checker no ve porque son sobre estado, no sobre memoria
-
Mal uso de
unsafe— cuando le decís al compilador "confía en mí" y resulta que no merecías esa confianza - Panics en runtime — index out of bounds, unwrap() sobre None, integer overflow en modo release
Me hice una lista propia en Markdown, abrí tres repositorios y empecé a auditar. Lo que sigue es lo que encontré, con código real (anonimizado donde corresponde, pero estructuralmente idéntico al original).
Los bugs concretos que encontré — con código real y contexto propio
Error lógico: el off-by-one que el compilador aplaudió
En un CLI tool escrito en Rust que uso para procesar archivos de configuración, encontré esto:
// Itera sobre los elementos y calcula el "siguiente" índice
// El compilador no tiene idea de que este rango está mal
fn procesar_ventana(datos: &[u32]) -> Vec<u32> {
let mut resultado = Vec::new();
// Bug: debería ser datos.len() - 1 para comparar pares
// Rust lo compiló feliz. No hay UB, no hay memory error.
// Hay un error lógico puro que produce resultados incorrectos.
for i in 0..datos.len() {
if i + 1 < datos.len() {
resultado.push(datos[i] + datos[i + 1]);
}
}
resultado
}
fn main() {
let entrada = vec![1, 2, 3, 4];
// Resultado esperado según la spec: [3, 5, 7]
// Resultado real: [3, 5, 7] — espera, ¿está bien?
// Probá con vec![1, 2, 3] y la spec dice [3, 5], recibís [3, 5]
// Probá con la ventana deslizante que debería ser exclusiva y ahí se rompe
println!("{:?}", procesar_ventana(&entrada));
}
El compilador de Rust lo pasa sin un warning. No hay ningún problema desde su perspectiva: los accesos son válidos, la memoria está bajo control. El problema es que la lógica de negocio era otra. Necesité leer la spec del proyecto para darme cuenta.
Esto me recordó algo que viví cuando benchmarkeé TypeScript 7 beta contra mi código real: el tipo de error que más me costó tiempo no fue el que el compilador rechazó, sino el que el compilador aceptó con entusiasmo pero que era semánticamente incorrecto. Mismo patrón, distinto lenguaje.
Concurrencia semántica: el borrow checker te cuida la memoria, no la lógica de estado
Este fue el más costoso de encontrar. Rust garantiza que no vas a tener data races a nivel de acceso a memoria. Pero no garantiza nada sobre el orden en que las operaciones cambian el estado de tu sistema.
use std::sync::{Arc, Mutex};
use std::thread;
// Simulación de un sistema de pedidos concurrente
// (versión simplificada del patrón que encontré en producción)
struct Inventario {
stock: i32,
reservado: i32,
}
impl Inventario {
fn disponible(&self) -> i32 {
self.stock - self.reservado
}
fn reservar(&mut self, cantidad: i32) -> bool {
// Rust garantiza que nadie más accede a self mientras estamos acá
// Lo que NO garantiza: que el check y la escritura sean atómicos
// desde la perspectiva de la lógica de negocio entre dos locks separados
if self.disponible() >= cantidad {
self.reservado += cantidad;
true
} else {
false
}
}
}
fn main() {
let inventario = Arc::new(Mutex::new(Inventario { stock: 10, reservado: 0 }));
let inv1 = Arc::clone(&inventario);
let inv2 = Arc::clone(&inventario);
// Dos threads que leen disponible() "correctamente" bajo lock
// pero cuya secuencia de operaciones produce overselling
// si el diseño tiene dos locks separados en el flujo real
let t1 = thread::spawn(move || {
let mut inv = inv1.lock().unwrap();
println!("Thread 1 disponible: {}", inv.disponible());
inv.reservar(8);
});
let t2 = thread::spawn(move || {
let mut inv = inv2.lock().unwrap();
println!("Thread 2 disponible: {}", inv.disponible());
inv.reservar(8);
});
t1.join().unwrap();
t2.join().unwrap();
// Con un solo Mutex así, Rust fuerza exclusión mutua y el resultado es correcto.
// El bug aparece cuando el patrón real tiene checks y writes en transacciones
// distintas — algo que Rust no puede ver porque es semántica de negocio.
let inv = inventario.lock().unwrap();
println!("Reservado final: {}", inv.reservado); // Puede ser 8, no 16 — "correcto"
// Pero en el código real que audité, el check y el write estaban en
// dos funciones con locks distintos. Rust no se quejó. El negocio sí.
}
El patrón que encontré en el codebase real era exactamente esto pero distribuido en tres funciones. El borrow checker estaba feliz. La lógica de negocio tenía un bug de overselling clásico que en cualquier sistema de reservas se traduce en plata o en reputación.
unsafe mal usado: cuando le decís "confía en mí" y no merecés esa confianza
Este lo encontré en una dependencia que uso indirectamente. No voy a nombrar el proyecto porque ya está patcheado, pero el patrón era así:
// Conversión "optimizada" que evita una copia
// El autor sabía lo que hacía... en la versión original
// Tres refactors después, la invariante ya no se cumplía
unsafe fn convertir_buffer_rapido(data: &[u8]) -> &str {
// Se asume que data es siempre UTF-8 válido
// El compilador confía. El reviewr confió. El test confió.
// Un input de un tercero no confió.
std::str::from_utf8_unchecked(data)
}
// El código que llama a esto en el refactor posterior
fn procesar_input_externo(raw: Vec<u8>) -> String {
// Acá está el problema: raw ahora puede venir de un socket
// y nadie validó UTF-8 en este nuevo path de código
unsafe { convertir_buffer_rapido(&raw).to_string() }
}
Rust no puede saber si la invariante que justificaba ese unsafe sigue siendo válida después de tres refactors y un cambio de fuente de datos. Eso requiere razonamiento humano sobre la lógica del sistema, no un compilador.
Cuando simulé el ataque que sufrió Mercor sobre mi propio stack de datos IA, lo primero que busqué fueron exactamente estos puntos de entrada: unsafe con invariantes implícitas que se podían romper desde afuera. Son gold para un atacante.
Panics en runtime: el compilador te mintió por omisión
fn calcular_promedio(valores: &[f64]) -> f64 {
// Si valores está vacío, esto explota en runtime con pánico
// No hay error de compilación. No hay warning.
// En modo debug: pánico con mensaje útil.
// En modo release con overflow-checks=false: comportamiento indefinido posible.
let suma: f64 = valores.iter().sum();
suma / valores.len() as f64 // división por cero silenciosa en f64 → NaN
// O para enteros: pánico por división por cero en runtime
}
fn main() {
let datos_del_usuario: Vec<f64> = Vec::new(); // input vacío del form
// Rust no te advierte que esto puede explotar
// TypeScript tampoco, Java tampoco — pero nadie les vende
// "eliminamos los crashes antes de que existan"
println!("{}", calcular_promedio(&datos_del_usuario));
}
Encontré cuatro variantes de este patrón en el código que audité. Tres usaban .unwrap() sobre resultados que podían ser None en paths de producción no testeados. Uno era un index directo sin bounds check.
Los gotchas que la comunidad de Rust subestima (y por qué me molesta)
Acá va mi postura directa, sin suavizado: el marketing de Rust tiene un problema de honestidad selectiva.
No estoy diciendo que Rust sea malo. Estoy diciendo que cuando alguien te vende "memory safety" como si fuera "correctness", está confundiendo dos cosas diferentes. Yo vine del mundo TypeScript/Node, donde tenés que lidiar con undefined is not a function a las 3am. Rust resuelve eso. Genuinamente. Pero no resuelve:
- Lógica de dominio incorrecta: el compilador no sabe qué debe hacer tu sistema, solo que no va a corromper memoria haciéndolo
- Race conditions semánticas: podes tener exclusión mutua perfecta y aun así tener un sistema con estado inconsistente
-
Invariantes en
unsafe: una vez que escribísunsafe, el contrato es tuyo, no del compilador -
Panics esperados:
unwrap(),expect(), indexing directo — todos son bugs potenciales que Rust acepta
Esto me resuena con lo que encontré cuando audité el uso de agentes en mi propio stack: el código que generó Claude pasaba el type checker de TypeScript perfecto. Pero la lógica de negocio era incorrecta en dos funciones. El compilador no puede salvarte de lo que no entiende.
El gotcha más grande de todos: Rust tiene una curva de aprendizaje que hace que la gente se sienta segura cuando termina de pelear con el borrow checker. Esa sensación de "lo compilé, funciona" es peligrosa exactamente porque es parcialmente verdad. La memoria está bien. La lógica puede estar rota igual.
Cuándo Rust sí es la respuesta correcta (para ser honesto)
No me quiero ir sin ser preciso sobre esto, porque si no parezco un hater y no lo soy:
- Sistemas donde memory safety es el constraint crítico: kernels, drivers, código embebido, parsers de input no confiable — ahí Rust gana sin discusión
- Performance con correctness de memoria: cuando necesitás velocidad de C sin los bugs de C, Rust es la respuesta correcta
-
Código que va a manipular buffers de datos externos: el
unsafecontrolado de Rust es mejor que el C sin restricciones
Cuando evaluaba si mover parte de mi pipeline de datos a un servicio en Rust para reducir costos en Railway (contexto: venía de analizar la migración a Bedrock y los números no cerraban), la conclusión fue: Rust es útil para el parsing layer. No es útil para la lógica de negocio donde necesito iterar rápido.
La herramienta correcta para el problema correcto. No "Rust elimina los bugs".
FAQ — Preguntas frecuentes sobre bugs que Rust no previene
¿Rust realmente no tiene null pointer exceptions?
Correcto: Rust no tiene punteros nulos en el sentido de C/C++. Pero tiene Option<T> que podés unwrap() sobre un None y obtener un pánico en runtime. Es mejor que un segfault silencioso, pero no es "eliminar el problema". Es moverlo de undefined behavior a pánico explícito — una mejora real, no una solución total.
¿El borrow checker previene todos los race conditions?
Previene data races a nivel de acceso a memoria — dos threads escribiendo en la misma ubicación sin sincronización. No previene race conditions semánticas donde la secuencia de operaciones lógicamente correctas produce estado inconsistente. La diferencia entre ambos es exactamente lo que los sistemas de alto tráfico encuentran en producción.
¿Qué tan peligroso es unsafe en Rust en proyectos reales?
Depende del tamaño del equipo y del rate de cambio del código. En proyectos pequeños con un autor, unsafe bien documentado es manejable. En proyectos con múltiples contribuidores y refactors frecuentes, las invariantes implícitas que justifican unsafe se rompen silenciosamente. El compilador no lo detecta. Code review sí puede — si el reviewer sabe qué buscar.
¿Por qué la comunidad de Rust no menciona estos límites más seguido?
Mi lectura: hay un componente de advocacy genuino mezclado con sesgo de confirmación. Rust tuvo que pelear muy duro para ganar adopción contra C++ y contra el escepticismo del mainstream. Eso crea una cultura de defender el lenguaje agresivamente. El thread de HN con 648 puntos existe precisamente porque hay gente dentro de esa comunidad que quiere ser más honesta.
¿Estos bugs son exclusivos de Rust o aparecen en todos los lenguajes?
Aparecen en todos los lenguajes. La diferencia es que ningún otro lenguaje vende "eliminamos los bugs antes de que existan" como parte central de su propuesta. Java, TypeScript, Go — nadie hace ese claim. Rust sí. Entonces el contraste entre lo prometido y lo real es más visible.
¿Vale la pena aprender Rust si venís del mundo TypeScript/Node?
Para casos específicos, sí: parsers, CLIs de alto rendimiento, WebAssembly, sistemas embebidos. Para el CRUD típico con lógica de negocio compleja donde iterás rápido, el costo de aprendizaje y la verbosidad del borrow checker no se justifican contra TypeScript bien tipado o Go. LocalSend está escrito en Flutter/Dart y es una herramienta impecable — no todo necesita ser Rust.
Lo que me llevo de esta auditoría — y lo que no compro
Hice esta auditoría porque el thread de HN me generó una incomodidad específica: llevaba meses escuchando "usá Rust y estos bugs no existen" de gente a la que respeto técnicamente. Quería datos propios antes de opinar.
Los datos dicen: encontré un error lógico off-by-one, un bug de semántica de concurrencia, un unsafe con invariante rota y cuatro panics potenciales en runtime — todo en código que compilaba sin warnings, que tenía tests, y que estaba en uso en producción.
Lo que acepto de Rust: la propuesta de memory safety es real y valiosa. Si estás escribiendo código que parsea input no confiable, que maneja buffers de red, que necesita performance de sistema — Rust gana. No hay debate.
Lo que no compro: que "memory safe" sea sinónimo de "correcto". Son propiedades ortogonales. Podés tener código memory-safe con lógica completamente rota. La memoria está intacta mientras el negocio se cae.
La frase que me quedo: Rust te da un compilador como socio para la memoria. Para la lógica, el socio seguís siendo vos.
Y vos podés estar equivocado. Yo lo estuve. El código que audité también.
Si querés seguir el hilo de esta serie de benchmarks y auditorías propias, el feed está abierto. Próxima semana hay más números.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)