Il faut corriger vos `assert`
(kristoff.it)- Un assert sert à exprimer dans le code des préconditions, postconditions et invariants, et lorsqu’une contrainte peut être imposée par le système de types, il est préférable de l’exprimer via les fonctionnalités du langage
- Dans Zig,
std.debug.assertn’est pas une macro mais une fonction ordinaire, qui s’appuie surunreachablepour signaler un chemin impossible à atteindre et peut aussi servir à l’optimisation - En Debug et ReleaseSafe, un assert qui échoue provoque un panic et fait planter le programme, mais en ReleaseFast et ReleaseSmall, cela devient un comportement illégal non vérifié pouvant entraîner un fonctionnement erroné
- Désactiver les asserts en production fait perdre l’occasion de découvrir rapidement des hypothèses erronées, puis le code peut finir par dépendre d’asserts faux, jusqu’à créer des vulnérabilités
- Le choix entre ReleaseSafe et ReleaseFast dépend des priorités du programme, mais l’essentiel est de ne pas masquer le problème en désactivant les asserts : il faut corriger les asserts erronés
Rôle des asserts et comportement par défaut de Zig
- Un assert permet d’exprimer dans le code qu’une condition doit toujours être vraie, comme « cet argument ne peut pas être null » ou « cet entier ne peut pas être pair »
- Exemples :
assert(my_arg != null);,assert(my_num % 2 != 0); - Si le système de types peut imposer la contrainte, mieux vaut utiliser une fonctionnalité du langage plutôt qu’un assert
- En Zig, un pointeur ordinaire
*Foone peut pas être null, tandis qu’un pointeur optionnel?*Foopeut l’être, mais oblige à faire une vérification avant d’accéder à la valeur
- Exemples :
- Les asserts conviennent bien pour expliciter des préconditions, postconditions et invariants
- Un bon assert peut être plus puissant que des tests unitaires pour attraper des erreurs de programmation
- Leur efficacité peut encore augmenter lorsqu’ils sont utilisés avec du fuzzing
unreachable et les asserts dans Zig
- Dans Zig, les asserts reposent sur
unreachable, une fonctionnalité du langage qui indique qu’un chemin de code est invalide- Dans un
switch, on peut marquer une branche inatteignable comme.a => unreachable unreachablepeut être utilisé comme instruction, mais aussi là où une expression de n’importe quel type est requise- Il n’est pas nécessaire de fabriquer artificiellement une valeur temporaire pour le cas impossible
- Dans un
- Dans la bibliothèque standard de Zig,
std.debug.assertest implémenté ainsipub fn assert(ok: bool) void { if (!ok) unreachable; // assertion failure } - L’information portée par
unreachablepeut être exploitée pour l’optimisation- Le compilateur peut supprimer les chemins inatteignables, et cette information peut se propager pour permettre des optimisations non locales
- Tous les asserts n’améliorent pas les performances, mais certains peuvent déboucher sur des optimisations difficiles à anticiper pour le programmeur
Modes de build et sûreté à l’exécution
- Zig propose les modes de build Debug, ReleaseSafe, ReleaseFast et ReleaseSmall
- Ce réglage n’a pas nécessairement à s’appliquer globalement à tout le programme
- Chaque dépendance peut être compilée dans un mode différent, et
@setRuntimeSafetypermet aussi d’ajuster la sûreté à l’exécution à l’échelle d’un bloc dans une fonction
- L’échec d’un assert est considéré comme un « illegal behavior » dans Zig
- Dans les modes vérifiés, à savoir Debug, ReleaseSafe et
@setRuntimeSafety(true), le programme panique et s’arrête - Dans les modes non vérifiés, à savoir ReleaseFast, ReleaseSmall et
@setRuntimeSafety(false), cela devient un « unchecked illegal behavior » qui fait fonctionner le programme de travers
- Dans les modes vérifiés, à savoir Debug, ReleaseSafe et
- Les conséquences d’un unchecked illegal behavior ne sont pas garanties
- Dans l’exemple avec
switch, les caractéristiques du code machine généré peuvent actuellement donner l’impression que l’exécution tombe dans une autre branche - Avec une autre version du compilateur, le comportement erroné pourrait être totalement différent
- On peut voir ce comportement dans cet exemple godbolt
- Dans l’exemple avec
- La différence de comportement entre assert et
switchen ReleaseSafe et en ReleaseFast peut être observée dans un autre exemple godbolt- En ReleaseFast, on voit apparaître une forme où la fonction saute toutes les comparaisons et retourne
true - C’est le type d’optimisation dont dépendent fortement les jeux vidéo et d’autres applications multimédia temps réel
- En ReleaseFast, on voit apparaître une forme où la fonction saute toutes les comparaisons et retourne
L’assert de Zig n’est pas une macro
std.debug.assertdans Zig est une fonction ordinaire, pas une macro- Zig n’a pas de macros
- C’est souvent un point surprenant pour les développeurs C/C++ qui découvrent Zig
- En C/C++, lorsqu’on désactive les asserts, il est courant que l’appel à assert entier, ainsi que l’expression passée en argument, se comporte comme s’il avait été commenté
- C’est pourquoi il ne faut pas mettre d’expressions avec effets de bord dans un assert en C/C++
- En cas de désactivation, l’opération elle-même peut disparaître
- En Zig, selon les règles d’appel de fonction, les arguments sont évalués avant l’appel
- L’expression d’argument est donc évaluée indépendamment de la logique interne de
std.debug.assert - On peut ainsi mettre dans un assert une expression qui a des effets de bord, par exemple
// assert that the remove operation is not a noop: assert(my_map.remove("expected-to-exist")); - L’expression d’argument est donc évaluée indépendamment de la logique interne de
- En revanche, si calculer la condition de l’assert demande des opérations complexes, ce calcul n’est pas forcément éliminé en mode non vérifié
- Dans ce cas, il faut protéger le code avec
comptime if
const builtin = @import("builtin"); if (builtin.mode == .Debug) { var condition = ...; // whatever bookkeeping is necessary // to compute the condition assert(condition == .ok); } - Dans ce cas, il faut protéger le code avec
- Cela peut sembler inhabituel si l’on est habitué à la sémantique C/C++, mais Zig part du principe que les asserts ne sont généralement pas désactivés
Le problème de désactiver les asserts en production
- Il existe grosso modo trois façons d’utiliser les asserts
- Les conserver comme vérifications à l’exécution et faire planter le processus avec un panic en cas d’échec
- Les utiliser comme outil d’optimisation des performances, en acceptant que le programme se comporte mal si l’assert est faux
- Les désactiver complètement
std.debug.assertne prend pas en charge nativement la désactivation totale des asserts- On peut obtenir un comportement plus proche de C/C++ en implémentant son propre assert qui consulte un drapeau au moment du build
- Si l’on a envie de désactiver les asserts, c’est généralement à cause de la combinaison de deux facteurs
- On ne veut pas garder des vérifications à l’exécution à cause de leur coût en performances ou du risque de crash de l’application
- On a du mal à croire que les asserts sont toujours corrects, et l’on craint les dysfonctionnements qu’ils pourraient causer s’ils sont exploités pour l’optimisation
- Comme l’a rappelé matklad dans une discussion liée, il existe des situations où éviter un crash est justifié pour des raisons d’ingénierie parfaitement valables
- Mais, pour les logiciels ordinaires, prendre l’évitement des crashs comme choix par défaut est jugé mauvais
- Désactiver les asserts permet au programme de continuer à tourner même lorsqu’une condition censée être impossible se produit réellement
- Le programme continue alors sous une hypothèse fausse, ce qui constitue déjà une forme de dysfonctionnement, même sans unchecked illegal behavior
- Si l’unchecked illegal behavior, ou l’undefined behavior en C, est dangereux, c’est notamment parce qu’il peut transformer un programme en weird machine
- Dans un logiciel suffisamment complexe, le programme peut se tordre de manière imprévue même sans UIB
- Le fait qu’un assert devienne faux à l’exécution constitue déjà une sortie hors spécification, et cela peut à lui seul conduire à des actions non intentionnelles
- L’injection SQL est un exemple concret et répandu de dysfonctionnement de type weird machine sans UIB
- Si le coût d’un dysfonctionnement est trop élevé, il vaut mieux laisser les asserts activés
- Si les performances sont extrêmement importantes et que l’on peut accepter le risque d’un comportement erroné, il est alors logique d’utiliser les asserts comme opportunités d’optimisation
- Désactiver les asserts risque de faire perdre les gains de performance tout en donnant une fausse impression de sûreté
Comment des asserts erronés trompent toute une base de code
- Le risque central est qu’un assert faux puisse ne jamais apparaître dans les tests et n’échouer qu’en production
- Si l’on pouvait garantir que tous les asserts sont toujours vrais, leur utilisation pour l’optimisation ne ferait pas débat
- Si l’on pouvait garantir que les tests attrapent tous les asserts erronés, les optimisations en production seraient elles aussi sûres
- En pratique, on peut écrire de mauvais asserts, et les tests ne les détectent pas forcément
- Désactiver les asserts en production fait perdre la meilleure occasion de détecter le plus tôt possible des asserts erronés
- Plus grave encore : le code ultérieur continue d’être écrit en s’appuyant sur ces asserts erronés
- Dans l’exemple,
processThingest censée n’être appelée que sur unthingdéjà démarréfn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... } - On peut ne pas voir que cet assert, jamais pris en défaut pendant les tests, est en réalité désactivé en production et peut donc être faux
- S’il n’y a pas de dysfonctionnement visible côté utilisateur, tout peut sembler normal et le développement continue
- Plus tard, quelqu’un peut ajouter du code en partant du principe que
thinga déjà été démarré et qu’il est donc possible d’appelerbazsans préparation supplémentairefn 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); } - Même si le second assert est logiquement correct en lui-même, le danger apparaît si le premier assert peut en réalité être faux
- Dans les tests, comme le premier assert n’échoue pas, le second non plus
- En production, avec des asserts désactivés, on peut ne pas remarquer le moment où une vulnérabilité entre dans la base de code
- Quand les asserts du code se mettent à tromper les développeurs, écrire du code correct devient déraisonnablement difficile
Le choix dépend des priorités du programme
- Chaque programme a ses propres priorités, et pour certains il peut être légitime de privilégier les performances plutôt que la réduction maximale du risque de dysfonctionnement
- Dans ce cas, transformer les asserts en opportunités d’optimisation est un choix naturel
- Désactiver machinalement les asserts en production est présenté comme un choix inférieur, aussi bien au fait de les laisser activés qu’à l’exploitation assumée des optimisations de performance
- Zine est un générateur de sites statiques, aujourd’hui surtout utilisé pour compiler des blogs personnels
- Son modèle de menace n’est pas défini et ce n’est pas sa priorité principale
- Il distribue des builds ReleaseFast, en privilégiant le fait d’être un ordre de grandeur plus rapide que Hugo
- Awebo est une alternative à Discord auto-hébergeable encore en phase pre-alpha
- Le fait qu’il traite des données personnelles et soit exposé à Internet est déjà clair
- Il est prévu de fournir des builds ReleaseSafe au moment du déploiement
- En revanche, certaines dépendances essentielles comme FFmpeg, Xiph Opus et SQLite seront compilées en ReleaseFast
- Pour ces dépendances, le gain de performance est jugé nettement plus important qu’une réduction supplémentaire du risque de dysfonctionnement
Choix de vrais projets et exemples de sécurité
- TigerBeetle est une base de données financière qui laisse toujours les asserts activés
- Ghostty est un émulateur de terminal qui distribue des builds ReleaseFast pour macOS
- Il recommande aussi cette approche aux consommateurs downstream, par exemple aux mainteneurs de distributions Linux
- Deux CVE publiques relativement sérieuses concernant Ghostty étaient toutes deux des cas d’exécution de commandes arbitraires sans corruption mémoire
- La corruption mémoire ou l’UIB ne résument pas à elles seules tout le risque
Les asserts implicites de Zig ne disparaissent jamais totalement
- Même si l’on peut désactiver ses propres asserts, il est impossible de désactiver les asserts que le langage Zig ajoute implicitement au code
- Cela concerne par exemple le dépassement d’entier, la division par zéro ou les accès hors limites d’un tableau
- Ces conditions déclenchent soit un panic à l’exécution, soit sont exploitées à des fins d’optimisation
- La pratique consistant à désactiver les asserts en production peut laisser des asserts erronés se dégrader et se multiplier dans la base de code
- Cela peut renforcer une paranoïa vis-à-vis de l’UIB, au point que les développeurs finissent par craindre inconsciemment de réactiver les asserts et d’en voir les conséquences
- La conclusion inévitable est qu’il ne faut pas masquer le problème en désactivant les asserts : il faut corriger les asserts erronés
- La correction du programme doit être recherchée dans son ensemble, pas seulement sur un sous-ensemble
1 commentaires
Avis sur Lobste.rs
Je suis d’accord sur le fait que, pour
assert, le mieux est généralement soit de simplement provoquer un crash, soit de ne faire planter que la tâche en cours, comme avec une panic en Rust. En revanche, j’ai du mal à accepter que transformerasserten indice d’optimisation soit toujours préférable à le supprimer purement et simplementPremièrement, un
assertarbitraire aide souvent peu à l’optimisation, et de nombreuses conditions ne peuvent pas être exploitées directement par l’optimiseur. À moins d’introduire une hypothèse explicite du type « cette branche ne sera jamais prise », les gains de performance obtenus en disséminant des hypothèses aléatoires dans le code ont de fortes chances d’être modestesDeuxièmement, remplacer
assertpar une hypothèse élargit énormément le rayon d’impact d’une erreur. Par exemple, dans un système qui traite des données séparées par projet ou par utilisateur, imaginons unassertau milieu d’une fonction de calcul qui détecte un état censé être impossible. Si, dans un build de release, on le désactive parce qu’il coûte trop cher, un simple retrait peut limiter l’impact à un projet ou à un utilisateur, et le problème peut encore être détecté plus tard par d’autres vérifications. En revanche, si on en fait un cas de comportement indéfini, le calcul peut sauter vers un code aberrant, corrompre la mémoire de façon arbitraire et endommager les données de tous les projetsAu final, choisir par défaut des
assertnon sûrs en build de release revient à optimiser prématurément des points arbitraires du code, au prix d’une moindre capacité à contenir les dégâts lorsqu’un problème survient. Je trouve que Rust est bien conçu sur ce point :assert!()panic toujours,debug_assert!()ne panic qu’en mode debug, etassert_unchecked()panic en debug et devient un indice d’optimisation en releaseReleaseSafeplutôt queReleaseFastassertindividuellement, mais à leur désactivation globale comme recommandation généraleIl est tout à fait raisonnable de juger qu’un
asserta un impact trop fort sur les performances pour être conservé en build de release. De plus, comme indiqué plus haut, unassertcoûteux en calcul a très peu de chances d’apporter un gain de performanceIl y a quelques exemples de ce type dans Zine :
https://github.com/kristoff-it/zine/…
https://github.com/kristoff-it/zine/…
Zig n’a pas de « mode release par défaut ». Il faut toujours choisir explicitement comment traiter les
assert, et les options globales sont le crash ou l’optimisation ; aucune des deux ne peut vraiment être considérée comme plus par défaut que l’autreLe fait que les deux CVE relativement graves publiées jusqu’ici dans Ghostty aient toutes deux conduit à une exécution de commandes arbitraires sans corruption mémoire me paraît très étrange. Que cela se produise alors même que le logiciel était distribué en
ReleaseFastcontredit frontalement ma compréhension du fonctionnement du mondePour avoir travaillé sur des émulateurs de terminal, ces vulnérabilités me paraissent être exactement le type de problèmes pénibles mais prévisibles qu’on rencontre. Ce n’est pas pour rabaisser les développeurs ou les chercheurs : ce genre d’injection de commandes dans un endroit inattendu est pratiquement un classique du domaine, un peu comme d’autres familles de vulnérabilités par injection dans d’autres secteurs
C’est amusant d’entendre depuis presque 40 ans qu’il faudrait désactiver
assertet les contrôles de bornes en production « pour des raisons de performance ». Pendant ce temps, les ordinateurs sont devenus plus rapides de plusieurs ordres de grandeur et les logiciels se sont bien plus profondément intégrés dans la vie de tout le monde ; l’exactitude à l’exécution est donc plus importante que jamaisPour rendre la discussion plus productive, chez Microsoft autrefois, en plus des
assert,checket autres mécanismes classiques, il existait une forme d’assert de reporting que j’ai rarement vue ailleurs. On l’utilisait lorsqu’une condition n’était pas entièrement sous notre contrôle, qu’on supposait vraie, qu’on gérait quand même de manière défensive le cas où elle serait fausse, mais qu’on voulait savoir via les logs ou la télémétrie si elle se produisait réellement en production. Par exemple, on peut supposer qu’un utilisateur ne mettra jamais plus de 1000 éléments dans une liste et utiliser donc un algorithme quadratique, ou supposer qu’une latence réseau restera sous 200 ms et choisir un protocole avec beaucoup d’allers-retourscheck?En tant que l’une des personnes mentionnées ici, je trouve que cela transforme ma position sur
asserten une fausse alternative ridicule et caricaturale. Comme je l’ai aussi écrit dans un autre commentaire, je préfère décider au cas par cas, pour chaqueassert, s’il faut ou non le convertir en comportement indéfini. Ma critique deReleaseFastest qu’il lie ce choix non seulement à tous lesassertd’une certaine portée, mais aussi à toutes les vérifications de sûretéJe suis d’accord avec kristoff quand il dit qu’il est absurde de désactiver des
assertsimplement parce qu’ils provoqueraient un crash s’ils ne sont pas corrigés. En revanche, je ne suis pas d’accord avec l’idée que les seules alternatives raisonnables soient « crash » ou « comportement indéfini ». La position de goldstein dans le commentaire voisin est, à mon avis, plus proche de la mienneIl est difficile de défendre l’idée de faire du comportement de
assert_unchecked()la valeur par défaut globale, mais cela peut être raisonnable comme technique d’optimisation des performances. Si convertir tous lesasserten hypothèses accélère fortement un build de production, il est possible qu’une petite poignée d’hypothèses, voire idéalement une seule, soit responsable de l’essentiel du gain, et qu’on puisse la trouver par une méthode comme la recherche dichotomiqueReleaseSafeetReleaseFast/ReleaseSmallDans la littérature sur l’analyse de programmes, il existe une dualité qui sépare les assertions dans le code, ou
assert, en deux formes. L’une concerne le contexte autour du code — pour une fonction, les conditions que l’appelant doit satisfaire — et l’autre concerne le code lui-même — pour une fonction, les conditions que la fonction doit satisfaire.Cette distinction devient claire si on l’examine à travers la notion académique standard de « responsabilité » (blame) dans la littérature sur les contrats et les types graduels. Si une assertion sur le contexte échoue, ce n’est pas notre faute : la responsabilité incombe au contexte ou à l’appelant ; mais il est aussi possible que l’appelant ait raison et que l’assertion elle-même soit boguée. Si une assertion sur le code lui-même échoue, c’est notre responsabilité ; mais là encore, il est possible que le code ait raison et que l’assertion elle-même soit boguée.
Au niveau des fonctions, une précondition est une assertion sur le contexte, et une postcondition est une assertion sur le code lui-même. Cela dit, on peut placer les deux au milieu du code également. Certains frameworks de vérification utilisent
assertpour les assertions sur le code, etassumepour les assertions sur le contexte. Cela rejoint aussi la manière dont certains frameworks de test, en particulier les frameworks de test aléatoire, interprètent ces notions. Siassertéchoue, le test est marqué comme un échec ; siassumeéchoue, le test est ignoré.Cela semble faire allusion à Bun, donc j’aimerais rendre le lien un peu plus explicite. Il existe un ticket Zig de 2024 où Jarred Sumner, le créateur de Bun, a proposé que
unreachabledéclenche un panic en ReleaseFast. Les commentaires d’Andrew Kelley et de Matthew Lugg dans ce fil sont liés à cette discussion.=> https://github.com/ziglang/zig/issues/19664
Bun utilise ses propres fonctions
assert, qui paniquent ou sont supprimées en mode release, mais n’introduisent pas de comportement indéfini. Cela dit, il faut aussi garder en tête la note de bas de page de Loris : « en tant que langage, Zig ajoute implicitement au code de nombreuses assertions impossibles à désactiver ».Je ne veux pas trop m’étendre sur Bun, car il s’agit d’un projet unique porté par une petite équipe. L’idée essentielle, c’est que s’il y a le moindre doute, il faut utiliser ReleaseSafe. ReleaseSafe a la réputation d’être lent, mais sur mes petits projets Zig, je n’ai pas réussi à mesurer de différence en benchmark entre ReleaseSafe et ReleaseFast. Il y a malgré tout de fortes chances que ce soit encore plus rapide que beaucoup d’autres langages.
Ou bien, si cela a du sens dans le contexte, on peut distribuer un exécutable ReleaseFast, puis revenir à ReleaseSafe quand des rapports de bugs non déterministes commencent à arriver à cause d’un comportement indéfini. Cela permet alors de recueillir des rapports de bugs exploitables indiquant quelle
asserta échoué, ainsi que des accès hors limites, des débordements, etc., puis de corriger le code. J’irais jusqu’à recommander cette approche même si la décision initiale de livrer en ReleaseFast a été prise dans un contexte où il ne fallait pas le faire :^)On peut aussi faire la même chose sur certaines parties du projet seulement, en ajustant les dépendances et en utilisant
@setRuntimeSafety. Au fond, tous les outils nécessaires sont là, à condition d’avoir la volonté de s’en servir intelligemment.Il ne faut pas écrire comme si on pouvait mettre des expressions avec effets de bord dans un appel à
assert. C’est une mauvaise pratique. Il faut aussi éviter d’utiliserassertpour vérifier des erreurs. Pour être juste, je n’ai pas l’impression que l’auteur défende cela.À l’inverse, il est aussi expliqué que si
assertdépend d’un calcul complexe, ce calcul n’est pas forcément éliminé en mode unchecked ; il faut donc le protéger aveccomptime if.J’espère que l’auteur n’a pas raté l’ironie de la formule « une bonne occasion de se débarrasser du traumatisme laissé par les macros et d’embrasser la simplicité ». Cela revient à inviter à adopter « la simplicité » qui consiste à tenir compte du mode de build du programme et à parsemer partout des
comptime ifdéfensifs.J’écris un peu de code de calcul numérique en C#, et j’utilise beaucoup d’
assertqui sont désactivés en release. C’est trop coûteux pour être exécuté dans chaque boucle serrée, mais dans les tests unitaires, il est utile que l’exécution s’arrête immédiatement dès qu’une routine rencontre pour la première fois une entrée NaN.Ces NaN viennent souvent moins d’une entrée utilisateur que de bugs dans le code — par exemple quand l’optimiseur passe là où il ne devrait pas — et montrent qu’il faut de meilleures contraintes de frontière. Bien sûr, l’entrée utilisateur peut aussi nécessiter une validation, mais cela doit se faire à la frontière la plus externe, pas au plus profond de l’algorithme. Ce serait bien d’avoir un système de preuve permettant de démontrer, à partir de la validation des entrées utilisateur, les invariants internes de l’algorithme sans
assert, mais c’est un projet annexe et personne ne mourra s’il plante.90 % des désaccords sur
assertviennent du fait que la définition du mot est faible et multiple, ce qui brouille la réflexion et la communication. Il faut donc séparer le concept en trois noms distincts et les employer rigoureusement.assert(bool)— ou en Rust,assert_unchecked()— désigne quelque chose que le programmeur croit toujours vrai, et que le compilateur suppose également toujours vrai pour l’utiliser dans ses optimisations. Pour éviter l’association avec les anciens assert de vérification dans les vieux langages, il vaudrait peut-être mieux appeler celaassume().check(bool)panique si la condition est fausse et continue si elle est vraie ; c’est toujours son comportement.debug_check(bool)se comporte commecheck()en mode debug, et continue toujours en mode release. En pratique, ce comportement est contrôlé par un flag--debug_checks, activé par défaut en mode debug.Il faudrait aussi un flag compilateur
--check_assertsqui transformeassert()encheck(). On l’utiliserait lorsqu’on soupçonne ses propresassertet qu’on veut les vérifier, et il serait activé par défaut en mode debug. Tant qu’on n’est pas extrêmement clair sur ce qu’on entend par « assert », une discussion mature est impossible et on ne fait que gaspiller des mots.