- Scylla propone una ruta para compilar directamente a Rust seguro estructurando gradualmente el C original, en lugar de primero migrar el C existente a Rust unsafe y luego corregirlo
- El objetivo es un subconjunto applicative de C que incluye procesamiento de datos, aritmética de punteros, flujo de control estructurado y código portable; se excluye el código que depende de
goto, casts entero-puntero, trucos con punteros, bitfields y uniones untagged - La transformación tiene dos etapas: primero baja el AST de Clang a Mini-C y luego lo pasa a Rust seguro, haciendo explícitos comportamientos sutiles de C como promociones de enteros, conversiones implícitas, expresiones de asignación e incrementos prefijos/posfijos
- La parte más difícil, la aritmética de punteros, se convierte en particiones basadas en slices de Rust y
split_at_mut/split_at; también se manejan inferencia de mutabilidad, derivación de traits y la elección entreBoxy borrow - Se evaluaron SymCrypt de Windows, HACL★, el algoritmo central de compresión de bzip2, el parser/serializador CBOR de EverParse y partes de Microsoft FrodoKEM; durante la transformación también se encontró undefined behavior en bzip2 y FrodoKEM
La ruta C→Rust elegida por Scylla
- La motivación para migrar código C existente a Rust parte del problema de la seguridad de memoria
- Estudios de Google y Microsoft estiman que el 70% de las vulnerabilidades de seguridad están relacionadas con un manejo incorrecto de la memoria
- Empresas y gobiernos recomiendan usar lenguajes con seguridad de memoria como Rust en sistemas críticos para la seguridad
- Rust tiene ventajas claras para código nuevo, pero reescribir por completo código C industrial ya probado y depurado es difícil
- Las herramientas automáticas existentes de conversión C→Rust, para soportar todo C, suelen generar Rust unsafe
- Permiten modismos estilo C como punteros unchecked o transmutation de Rust
- Se pierden las garantías estáticas de seguridad de memoria, debilitando el propósito de usar un lenguaje seguro
- El flujo de trabajo habitual toma la salida en Rust unsafe como punto de partida y luego la refactoriza iterativamente hacia Rust seguro
- Se han propuesto análisis estáticos que convierten raw pointers en borrows seguros de Rust o recuperan abstracciones de Rust desde representaciones de bajo nivel
- Las herramientas de refactorización están dispersas, y el soporte de
c2rust refactorse discontinuó en 2022
- Scylla elige estructurar gradualmente el código C original para que pueda compilarse a Rust seguro, en vez de corregir el Rust unsafe generado
Subconjunto de C soportado y patrones excluidos
- El objetivo de Scylla es producir una transformación predecible y código Rust cercano al C original
- El objetivo soportado es un subconjunto applicative de C
- Código que manipula y procesa datos
- Código que usa aritmética de punteros
- Código con flujo de control estructurado
- Código portable
- No se soportan codebases que dependen de los siguientes patrones
goto- Uso de la representación de objetos mediante casts entero-puntero
- Trucos con punteros
- Bitfields
- Uniones untagged
- Los desarrolladores pueden aplicar reescrituras y anotaciones dirigidas al código fuente C para que Scylla pueda entenderlo
- Pueden reescribir patrones de aliasing incompatibles con el borrow checker de Rust
- Pueden entregar a Scylla la información de que una tagged union debe traducirse a un ADT de alto nivel
Mini-C: un lenguaje intermedio que reduce las ambigüedades de C
- Scylla parte del AST del frontend de Clang y primero lo transforma a un lenguaje llamado Mini-C
- Mini-C maneja branches, loops, punteros, dereferencias y toma de direcciones como C, pero tiene una semántica “sin sorpresas”
- Todos los enteros tienen ancho fijo
- La integer promotion y la integer conversion de C se expresan como casts explícitos
- No se permiten punteros untyped como
void *
- A diferencia de C, Mini-C es un lenguaje de expresiones
- La asignación no devuelve un valor
- Sintaxis de C como
e1 = e2 = e3op[i++]se desazucara en Mini-C - Las expresiones de prueba de loops y condicionales deben ser de tipo
bool, nointde C
- Al pasar el AST tipado de Clang al AST tipado de Mini-C, se hacen explícitos los comportamientos implícitos de C
- Las condiciones se ajustan a
bool - Los índices de arreglos se ajustan a
size_t - Las conversiones implícitas en argumentos de llamadas a funciones y en el lado derecho de asignaciones se convierten en casts explícitos
- En operaciones aritméticas se reflejan las usual arithmetic conversions del estándar C
- Las condiciones se ajustan a
- La transformación asume que el código C es portable y que no depende del modelo de datos de C
- Por ejemplo, no se espera código cuyo comportamiento cambie según si
longmide 4 u 8 bytes - La implementación detecta en configure-time el modelo de datos de la arquitectura objetivo y convierte
unsigned inta tipos de ancho fijo comouint32_t
- Por ejemplo, no se espera código cuyo comportamiento cambie según si
Composición de ADT y tuplas
- Mini-C ofrece ADT, tuplas y pattern matching de mayor nivel que C
- Las tagged unions se traducen a ADT mediante anotaciones
- La forma objetivo es una estructura como
{ int tag; union { t0 case0; ...; tn caseN }} - Se asume que los valores de tag van de 0 a N y que coinciden con el orden de los cases de la union
- La forma objetivo es una estructura como
- Scylla convierte los tipos tagged union anotados en tipos variant
- Al crear valores, verifica que
.tag = iy.casej = ecoincidan - Si coinciden, los convierte al valor constructor correspondiente
- Si el payload y el tag no coinciden, no los traduce a Mini-C
- Al crear valores, verifica que
- Para que el acceso a campos de una tagged union sea seguro, se debe conocer el estado actual del tag
- Reconoce patrones como
if (x.tag == i) { ... x.casei }oswitch - Los transforma a una forma
match x with | Ci v -> ... - El acceso a otros cases de la union se considera inválido y produce un error de transformación
- Reconoce patrones como
- Las tuplas también pueden componerse mediante anotaciones
- Un struct con
ncampos se transforma en una tupla n-aria - Los accesos a campos se convierten en accesos a campos de tupla
- Las tuplas se tipan estructuralmente y pueden aprovechar el mut-polymorphism
- Un struct con
Transformación de Mini-C a Rust seguro
- Mini-C ofrece una representación de programas C con anotaciones de tipo completas, que luego se transforma a Rust seguro
- Hay tres dificultades principales
- Eliminar la aritmética de punteros de C
- Hacer explícitas la mutabilidad y el aliasing
- Proveer automáticamente estructuras idiomáticas de Rust como traits
- La conversión de tipos de puntero es compleja por las diferencias en la representación de punteros de Rust
- Rust distingue entre
Box<T>y&T - También distingue punteros a un solo elemento y punteros a múltiples elementos, por ejemplo
&Ty&[T] - Los arreglos de Rust son valores y no decaen automáticamente a punteros como en C
- Rust distingue entre
- La estrategia básica es compilar todos los pointer types de C como slice borrows
&[T]de Rust- Tanto los punteros de stack como los de heap se vuelven por defecto slice borrows
- Los punteros a un solo elemento y a múltiples elementos también se vuelven por defecto slice borrows
- La mutabilidad se infiere automáticamente en una etapa separada
- Scylla traduce algunos punteros a
Box<T>mediante heurísticas y anotaciones manuales- Funciones como
T *create(), que no tienen referencias globales y se consideran fresh allocations, pueden traducirse comofn create() -> Box<T> - Este análisis se aplica recursivamente con un enfoque de punto fijo incluso dentro de definiciones de structs y variants
- Los structs en los que permanecen borrows obtienen parámetros de lifetime
- Funciones como
Restricciones en la conversión de Box, slice y array
- En Rust se necesitan conversiones explícitas entre arrays, slice borrows y
Box, por lo que la transformación a Rust de Scylla también opera de forma orientada por tipos - Las reglas de transformación insertan coercions que convierten arrays o boxed slices en slice borrows
- Un array se vuelve un slice borrow con una forma como
&x[..] - Un boxed slice puede convertirse en un borrow
- Un array se vuelve un slice borrow con una forma como
- También es posible la conversión en sentido contrario
- Un slice o array puede promoverse a una heap allocation y convertirse en
Box<[T]>
- Un slice o array puede promoverse a una heap allocation y convertirse en
- Esta conversión inversa puede generar diferencias en la semántica de copia
- En C, arrays y punteros pueden apuntar a la misma memoria
- En Rust,
Box::new(x)puede crear una copy dex - En arrays de tipos básicos como arrays de enteros, no hay forma de hacer opt out del trait
Copy, por lo que Rust puede copiar silenciosamente
- Cuando ocurre este tipo de conversión, Scylla elimina la variable original del entorno y prohíbe su uso posterior
- Si el programa C original sigue usando esa variable, queda un error de transformación
- El desarrollador debe corregir el código fuente C antes de la transformación para dejar más clara la intención
Convertir aritmética de punteros en particiones de slices de Rust
- Los programas C no acceden a los arrays solo mediante un único base pointer; con frecuencia usan patrones en los que dividen arrays en chunks o recorren manteniendo un puntero a la posición actual
- Rust no permite aritmética arbitraria de punteros y ofrece una forma de dividir slices con
split_at_mutosplit_atsplit_at_mutes un primitive que entrega dos sub-slices renunciando a la propiedad del slice original- Mantiene el invariant de Rust de que los datos mutables deben tener un owner único
- Scylla introduce un split tree para alinear la aritmética de punteros de C con el esquema de partición de Rust
- Cada puntero de C se mapea a un split tree
- El split tree cambia de forma flow-dependent
- Rastrea qué acceso a slice de Rust debe reemplazar un acceso de puntero de C en un punto específico del programa
- Como los punteros de C no tienen información de longitud, Scylla asume que los chunks no se solapan
- Si la intención era que se solaparan, no podrá pasar el type checking de Rust y el desarrollador deberá reescribir el código C
- Para que la transformación sea predecible, evita backtracking y se realiza hacia adelante
- En el ejemplo, un array de 32 bytes
abcdse divide en cuatro regiones limb de 8 bytes- En C se usa aritmética de punteros no ordenada de izquierda a derecha, como
abcd + 0,abcd + 16,abcd + 8,abcd + 24 - La transformación a Rust mantiene el historial de llamadas a
split_at_muten un split tree para encontrar el sub-slice correcto
- En C se usa aritmética de punteros no ordenada de izquierda a derecha, como
Objetivos de evaluación y undefined behavior encontrado
- La implementación de Scylla usa Clang para tomar código C existente como entrada y generar Rust seguro como salida
- La evaluación incluye partes de varios proyectos C existentes
- Partes de SymCrypt de Windows
- Partes de la biblioteca criptográfica HACL★
- La parte central del algoritmo de compresión bzip2
- El parser y serializador binario CBOR de la biblioteca EverParse
- La implementación de la primitive criptográfica post-cuántica FrodoKEM de Microsoft
- Estos casos muestran que el subconjunto applicative de C de Scylla puede cubrir varias aplicaciones sensibles a la seguridad
- Durante la transformación, también se identificó y reportó undefined behavior presente en el código C original de bzip2 y FrodoKEM
1 comentarios
Opiniones de Hacker News
Es importante que este trabajo apunta a “bases de código en C que ya fueron verificadas formalmente”.
El C de sistemas común no está verificado formalmente, así que es una historia bastante distinta.
Por ejemplo, un puntero que apuntaba a un arreglo de C en la pila podría traducirse en Rust como
Box<[u8]>, es decir, como un puntero que posee una nueva copia en el heap. Si el código original dependía del hecho de que el puntero apuntaba al arreglo real, el código traducido podría comportarse mal en silencio.La función de traducción automática del subconjunto de C++ con seguridad de memoria de mi proyecto scpptool lo habría manejado moviendo los arreglos a tipos sustitutos e iteradores para preservar la semántica original.
Puede que el proyecto del OP solo trate C que sea fácil de convertir a Rust seguro, pero considerando la dificultad del problema, el logro es respetable y también se le ve cierta utilidad.
Para empezar, no tradujeron C real, sino que modificaron código escrito en F* para que del lado del compilador a C emitiera Rust. No se enfrentaron a C real complejo; como mucho trataron un Mini-C limitado, de esos que podría emitir un compilador de juguete.
Incluso el texto original dice que si el programa C original depende más de
x, la traducción produce un error y le exige al programador modificar el código fuente, lo que significa que esperan que el C ya esté escrito en un estilo que satisfaga al borrow checker de Rust.Suena a la típica expresión académica de “en la Figura 4 presentamos reglas hermosas, pero la implementación real depende de un montón de trucos”.
Peor aún, dicen que los solapamientos distinguibles estáticamente generan errores de compilación, y que si no, el código Rust puede entrar en pánico en tiempo de ejecución. Convertir un programa C verificado formalmente en un programa Rust que “ahora también podría crashear” es raro.
También es impreciso llamar a HACL* una base de código C existente y verificada formalmente. HACL* compila a C, pero no es una biblioteca en C; está escrita en un lenguaje completamente distinto.
Un título honesto habría sido algo como “Compilar un subconjunto de F* a Rust parcialmente seguro, parcialmente formalizado”.
En 2002, investigadores publicaron un paper sobre Cyclone, un dialecto seguro de C, y al portar manualmente código C a Cyclone encontraron bugs de seguridad en el código C existente.
Estas transformaciones de C, manuales o automáticas, tienen potencial no solo para aumentar la adopción de lenguajes más seguros, sino también para revelar bugs existentes.
[1] https://www.researchgate.net/profile/James-Cheney-2/publicat...
Varias ideas de Cyclone llegaron a Rust, y el código puede hacerse funcionar con algo de esfuerzo, pero no compila directamente en plataformas modernas de 64 bits.
http://cyclone.thelanguage.org
Probé portar a Rust algunos proyectos, incluidos proyectos en C, usando C2Rust como primer paso, y llegué a varias conclusiones.
unsafe, las fuertes restricciones de Rust —por ejemplo, la verificación de límites y las firmas estrictas— suelen hacer que los bugs aparezcan rápido.Aun así, las herramientas son esenciales para portar, y mientras más mejoren, más fluido será el proceso.
Basta con representar la memoria como un arreglo y tratar los punteros como índices dentro de ese arreglo. Así se pueden expresar comportamientos de C como la aritmética de punteros sin chequeos o los
union, sin pelearse con el borrow checker, y se preserva la semántica. En C→Java se han usado técnicas similares desde hace mucho.Claro que el valor de esa conversión es ambiguo. En la práctica se parece a compilar C a wasm, pero más lento, y aunque el código resultante sea técnicamente “seguro”, siguen existiendo problemas como que un buffer overflow cree un estado incorrecto o que un puntero colgante permita acceder a datos en un contexto donde no debería permitirse.
Soy el autor. Creo que sería útil ordenar algunas cosas que salieron en varios hilos
unsafe Rustpara las partes que no encajanLo que de verdad me pregunto es por qué habría que hacerlo así
Si es una tecnología capaz de convertir de verdad apps industriales de C a Rust, parecería que también podría facilitar blindar apps C existentes. Bastaría con crear análisis para integrarlos en herramientas existentes como analizadores estáticos o generadores de pruebas
Del mismo modo, también se podrían generar wrappers seguros para permitir escribir código nuevo en Rust junto al C verificado. El código nuevo obtiene los beneficios de Rust, el código existente queda confirmado como seguro y la interfaz también se vuelve más segura
Un traductor completo puede ser ideal. A largo plazo conviene que la base de código tenga un solo lenguaje. Pero para C/C++ existente, la mayor necesidad sigue siendo lograr seguridad con un solo botón y pocos falsos positivos. También podría ser posible corregir automáticamente estructuras peligrosas dentro de C, como las herramientas de compilador de Google o Mayhem de ForAllSecure
Puede ser porque dependen de comportamiento indefinido o no especificado, o porque agregar comprobaciones de seguridad adecuadas reduce tanto el rango de entradas aceptables que dejan de ser útiles
Traducirlos a un lenguaje seguro es objetivamente mejor en esos casos, porque permite mantener la expresividad de las entradas y garantizar estáticamente un comportamiento correcto en tiempo de ejecución
La idea de “C probado en producción” también es difícil de sostener, como muestran innumerables vulnerabilidades críticas. Lo que realmente existe es C que funciona lo bastante bien con suficiente frecuencia como para parecer útil
El código viejo se asume seguro por suerte, no porque esté demostrado. “Demostrar” tiene un significado específico, sobre todo en el contexto de este tipo de artículos, y la abrumadora mayoría del código C no está demostrado bajo criterios matemáticos rigurosos. En cambio, la corrección del sistema de tipos de Rust sí está demostrada matemáticamente
Un traductor completo depende de qué estés dispuesto a sacrificar. Si puedes sacrificar rendimiento, rango de entradas, rango de salidas, legibilidad del código, etc., hasta cierto punto es posible; pero los problemas aparecen cuando empiezas a querer un traductor sólido y completo en todos esos aspectos
Si se traduce ingenuamente a Rust, ¿no terminaría mezclando partes seguras y partes
unsafe? Entonces el trabajo manual solo tendría que verificar la seguridad de las zonasunsafe. Sería parecido a escribir Rust desde ceroSi el 90% del resultado no es
unsafe, parece que podría ser una ganancia considerableunsafe Rustde bajo nivelSe sabía que OpenJPEG provocaba un segfault con cierto caso de prueba, y al correr esa prueba en la versión Rust, se produjo un segfault en el código Rust correspondiente al mismo lugar. Al menos era compatible
Pero ese enfoque es un callejón sin salida. Para avanzar, el traductor tiene que reconocer modismos comunes de C y elevarlos a formas naturales del lenguaje destino. Si “compilas” a Rust, obtienes un Rust horrible lleno de llamadas a funciones de manipulación de punteros al estilo C inseguro
El mayor problema de elevación tiene que ver, en su mayoría, con punteros. El resultado más prometedor de este artículo es que encontró una forma de convertir la aritmética de punteros de C en slices de Rust. Los slices pueden hacer la mayor parte de lo que hace la aritmética de punteros de C, y ahora alguien automatizó esa traducción. La aritmética de punteros que no se pueda traducir debería verse con muchísima sospecha
Es útil pensar que los punteros crudos que apuntan a arrays en C tienen una longitud implícita asociada. Esa longitud no aparece en el código fuente C, pero existe en alguna parte como función del estado del programa. Puede ser una constante, el tamaño solicitado a
malloco un parámetro de función. Para un programador de mantenimiento, encontrar la longitud de un array no suele ser demasiado difícilEste podría ser un problema adecuado para un LLM. La idea sería preguntarle “mira este código y encuentra cuál es la longitud del array
foo”, y luego hacer que un traductor que no sea el LLM use eso para guiar la conversión a Rust. Si el LLM se equivoca, Rust producirá un error de índice o tendrá un array demasiado grande, pero no será inseguro. Los modismos de C para la información de tamaño de arrays son lo bastante formalizados como para acertar en la mayoría de los casos. En particular, los LLM también pueden leer comentariosunsafe, porque usaría punteros crudos por todas partes en vez de referenciasEl código C no se escribe teniendo en cuenta el modelo de aliasing de Rust ni las restricciones del borrow checker, así que traducirlo a referencias es difícil
Solo compila un subconjunto de C muy pequeño. En la práctica, podría ser tan pequeño que resulte inútil
Tengo pocas expectativas para este enfoque. Inevitablemente se topará con los límites de lo que puede hacerse con análisis estático de código C. Además, al elegir Rust como destino, el problema se vuelve innecesariamente difícil. El modelo de propiedad de Rust es demasiado distinto de la forma en que funcionan los programas reales en C
Por ejemplo, los buffers de C obviamente tienen una longitud, pero en C la longitud no está vinculada explícitamente al puntero. Por eso el traductor tiene que inferir cómo el programa en C rastrea la longitud y convertirlo en un slice. Incluso si la longitud es una variable explícita no es fácil, y se vuelve más complicado si se calcula o si la representación cambia a una forma de “puntero al elemento posterior al final”
Patrones de C como
bool should_free_this_pointertambién pueden trasladarse a un enumOwned/Borrowedde Rust, pero hay que inferir qué asignación está conectada con qué booleano y cuál es el verdadero alcance seguro de la variante prestadaAun así, estoy de acuerdo en que producir Rust idiomático a partir de C arbitrario será difícil. Digamos que quedará en un nivel de “más o menos correcto”
Me da curiosidad cómo se compara esto con la función de traducción de C de Zig
Zig parece destacarse para crear un entorno mixto donde el código nuevo está en Zig y el legado queda en C, hacer traducción o interoperabilidad, e incluso actuar como compilador de C
Seguramente hay muy buenas razones por las que los mantenedores del kernel de Linux no ven a Zig, en lugar de Rust, como reemplazo de C. No sé lo suficiente como para especular, así que me gustaría que gente con más conocimiento lo explicara
No soy mantenedor del kernel, pero si tuviera que adivinar dos grandes razones por las que se eligió Rust por encima de Zig, podrían ser que el lenguaje ofrece mejores garantías en tiempo de compilación y que su adopción avanza más rápido
Grandes empresas de la industria están haciendo mucho trabajo para ofrecer código nativo en Rust para APIs o bindings de Rust mantenidos. Los desarrolladores de Windows también están reescribiendo partes de su kernel en Rust. Es un movimiento que lleva bastante tiempo y espero que no se detenga
Puede que los mantenedores sientan que Zig no aporta suficientes ventajas frente a C. Muchos de ellos todavía se oponen incluso a Rust
Zig tiene una interoperabilidad con C mucho mejor que Rust, pero no es seguro en memoria y tampoco está estabilizado. La adopción de Zig en el mundo C probablemente crecerá bastante, pero es difícil verlo como competidor directo de Rust
En mi región nadie adopta Rust, y la gente de C++ se queda en C++. Al principio había algo de interés en Rust, pero no logró consolidarse en ninguna empresa que conozca. Probablemente sea por razones parecidas a por qué Go creció mucho en empresas jóvenes, pero no logró entrar bien en compañías tradicionales de Java/C#. Aunque tenga sentido técnicamente, es un enorme desafío de gestión del cambio
Zig está ganando tracción en programas que no necesitan asignación dinámica de memoria, pero no mucho más allá de eso
Sigue teniendo cambios que rompen compatibilidad con regularidad, lo cual ahora es bueno para Zig, pero no para una base de código tan enorme y longeva como Linux. También aparecen bugs del compilador
Lo digo como alguien a quien, en general, le gusta la dirección de Zig
Me pregunto si herramientas como
C2Rustpodrían usar esto para generar código formalmente correctoTambién me da curiosidad cuánto trabajo manual hicieron los autores, o si ejecutaron algo para generar el código Rust. Si es así, no sé dónde está el código que genera Rust, y tampoco veo un enlace al repositorio fuente
Si una biblioteca en C funciona, es decir, si en general anda bien aunque no haya sido demostrada formalmente como libre de problemas, me pregunto por qué no traducirla usando
unsafe RustCreo que tiene valor, porque en general a Rust le faltan bibliotecas. Al final, no es muy distinto de usar una dll/so escrita en C que en ciertas situaciones podría no ser segura