1 puntos por GN⁺ 2 시간 전 | 1 comentarios | Compartir por WhatsApp
  • assert es un mecanismo para expresar en el código precondiciones, postcondiciones e invariantes, y cuando una restricción puede imponerse con el sistema de tipos, es preferible expresarla con las funciones del lenguaje
  • En Zig, std.debug.assert no es un macro sino una función normal, que marca rutas imposibles mediante unreachable y también se aprovecha para optimización
  • En Debug y ReleaseSafe, un assert fallido hace que el programa se cierre con panic, pero en ReleaseFast y ReleaseSmall puede provocar comportamiento ilegal no verificado y hacer que funcione mal
  • Si desactivas los assert en producción, pierdes la oportunidad de descubrir rápidamente suposiciones erróneas, y luego el código puede terminar dependiendo de assert incorrectos hasta convertirse en una vulnerabilidad
  • Elegir entre ReleaseSafe y ReleaseFast depende de las prioridades del programa, pero el punto clave es no tapar el problema desactivando los assert, sino corregir los assert incorrectos

El papel de assert y el comportamiento predeterminado de Zig

  • assert es un mecanismo para expresar en código que ciertas condiciones siempre deben ser verdaderas, como “este argumento no puede ser null” o “este entero no puede ser par”
    • Ej.: assert(my_arg != null);, assert(my_num % 2 != 0);
    • Si una restricción puede imponerse con el sistema de tipos, es mejor usar una función del lenguaje en lugar de un assert
    • En Zig, un puntero normal *Foo no puede ser null, y un puntero opcional ?*Foo sí puede serlo, pero obliga a verificarlo antes de acceder al valor
  • Los assert son adecuados para expresar precondiciones, postcondiciones e invariantes
    • Un buen assert puede ser más poderoso que una prueba unitaria para detectar errores de programación
    • Su efecto puede aumentar todavía más si se usa junto con fuzzing

unreachable y los assert en Zig

  • El assert de Zig se basa en unreachable, una función del lenguaje que marca rutas de código inválidas
    • En un switch, una rama imposible puede marcarse como .a => unreachable
    • unreachable puede usarse tanto como sentencia como en lugares donde se requiere una expresión de cualquier tipo
    • No hace falta fabricar a la fuerza un valor temporal cuando algo no debería poder alcanzarse
  • std.debug.assert de la biblioteca estándar de Zig está implementado así
    pub fn assert(ok: bool) void {
      if (!ok) unreachable; // assertion failure
    }
    
  • La información de unreachable puede aprovecharse para optimización
    • El compilador puede eliminar rutas inalcanzables, y esa información puede propagarse para permitir optimizaciones no locales
    • No todos los assert mejoran el rendimiento, pero sí pueden habilitar optimizaciones que el programador no anticiparía fácilmente

Modos de compilación y seguridad en tiempo de ejecución

  • Zig tiene modos de compilación Debug, ReleaseSafe, ReleaseFast y ReleaseSmall
    • Esta configuración no necesariamente se aplica solo de forma global a todo el programa
    • Cada dependencia puede compilarse en un modo distinto, y con @setRuntimeSafety también puede ajustarse la seguridad en tiempo de ejecución por bloques dentro de una función
  • En Zig, la falla de un assert se considera “illegal behavior”
    • En los modos checked —Debug, ReleaseSafe y @setRuntimeSafety(true)— el programa se cae con un panic
    • En los modos unchecked —ReleaseFast, ReleaseSmall y @setRuntimeSafety(false)— ocurre “unchecked illegal behavior” y el programa se comporta mal
  • El resultado del unchecked illegal behavior no está garantizado
    • En el switch del ejemplo, por las características del código máquina generado actualmente, puede parecer que salta a otra rama
    • En otra versión del compilador, el comportamiento erróneo podría ser completamente distinto
    • Puedes ver el comportamiento relacionado en este ejemplo de godbolt
  • También puede verse en otro ejemplo de godbolt cómo cambian el assert y el switch posterior entre ReleaseSafe y ReleaseFast
    • En ReleaseFast, aparece una forma en la que la función se salta todas las comparaciones y devuelve true
    • Este tipo de optimización es justo del que dependen mucho los videojuegos y otras aplicaciones multimedia en tiempo real

