iddqd, o el tipo más difícil de unsafe Rust
(oxide.computer)- iddqd es una biblioteca de mapas de Rust que toma prestada la clave desde el valor, y se usa para mantener índices en memoria de registros grandes como inventarios de discos y sleds en el plano de control Omicron de Oxide, donde la corrección es crítica
- El
BTreeMapestándar guarda claves y valores por separado, lo que vuelve engorroso pasarlos juntos o puede hacer que claves duplicadas se desalineen, pero IdOrdMap hace búsquedas extrayendo la clave de un campo dentro del registro - unsafe Rust es una vía de escape para expresar programas seguros que el compilador no puede demostrar, y cuando código genérico llama implementaciones de traits provistas por el usuario, debe resistir incluso Rust seguro patológico
- La iteración mutable de
iddqddepende del invariante de que todos los índices son distintos para extender tiempos de vida, y una implementación patológica de Ord podía desalinear el B-tree y el conjunto de ítems, creando índices duplicados para el mismo elemento - La corrección compara tanto clave como índice y, si eso falla, vuelve a un escaneo lineal que no llama código del usuario, además de combinar Miri, pruebas basadas en modelos, inyección de fallos por
panicy revisión adversarial con LLM para lograr suficiente confianza
El problema que resuelve iddqd
iddqdes una biblioteca de Rust que ofrece mapas cuya clave se toma prestada desde el valor, y Oxide la usa ampliamente en el plano de control de Omicron- Omicron es el software que, en el centro de un rack de Oxide, aprovisiona recursos como cómputo y almacenamiento y mantiene el rack funcionando continuamente; si
iddqdfalla, el plano de control puede comportarse incorrectamente de formas impredecibles y difíciles de diagnosticar - Una estructura de la biblioteca estándar de Rust como
BTreeMap<Email, User>almacena la clave, email, separada del valor - Para pasar clave y valor juntos, hay que obtener
(email, user)conget_key_value, y si en el momento de la consulta se crea una estructura separada comoUserRecord, se vuelve difícil de manejar cuando hay muchos tipos de registros - Si también se duplica
emaildentro deUser, aparece el riesgo de que la clave del mapa y el email dentro del valor no coincidan IdOrdMaphace que la clave se extraiga del registro mediante el traitIdOrdItem, y el tipo de clave puede tomarse prestado desde el valor, por ejemplotype Key<'a> = &'a Email- En Oxide, este patrón encajó bien con registros grandes como los resultados de consultas a base de datos, y resultó útil para indexar registros grandes en todo el plano de control
Funciones adicionales
iddqdsoporta como ciudadanos de primera clase claves complejas tomadas de varios campos, sin recurrir a atajos como dynamic dispatchBiHashMapyTriHashMapindexan cada elemento por dos o tres claves respectivamente, evitando el patrón común de mantener varios mapas a mano- La implementación de Serde serializa como secuencia en lugar de mapa, para permitir serializar en JSON claves que no son strings, y rechaza claves duplicadas
- También soporta serialización en forma de mapa por compatibilidad hacia atrás
Dónde unsafe Rust se vuelve más difícil
- En Rust, el riesgo central es el comportamiento indefinido (undefined behavior, UB); si un código seguro no puede causar UB de ninguna manera, es sound, y si no, es unsound
- Dentro de Rust seguro, el compilador rechaza programas con UB, pero por las limitaciones del análisis estático también rechaza algunos programas que no tienen UB
- La palabra clave
unsafees una vía de escape para expresar ese tipo de programas, donde quien lo escribe asume la responsabilidad de la soundness y le pide al compilador que confíe en ello - Entre las reglas de Rust que el código unsafe debe respetar están: no permitir data races, no leer memoria no inicializada o ya liberada, no permitir múltiples alias
&mutsobre la misma región de memoria, y no modificar datos inmutables - Estas reglas son especialmente difíciles de razonar por el aliasing mutable, por lo que normalmente hace falta encapsular
unsafedetrás de una abstracción segura
Etapas para validar una abstracción segura
split_at_mutes un método seguro que divide un slice mutable en dos partes, pero esta división no puede expresarse solo con Rust seguro, así que necesita unsafe- La soundness de
split_at_mutdepende de invariantes en el código seguro que lo rodea, como haber recibido un&mut [T], haber verificado quemid <= slice.len(), y haber calculado correctamente la longitud restante - El
getdeMyVec<T>depende de quelensea correcto y de que esté dentro de rango, y esa condición debe ser preservada por todos los demás métodos del mismo módulo - La dificultad máxima aparece cuando código unsafe genérico llama código provisto por el usuario
- El código escrito en Rust seguro, por más patológico o adversarial que sea, no debe poder hacer que el código unsafe provoque UB
- Código como
collect_exact, que confía enlen()deExactSizeIteratory escribe hasta la capacidad reservada, normalmente es unsound si el iterador devuelve una longitud falsa, porque terminaría escribiendo en memoria no asignada std::io::Readtambién es un trait cuyas implementaciones con errores pueden devolver mal la cantidad de bytes leídos, y Rust RFC 2930 trata este problemaiddqdcae en esta categoría más difícil porque es una estructura de datos genérica que llama implementaciones de traits provistas por el usuario
Estructura interna de iddqd
iddqdcombina una lista de elementos,ItemSet, con una tabla que contiene índices de slotsIdHashMap<T>usa unItemSet<T>y unhashbrown::HashTableque almacenaItemIndexItemSet<T>tiene unVec<ItemSlot<T>>, yItemSlot<T>puede serOccupied(T)oVacant { next: ItemIndex }free_headapunta al slotVacantliberado más recientemente o a un sentinel que indica que no hay slots libres, yfree_headjunto con los slotsVacantforman la free chain- Al insertar un nuevo elemento, primero se revisa
free_headpara ver si hay un slot reutilizable; si lo hay, se sobrescribe el slotVacantconOccupiedy luegofree_headavanza al siguiente valor - Si no hay slots libres, se hace push de un nuevo slot
Occupiedal final, luego se obtiene la clave, se calcula el hash y se registra el nuevo índice en la hash table - La eliminación hace lo inverso: busca y elimina el índice por clave en la hash table, convierte el
ItemSlotcorrespondiente enVacanty enlaza elfree_headanterior comonext IdOrdMapusa un índice B-tree en lugar de hash table, yBiHashMapyTriHashMapalmacenan dos y tres hash tables respectivamente
Iteración mutable y extensión de tiempos de vida
IdOrdMap::iter_mutitera mutando los elementos en orden de clave- Un iterador de Rust debe devolver valores que no tomen prestado del propio iterador, y combinadores como
collectpueden dejar valores comoVec<&mut T>incluso después de que el iterador ya desapareció - Para que esto sea seguro, el iterador no debe devolver el mismo valor dos veces
- El borrow checker solo ve accesos secuenciales a una lista y no puede saber que todos los índices son distintos
iddqdusa extensión de tiempos de vida constd::mem::transmute::<&mut T, &'a mut T>, y esto depende del invariante de que los índices devueltos porself.itersean todos distintos
El bug causado por una implementación patológica de Ord
- En un
IdOrdMapcon cinco elementos cuyas claves van de 0 a 4 en orden, si se consulta la clave 0 con laEntry API, elOccupiedEntryguarda internamente el índice 0 - Si después la implementación de
OrddeKeycambia para devolver siempreEqualsin importar el valor,OccupiedEntry::removepuede volver a bajar por el B-tree y eliminar el elemento incorrecto basándose solo en la comparación de claves - Por ejemplo, si primero compara
(key = 2, index = 2)en el B-tree, ese entry se elimina por culpa deEqual, mientras que el item set elimina el elemento 0 porqueOccupiedEntryconservaba el índice 0 - En ese estado, el B-tree aún contiene el índice 0, pero el slot 0 del item set queda como vacant, y el elemento 2 permanece en el item set aunque ya no tenga un puntero desde el B-tree
- Si luego
Ordvuelve a comportarse normalmente y se inserta un elemento con clave 1000, se reutiliza el slot 0 apuntado porfree_head - El resultado es que el B-tree termina con índices duplicados que apuntan al mismo slot, y
IterMutpuede crear múltiples referencias&muta la misma memoria, volviéndose unsound
Forma de la corrección y compromiso de rendimiento
- Se cambió la lógica para que al bajar por el B-tree verifique no solo igualdad de clave, sino también igualdad de índice
- Al buscar una clave con un índice conocido, se comparan ambos,
(key, index), así que aunque unOrdpatológico devuelvaEqual, la comparación entre(key = 2, index = 2)y el índice buscado 0 resultaLesspor el desempate con el índice - Para que esta búsqueda tenga éxito, el índice almacenado debe coincidir realmente con el índice buscado
- El desempate evita emparejar el elemento incorrecto, pero no garantiza que siempre se encuentre el elemento correcto
- El B-tree está ordenado por clave y el desempate compara el índice, por lo que ambos órdenes en general son independientes
- Si la búsqueda en el árbol falla, se hace un escaneo lineal del B-tree para eliminar ese índice sin llamar código del usuario
- Ese fallback vuelve lineal una operación de eliminación que normalmente sería logarítmica, pero se considera un compromiso aceptable porque solo se alcanza si hay código de usuario defectuoso
Capas de verificación
- Como
iddqdse usa como estructura de datos fundacional, combina revisión analítica con múltiples formas de validación empírica - Cada bloque unsafe y cada patrón unsafe es analizado por entre uno y tres autores o revisores de Rust
- Encima de cada bloque unsafe se deja el razonamiento en un comentario
// SAFETY:, y el lintundocumented_unsafe_blocksde Clippy verifica que el comentario exista - Las pruebas basadas en ejemplos crean mapas, ejecutan operaciones y luego verifican los resultados;
iddqdtiene unit tests internos e integration tests sobre la API pública - Estas pruebas también existen como doctests, cumpliendo además una función de documentación
- Las pruebas patológicas suministran un
Orddefectuoso y otras implementaciones de traits anómalas - En CI se ejecutan tanto pruebas normales como patológicas bajo Miri para detectar varios tipos de UB
- Condiciones como el invariante de no tener índices duplicados también pueden verificarse en entornos normales de prueba fuera de Miri
Pruebas basadas en modelos e inyección de fallos
iddqdusa dos capas de pruebas aleatorias: model-based testing comparado contra un oráculo correcto, y fault injection por encima de eso- El model-based testing, o stateful property-based testing, aplica secuencias aleatorias de operaciones a una instancia del tipo y compara los resultados con un oráculo conocido como correcto
iddqdejecuta pruebas basadas en modelos extensivas usando como referencia eloráculo NaiveMap, que es ineficiente pero claramente correcto- La inyección de fallos consiste en introducir errores aleatorios en el código del usuario, y en
iddqduna dimensión especialmente útil fue la seguridad frente apanic - Aunque el código del usuario haga
panica mitad de una operación, los invariantes del mapa no deben romperse - Para cada operación del mapa se añade una cuenta regresiva aleatoria de
panic, que se reduce en 1 en cada llamada a código del usuario, y al llegar a 0 provoca unpanic, permitiendo pruebas aleatorias de seguridad frente apanic - Este método encontró varios bugs sutiles en
iddqd, y algunos derivaban en unsoundness - Las pruebas basadas en modelos también verifican después de cada operación invariantes internos como no-duplicate-index
- Como estas pruebas son demasiado lentas para ejecutarse bajo Miri, se verifican por separado los invariantes de los que dependen la soundness y la corrección
Revisión adversarial con LLM y técnicas formales
- Un modelo frontier de la generación actual encontró varias implementaciones patológicas de código de usuario que podían corromper el mapa
- En un caso notable, el LLM construyó una ruta donde un asignador personalizado hacía
panicy desenrollaba la pila, rompiendo los invariantes del mapa - Este era un problema de seguridad frente a
panicdistinto de lospanicnormales en código de usuario, como los de implementaciones deOrd, que ya cubrían las pruebas existentes - Como un LLM también puede producir afirmaciones plausibles pero incorrectas sobre soundness, una defensa útil es un red-green TDD con Miri como oráculo
- Ante un bug de soundness, primero se crea una prueba que muestre UB bajo Miri, y después de corregirlo se vuelve a confirmar que esa misma prueba pase
- Un enfoque de probar invariantes del mapa con un model checker como
Kanichoca con queiddqdes demasiado complejo y la prueba necesaria crece más de lo que la herramienta puede manejar Creusotpuede ayudar a demostrar que código Rust está libre depanicy otros errores, pero hoy no puede probar invariantes que deben mantenerse incluso si el código del usuario hacepanico se comporta malNaiveMapcumple el papel de especificación más cercana deiddqd, y al ejecutar en CI miles de pruebas basadas en modelos se obtiene una alta confianza en que la implementación coincide con esa especificacióncargo-anneales una herramienta en desarrollo de interés porque permitiría poner una prueba de soundness en Lean dentro de un doc comment junto al código unsafe Rustiddqdtiene invariantes claros y un alcance limitado pero no trivial, así que es un buen candidato para servir como benchmark de herramientas de verificación formal
Conclusión
- El Rust genérico unsafe es extremadamente difícil porque debe preservar cada invariante incluso frente a implementaciones arbitrarias y adversariales de traits
- El bug de
iddqdmuestra que una implementación patológica deOrdpuede engañar al mapa para crear alias mutables sobre la misma memoria - Como ninguna técnica por sí sola puede detectar todos los bugs, se combinan razonamiento humano línea por línea sobre unsafe, pruebas basadas en ejemplos, pruebas patológicas, pruebas aleatorias y revisión adversarial con modelos frontier
- Oxide considera que este nivel de rigor está justificado en infraestructura fundacional
1 comentarios
Comentarios en Lobste.rs
Si lo entendí bien (lo vi desde el teléfono mientras iba en camino), esto parece resolverse usando un tipo wrapper y
HashSet/BTreeSeten lugar deHashMap/BTreeMapEl tipo wrapper no es estrictamente necesario, pero pensando en la seguridad y el mantenimiento futuro, parece una buena decisión
Lo único que hace falta es un wrapper de tamaño 0 que envuelva al objeto, con una implementación personalizada de
PartialEq/Hashque mire solo los campos relevantes. Si quieres buscar sin construir el valor completo, se puede crear un segundo tipo que implementeAsRefpara el tipo de valorEsta forma reutiliza tal cual la interfaz existente de
HashSet/BTreeSetsinunsafe. En vez de envolver el tipo de valor/clave, también podrías crear un nuevo wrapper deHashSet/BTreeSetque se encargue de ese comportamientoTambién incluye cosas como la API de
Entry, acceso mutable/iteración, etc. Dentro deiddqd, la mutabilidad se maneja con bastante cuidado, y en algunos lugares se usan valores atómicos para permitir mutabilidad interna. Ese manejo habría sido bastante difícil sin indirección por índicePara verificar formalmente
iddqd, al principio yo intentaría usar un model checker como Kani para demostrar que el mapa no rompe sus invariantes internas. Pero me da curiosidad la parte donde dices queiddqdes un poco demasiado complejo para lo que Kani puede manejar, y que la prueba necesaria crece hasta un punto que la herramienta no soporta¿Podrías compartir más detalles sobre esa parte? Me pregunto si significa que la complejidad computacional del algoritmo se degrada al peor caso
En general, es un caso interesante para métodos formales, y me gustó que tocaras ese tema. Al ver casos exitosos de herramientas de verificación formal ya existentes en proyectos grandes, uno podría pensar ingenuamente que al menos sería posible probar la corrección de una estructura de datos, pero en la práctica se ve que la dificultad varía según la estructura, y que incluso en lenguajes que se consideran más favorables para la demostración que Rust, donde se permite aliasing ilimitado, no es algo fácil en términos prácticos
Un poco fuera de tema, pero también me da curiosidad cómo hicieron los diagramas. ¿Escribieron un script de una sola vez, o son piezas especiales hechas a medida para el diseño del blog/sitio web de Oxide? No parecen una herramienta genérica
También traté de reducir el alcance. Por ejemplo, asumí que
hashbrownera correcto, pero eso tampoco ayudó mucho. Para ese punto ya casi me había rendido. Respecto a implementaciones de trait bien portadas, estoy bastante convencido de queiddqdes correcto, y lo que de verdad me interesaba desde el lado de los métodos formales era una prueba que también valiera para implementaciones arbitrarias y defectuosasDicho eso, yo no soy la persona más experta usando Kani. Me encantaría que tú u otra persona lo intentaran
Primero hice un boceto de los diagramas en Mermaid, y luego el excelente diseñador Ben Leonard los refinó a mano para ajustarlos a nuestra paleta de colores y tema
El patrón de mapa basado en campos, donde se indexa un objeto usando como clave uno de sus propios campos, es algo que siempre he sentido que también sería bueno poder usar libremente en C#
Hace tiempo intenté construir algo simple por mi cuenta, pero la interfaz no quedó muy limpia y lo abandoné. Este artículo me da ganas de intentarlo otra vez
cargo-nextesthasta proyectos personalesUsar un solo campo como clave es el caso más común, pero
iddqdes lo bastante flexible como para usar cualquier combinación de campos, subcampos o funciones puras y baratas de calcular a partir de campos. Por ejemplo, puedes buscarArtifactKeyen https://docs.rs/iddqd/latest/iddqd/ (perdón si no estás familiarizado con la sintaxis de Rust)Cuando diseñé
iddqd, sentía con fuerza que el usuario debía poder aprovechar todo el poder del sistema de tipos de Rust, sin importar cuántos rodeos tuviera que dar yo como autor de la biblioteca. De verdad quería queiddqdfuera una biblioteca agradable de usar para sus usuarios, especialmente para mis colegas)