El assert de Zig no es un macro

  • std.debug.assert de Zig no es un macro, sino una función normal
    • Zig no tiene macros
    • Este suele ser un punto especialmente sorprendente para desarrolladores de C/C++ que se acercan a Zig
  • En C/C++, al desactivar assert suele ser común que toda la llamada y la expresión pasada actúen como si quedaran comentadas
    • Por eso, en C/C++ no conviene poner en assert expresiones con efectos secundarios
    • Si assert se desactiva, esa operación en sí puede desaparecer
  • En Zig, según las reglas de llamada a funciones, los argumentos se evalúan antes de llamar a la función
    • Independientemente de la lógica interna de std.debug.assert, la expresión del argumento sí se evalúa
    • Por eso pueden ponerse en assert expresiones con efectos secundarios, como en este caso
    // assert that the remove operation is not a noop:
    assert(my_map.remove("expected-to-exist"));
    
  • En cambio, si calcular la condición del assert requiere una operación compleja, en modo unchecked ese cálculo no necesariamente se eliminará
    • En esos casos, conviene proteger el código con comptime if
    const builtin = @import("builtin");
    
    if (builtin.mode == .Debug) {
      var condition = ...;
      // whatever bookkeeping is necessary
      // to compute the condition
      assert(condition == .ok);
    }
    
  • Si vienes de la semántica de C/C++, esto puede resultar extraño, pero en Zig se parte de la idea de que normalmente no se desactivan los assert

El problema de desactivar assert en producción

  • En términos generales, hay tres opciones con los assert
    • Mantenerlos como chequeos en tiempo de ejecución y hacer que, si fallan, el proceso se cierre con panic
    • Usarlos para optimización de rendimiento y aceptar que, si el assert es incorrecto, el programa pueda comportarse mal
    • Desactivarlos por completo
  • std.debug.assert no ofrece soporte predeterminado para desactivar por completo los assert
    • Si implementas tu propio assert que revise una bandera de compilación, puedes acercarte más al comportamiento típico de C/C++
  • Normalmente, querer desactivar assert es el resultado de combinar dos motivos
    • No quieres mantener el chequeo en tiempo de ejecución por su costo de rendimiento o por no querer que la aplicación se caiga
    • No confías del todo en que el assert sea siempre correcto, y temes el mal funcionamiento que podría producirse si se usa para optimización
  • Como recordó matklad en una discusión relacionada, sí existen situaciones con razones legítimas de ingeniería para evitar un crash
    • Pero en el software general, tomar por defecto la evasión del crash se considera una mala elección
  • Si desactivas un assert, aunque se dé una condición que se asumía imposible, el programa sigue ejecutándose
    • El programa sigue funcionando bajo una suposición falsa, y eso ya es una forma de mal funcionamiento aunque no llegue a ser unchecked illegal behavior
  • Lo peligroso del unchecked illegal behavior o del undefined behavior de C es que pueden abrir la puerta a convertir el programa en una weird machine
    • En software lo bastante complejo, incluso sin UIB, el programa puede torcerse de formas no previstas
    • Que un assert resulte falso en tiempo de ejecución significa salirse de la especificación, y eso por sí solo puede hacer que ejecute tareas no deseadas
    • La inyección SQL es un ejemplo concreto y muy extendido de mal funcionamiento tipo weird machine sin necesidad de UIB
  • Si el costo de un mal funcionamiento del programa es demasiado alto, entonces conviene dejar los assert activados
    • Si el rendimiento es tan importante que puedes aceptar el riesgo de mal funcionamiento, entonces tiene sentido usar assert como oportunidad de optimización
    • Si desactivas assert, es fácil perder rendimiento y además engañarte creyendo que estás más seguro de lo que realmente estás

Cómo los assert incorrectos engañan a la base de código

  • El riesgo central es que un assert incorrecto puede no mostrarse en las pruebas y fallar solo en producción
    • Si pudiera garantizarse que todos los assert son siempre verdaderos, no habría discusión sobre usarlos para optimización
    • Si pudiera garantizarse que las pruebas detectan todos los assert incorrectos, la optimización en producción también sería segura
    • En la práctica, pueden escribirse assert incorrectos y las pruebas no necesariamente los detectan
  • Si desactivas los assert en producción, pierdes la oportunidad de descubrir un assert incorrecto lo antes posible
    • Un problema aún más grave es que el código posterior puede seguir escribiéndose dependiendo de ese assert incorrecto
  • En el código de ejemplo, se asume mediante un assert que processThing solo debe llamarse con un thing que ya fue iniciado
    fn processThing(thing: Thing) void {
       // this function must always be invoked on
       // a thing that has already been started
       assert(thing.is_started);
    
       // ...
    }
    
  • Ese assert podría no fallar en pruebas, y al estar desactivado en producción podría pasarse por alto que en realidad sí puede ser falso
    • Si no hay un mal funcionamiento visible para el usuario, puede parecer que no existe problema y el desarrollo continúa
  • Después, alguien podría agregar código suponiendo que thing ya fue iniciado y que por eso puede llamar a baz sin preparación adicional
    fn processThing(thing: Thing) void {
       // this function must always be invoked on
       // a thing that has already been started
       assert(thing.is_started);
    
       // ...
    
       // Since thing is already started, we don't
       // need to foo the bar before bazzing the qux.
       // It would be really bad to baz the qux otherwise,
       // so we add an assert for good measure.
       assert(thing.is_fooed);
       thing.baz(qux);
    }
    
  • Aunque el segundo assert sea lógicamente correcto por sí mismo, si el primero en realidad puede ser falso, aparece el riesgo
    • En pruebas, como el primer assert no falla, el segundo tampoco falla
    • En producción, con assert desactivados, puede que nadie note el momento en que esa vulnerabilidad entra a la base de código
  • Si los assert dentro del código están engañando a los desarrolladores, escribir código correcto se vuelve injustificadamente difícil

Las opciones dependen de las prioridades del programa

  • Cada programa tiene prioridades distintas, y en algunos puede estar justificado priorizar el rendimiento sobre minimizar el riesgo de mal funcionamiento
    • En ese caso, convertir assert en oportunidad de optimización es una decisión natural
  • Desactivar assert en producción por inercia se considera una decisión peor que dejarlos activados, e incluso peor que explotar activamente las optimizaciones de rendimiento
    • Ser muy crítico con ReleaseFast pero aceptar sin cuestionamientos la desactivación de assert es una contradicción
  • Zine es un generador de sitios estáticos y hoy se usa principalmente para compilar blogs personales
    • No tiene un modelo de amenazas bien definido, y ese tampoco es su principal objetivo
    • Por preferir que corra un orden de magnitud más rápido que Hugo, distribuye builds ReleaseFast
  • Awebo es una alternativa a Discord autoalojable en etapa pre-alpha
    • Ya está claro que maneja información privada y será software expuesto a internet
    • Por eso, planea ofrecer builds ReleaseSafe al momento de distribución
    • Aun así, algunas dependencias clave como FFmpeg, Xiph Opus y SQLite se compilarán en ReleaseFast
    • En esas dependencias se considera claramente más importante la mejora de rendimiento que reducir aún más el riesgo de mal funcionamiento del programa

Decisiones en proyectos reales y casos de seguridad

Los assert implícitos de Zig no desaparecen por completo

  • Aunque puedas desactivar tus propios assert, no puedes desactivar los assert que el propio lenguaje Zig agrega implícitamente al código
    • Entre ellos están el overflow de enteros, la división por cero y el acceso fuera de rango en arreglos
    • Esas condiciones provocan panic en tiempo de ejecución o se usan con fines de optimización
  • La práctica de desactivar assert en producción puede hacer que los assert incorrectos se pudran y se multipliquen dentro de la base de código
    • Como resultado, puede crecer la paranoia frente al UIB, y los desarrolladores podrían terminar temiendo inconscientemente volver a activar los assert y enfrentarse al resultado
  • La conclusión inevitable no es tapar el problema desactivando assert, sino corregir los assert incorrectos
    • La corrección del programa debe buscarse en el conjunto completo, no solo en algún subconjunto

1 comentarios

 
GN⁺ 2 시간 전
Comentarios de Lobste.rs
  • Coincido en que, por lo general, lo mejor es que assert simplemente provoque un crash, o que solo haga caer la tarea, como el pánico de Rust. Pero me cuesta aceptar que usar assert como pista de optimización siempre sea mejor que simplemente quitarlo
    Primero, muchas veces un assert arbitrario no ayuda mucho a optimizar, y hay muchas condiciones que el optimizador no puede aprovechar de inmediato. A menos que introduzcas una suposición directa como “esta rama nunca se ejecuta”, es poco probable que obtengas grandes ganancias de rendimiento esparciendo supuestos aleatorios por todo el código
    Segundo, convertir assert en una suposición amplía muchísimo el radio de impacto de un error. Por ejemplo, en un sistema que procesa datos separados por proyecto o por usuario, imagina que hay un assert en medio de una función de cálculo para detectar un estado que en teoría nunca debería ocurrir. Si en un build de lanzamiento se desactiva por su costo, al simplemente inhabilitarlo el daño puede quedar limitado a un solo proyecto o usuario, e incluso otra verificación posterior podría detectarlo. En cambio, si eso se convierte en comportamiento indefinido, el cálculo puede saltar a código incorrecto, corromper memoria arbitrariamente y dañar los datos de todos los proyectos
    Al final, si eliges assert inseguros como valor predeterminado para los builds de lanzamiento, estás optimizando prematuramente puntos arbitrarios del código a cambio de reducir la posibilidad de aislar el daño cuando algo sale mal. Me parece que Rust está bien diseñado en esto: assert!() siempre hace pánico, debug_assert!() solo hace pánico en modo debug, y assert_unchecked() hace pánico en debug y se vuelve una pista de optimización en release

    • Si te preocupa el radio de impacto de los errores, deberías usar ReleaseSafe y no ReleaseFast
    • No me opongo a desactivar assert individuales, sino a desactivarlos masivamente como práctica general recomendada
      Es totalmente razonable concluir que el impacto en rendimiento es demasiado grande como para mantenerlos en builds de lanzamiento. Además, como decía antes, un assert costoso de evaluar casi nunca va a traducirse en una mejora de rendimiento
      En Zine también hay varios ejemplos de eso:
      https://github.com/kristoff-it/zine/…
      https://github.com/kristoff-it/zine/…
      En Zig no existe un “modo release predeterminado”. Siempre tienes que elegir tú mismo cómo tratar assert, y las opciones globales son crash u optimización; ninguna de las dos puede considerarse más predeterminada que la otra
  • Me resulta muy extraño que los dos CVE relativamente serios publicados hasta ahora en Ghostty hayan terminado en ejecución arbitraria de comandos sin corrupción de memoria. Que además se haya distribuido con ReleaseFast contradice de frente mi comprensión de cómo funciona el mundo

    • Yo no diría que sea tan extraño. Incluso si creemos el reporte de que el 70% de las vulnerabilidades graves están relacionadas con memoria, eso está basado en C y C++, y Zig podría ser un poco mejor en seguridad de memoria. Además, con una muestra de 2 casos, no sería raro que aproximadamente uno de cada diez proyectos arrojara un resultado así
      Como alguien que ha trabajado con emuladores de terminal, estas vulnerabilidades son exactamente el tipo de problema molesto que uno espera. No lo digo para menospreciar a desarrolladores o investigadores; este tipo de inyección de comandos en lugares insólitos casi viene de serie en este campo, igual que en otras áreas aparecen otras vulnerabilidades de inyección
  • Es curioso llevar casi 40 años escuchando el argumento de que en producción hay que desactivar assert y las verificaciones de límites “por rendimiento”. En ese tiempo las computadoras se han vuelto varios órdenes de magnitud más rápidas y el software se ha metido mucho más en la vida de todos, así que la corrección en tiempo de ejecución es más importante que nunca
    Para hablar de algo más productivo, en Microsoft antes había, además de los assert, check y similares habituales, unos asserts de reporte que no he visto mucho en otros lados. Se usaban cuando había una condición que no controlabas por completo, asumías que era cierta, pero aun así manejabas de forma defensiva el caso en que fuera falsa y querías saber mediante logs o telemetría remota si en la práctica llegaba a ser falsa. Por ejemplo, cuando asumes que un usuario no pondrá más de 1000 elementos en cierta lista y por eso usas un algoritmo cuadrático, o cuando das por hecho que la latencia de red será menor a 200 ms y por eso eliges un protocolo con muchos viajes de ida y vuelta

    • ¿Y en qué se diferencia eso de check?
  • Como una de las personas enlazadas aquí, diría que esto convierte mis ideas sobre assert en una falsa dicotomía ridícula y caricaturesca. Como ya escribí en otro comentario, prefiero decidir caso por caso si conviene convertir algo en comportamiento indefinido. Mi crítica a ReleaseFast es que agrupa esa decisión no solo para todos los assert de cierto alcance, sino también para todas las verificaciones de seguridad
    Coincido con kristoff en que es tonto desactivar assert sin corregirlos solo porque provocan crashes. Pero no estoy de acuerdo en que “crash o comportamiento indefinido” sean las únicas alternativas razonables. La postura de goldstein en el comentario hermano se parece más a lo que yo pienso

  • Es difícil defender que el comportamiento de assert_unchecked() se vuelva el valor predeterminado global, pero como técnica de optimización de rendimiento puede ser razonable. Si convertir todos los assert en suposiciones hace que el build de producción sea mucho más rápido, puede que haya unas pocas suposiciones, ojalá solo una, que generen casi toda la mejora, y podrían encontrarse con algo como una búsqueda binaria

    • No hay un valor predeterminado; la idea es que el usuario elija explícitamente entre ReleaseSafe y ReleaseFast/ReleaseSmall
  • En la literatura de análisis de programas existe una dualidad que divide las afirmaciones o assert dentro del código en dos formas. Una corresponde al contexto alrededor del código; en una función, serían las condiciones que debe satisfacer quien la llama. La otra corresponde al código mismo; en una función, serían las condiciones que la función debe satisfacer.
    Esta distinción se vuelve clara si se mira desde el concepto académico estándar de “culpa” (blame) en la literatura sobre contratos y tipos graduales. Si falla una afirmación sobre el contexto, no es culpa nuestra sino del contexto o del llamador, aunque también puede ocurrir que el llamador tenga razón y la afirmación misma sea el bug. Si falla una afirmación sobre el código mismo, la responsabilidad es nuestra, aunque también puede ocurrir que el código esté bien y la afirmación misma sea el bug.
    A nivel de función, una precondición es una afirmación sobre el contexto y una postcondición es una afirmación sobre el código. Pero ambas también pueden insertarse en medio del código. Algunos frameworks de verificación usan assert para afirmaciones sobre el código y assume para afirmaciones sobre el contexto. Esto también se relaciona con cómo lo interpretan algunos frameworks de testing, especialmente los de pruebas aleatorias. Si assert falla, se marca como fallo de prueba; si assume falla, se omite la prueba.

    • BIND9 sigue un estilo cercano al diseño por contrato, donde el macro REQUIRE() verifica las precondiciones que debe satisfacer el llamador y ENSURE() verifica las postcondiciones que garantiza la función. También existen INSIST() para verificaciones intermedias e INVARIANT() para bucles o estructuras de datos. La documentación de las funciones debería incluir notas de “requires” y “ensures” que correspondan a las precondiciones y postcondiciones.
  • Esto parece insinuar a Bun, así que quisiera dejar la conexión un poco más formal. Existe un issue de Zig de 2024 en el que Jarred Sumner, creador de Bun, propuso que unreachable hiciera panic en ReleaseFast. Los comentarios de Andrew Kelley y Matthew Lugg en ese hilo son relevantes para esta discusión.
    => https://github.com/ziglang/zig/issues/19664
    Bun usa sus propias funciones assert, y en modo release hacen panic o se eliminan, pero no introducen comportamiento indefinido. Aun así, también hay que recordar la nota al pie de Loris: “como lenguaje, Zig agrega implícitamente muchos assert que no se pueden desactivar al código”.
    No quiero extenderme demasiado con Bun, porque es un proyecto único de un equipo pequeño. El punto clave es que, si existe la más mínima preocupación, hay que usar ReleaseSafe. ReleaseSafe tiene fama de ser lento, pero en mis proyectos pequeños en Zig no he podido medir una diferencia de benchmark entre ReleaseSafe y ReleaseFast. Aun así, probablemente siga siendo más rápido que muchos otros lenguajes.

    • Es correcto decir que, si existe la más mínima preocupación, hay que usar ReleaseSafe. También son posibles estrategias más interesantes. Mientras se modifica el código, es decir, cuando existe la posibilidad de introducir bugs, puede mantenerse en ReleaseSafe; después, cuando el código se estabilice y ya haya sido validado en la práctica, si la mejora de rendimiento vale la pena, se puede cambiar a ReleaseFast.
      O, si tiene sentido en ese contexto, podrías distribuir un ejecutable en ReleaseFast y, si empiezan a llegar reportes de bugs no deterministas por comportamiento indefinido, volver a ReleaseSafe. Entonces podrás reunir reportes de bugs accionables sobre qué assert falló, incluyendo también accesos fuera de rango u overflows, y corregir el código. Incluso si al principio decidiste distribuir en un contexto donde nunca debiste usar ReleaseFast, igual recomendaría este enfoque :^)
      También se puede aplicar la misma estrategia solo a partes del proyecto ajustando dependencias y usando @setRuntimeSafety. Al final, si realmente quieres hacerlo con inteligencia, ya tienes todas las herramientas necesarias.
  • No debería escribirse como si estuviera bien poner expresiones con efectos secundarios dentro de una llamada a assert. Es una mala práctica. También conviene evitar usar assert para verificar errores. Siendo justos, no parece que el autor esté defendiendo eso.
    Por el contrario, también se explica que si assert depende de un cálculo complejo, ese cálculo no necesariamente se elimina en modo unchecked, así que conviene protegerlo con comptime if.
    Espero que el autor no haya pasado por alto la ironía de decir que es “una buena oportunidad para dejar atrás el trauma que dejaron los macros y abrazar la simplicidad”. Lo que en realidad se estaría proponiendo abrazar es “la simplicidad” de tener que considerar el modo de compilación del programa y esparcir comptime if defensivos por todas partes.

    • ¿Por qué es una mala práctica?
  • He escrito algo de código de cálculo numérico en C# y uso muchos assert que se desactivan en release. Son demasiado costosos para ejecutarlos en cada loop apretado, pero en pruebas unitarias es útil que revienten apenas la rutina vea por primera vez una entrada NaN.
    A menudo esos NaN no vienen de la entrada del usuario, sino de bugs en el código, como cuando el optimizador se va por caminos donde no debería, y entonces hacen falta mejores restricciones de borde. Claro, puede ser que la entrada del usuario necesite saneamiento, pero eso debe hacerse en el límite más externo, no en lo profundo del algoritmo. Sería bueno contar con un sistema de pruebas que permitiera demostrar los invariantes internos del algoritmo sin assert como resultado del saneamiento de la entrada del usuario, pero esto es un proyecto paralelo y si explota nadie va a morir.

  • El 90% de los desacuerdos sobre assert existen porque la definición de esa palabra es pobre y múltiple, y eso enturbia tanto el pensamiento como la comunicación. Por eso hay que separar el concepto en tres nombres y usarlos con rigor.
    assert(bool) o, en Rust, assert_unchecked(), es algo que el programador cree que siempre es verdadero, y que el compilador también asume como siempre verdadero para usarlo en optimizaciones. Para evitar la asociación con el assert verificable de los lenguajes antiguos, quizás sería mejor llamarlo assume().
    check(bool) hace panic si la condición es falsa y continúa si es verdadera, y siempre funciona así.
    debug_check(bool) es igual que check() en modo debug, y en modo release siempre continúa. En la práctica, se controla con un flag --debug_checks activado por defecto en modo debug.
    Además, también hace falta un flag del compilador --check_asserts que convierta assert() en check(). Sirve cuando quieres verificar tus propios assert porque te generan dudas, y en modo debug estaría activado por defecto. Si al decir “assert” no se deja absolutamente claro a qué se refiere uno, es imposible tener una discusión madura y solo se termina desperdiciando palabras.