coq-of-rust, qui transpose les programmes Rust en Coq, commence à couvrir les crates core et alloc de la bibliothèque standard, ce qui réduit la charge liée à l’écriture manuelle de définitions Coq pour chaque fonction primitive- Ces deux crates sont de vastes bases de code contenant beaucoup de code unsafe et de Rust avancé ; l’enjeu central est donc de traiter le résultat de la traduction automatique comme des unités compilables et vérifiables
- En découpant la sortie par fichier Rust d’entrée,
allocreprésente 54 fichiers et 171 783 lignes, tandis quecorereprésente 190 fichiers et 592 065 lignes, ce qui facilite la compilation parallèle et le débogage - Les collisions de noms de modules dans les blocs
implont été atténuées en incluant les informations des clauseswhere, mais 4 % des fichiers ne compilent toujours pas actuellement en Coq - L’exemple
Option::unwrap_or_defaultillustre une méthode consistant à prouver l’équivalence entre la définition traduite automatiquement et une définition fonctionnelle plus simple, ce qui nécessite à la fois de faire confiance à l’automatisation et de vérifier au moment de la preuve
Traitement des primitives de la bibliothèque standard Rust
- Formal Land développe
coq-of-rust, qui traduit les programmes Rust vers le système de preuves formelles Coq - Jusqu’ici, pour traiter les composants primitifs de la bibliothèque standard Rust, il fallait créer séparément une définition Coq décrivant le comportement de chaque fonction
- Exemple :
Option::unwrap_or_default - Les définitions manuelles sont répétitives et propices aux erreurs
- Exemple :
- Pour réduire cette charge, les crates
coreetallocde Rust ont été traduites aveccoq-of-rust - Le résultat de la traduction est consultable aux emplacements suivants
Résultats de la première exécution de la traduction
- Lors de la première exécution de
coq-of-rustsurallocetcore, deux fichiers Coq de plusieurs centaines de milliers de lignes ont été générés, chacun correspondant à l’intégralité d’une crate - Cela a confirmé que l’outil pouvait s’exécuter sur de grandes bases de code, mais le code Coq généré ne compilait pas immédiatement
- Les erreurs étaient rares, mais apparaissaient environ une fois toutes les quelques milliers de lignes
- Selon
cloc, la taille du code Rust d’entrée était la suivantealloc: 26 299 lignes de code Rustcore: 54 192 lignes de code Rust
- Comme une expansion des macros a lieu pendant la traduction, la cible réellement traduite est plus grande que le nombre de lignes d’origine
Découpage du code Coq généré
- Le principal changement a consisté à découper la sortie de
coq-of-rusten un fichier Coq par fichier Rust d’entrée - Ce découpage a été possible parce que la traduction est insensible à l’ordre des définitions et context-free
- Les fichiers Rust ont généralement des dépendances cycliques entre eux, ce que Coq n’autorise pas, mais cette approche de traduction permet une séparation par fichier
- Après découpage, la taille de la sortie est la suivante
alloc: 54 fichiers Coq, 171 783 lignes de code Coqcore: 190 fichiers Coq, 592 065 lignes de code Coq
- Le découpage en fichiers facilite l’exploration et la maintenance du code généré
- Il devient plus simple de paralléliser la compilation
- On peut déboguer en se concentrant sur un fichier à la fois
- Il est plus facile d’exclure les fichiers qui ne compilent pas
- Il devient plus simple de suivre le diff d’un fichier unique
Correction des collisions de noms de modules et fichiers restants
- Certains bugs provenaient de collisions de noms de modules dans les blocs
impl - La solution a consisté à augmenter l’unicité des noms de modules en y ajoutant davantage d’informations
- Les informations des clauses
where, auparavant absentes, sont désormais incluses - Par exemple, dans l’implémentation du trait
DefaultpourMapping<K, V>, la condition selon laquelleKetVdoivent tous deux implémenterDefaultest reflétée dans le nom du module
- Les informations des clauses
- Les fichiers qui ne compilent pas actuellement en Coq sont les suivants
alloc/boxed.vcore/any.vcore/array/mod.vcore/cmp/bytewise.vcore/error.vcore/escape.vcore/iter/adapters/flatten.vcore/net/ip_addr.v
- Cela correspond à 4 % de l’ensemble des fichiers
- Même dans les fichiers qui compilent, certains composants Rust non encore pris en charge sont axiomatisés ; ce seul pourcentage ne suffit donc pas à évaluer toute l’étendue des éléments non pris en charge
Exemple de traduction de Option::unwrap_or_default
- En Rust,
Option::unwrap_or_defaultrenvoiexsi la valeur estSome(x), et appelleT::default()si elle estNone coq-of-rusttraduit cela en une définition Coq de forme monadique- Elle fait correspondre les arguments d’entrée et les types
- Dans la branche
Some, elle récupère le champ du tuple et le copie - Dans la branche
None, elle appelle la méthodedefaultdu traitcore::default::Default
- Dans la vérification réelle, une définition fonctionnelle plus simple est utilisée à la place de la définition générée automatiquement
- Si la valeur est
None, elle renvoiecore.simulations.default.Default.default - Si la valeur est
Some x, elle renvoiex
- Si la valeur est
- La preuve d’équivalence entre la définition générée automatiquement et la définition simple se trouve dans
CoqOfRust/core/proofs/option.v - Si le code Rust d’origine change, cette preuve permet de détecter le changement
- Comme la traduction de la bibliothèque
coreest automatique, la définition générée peut être jugée plus fiable qu’une définition écrite à la main - Toutefois,
coq-of-rustpeut lui aussi comporter des erreurs ou des incomplétudes ; il faut donc vérifier au moment de la preuve que le code est valide
Travaux restants
- La confiance dans la formalisation de la bibliothèque standard pour la vérification de programmes Rust s’en trouve renforcée
- Le prochain objectif reste la simplification du processus de preuve, encore fastidieux
- En particulier, pour montrer qu’une simulation est équivalente au code Rust d’origine, les tâches suivantes sont nécessaires
- Résolution des noms
- Introduction de types de haut niveau
- Élimination des effets de bord
- Traiter ces étapes séparément constitue la direction des améliorations à venir
1 commentaires
Avis de Hacker News
Vraiment impressionnant.
Ce genre de traduction automatique déplace l’objet de la confiance vers l’outil. coq-of-rust lui-même est écrit en Rust, pas en Coq, donc la structure récursive est assez étonnante ; mais en combinant une approche à la CompCert avec une manière de contourner l’attaque Trusting Trust de Ken Thompson au moyen d’un second compilateur, comme dans « Countering Trusting Trust through Diverse Double-Compiling » (2009) de David A. Wheeler [0], une preuve de correction semble possible.
Pour le vérifier, il suffirait de convertir le traducteur coq-of-rust de Rust vers Coq avec coq-of-rust ; même si cette traduction a été effectuée en Rust et qu’on ne lui fait donc pas confiance, il reste à prouver dans Coq les propriétés de correction voulues, en particulier que la traduction d’un programme Rust vers Coq préserve la sémantique.
Comme dans l’article, il sera probablement plus facile de prouver les choses sur des définitions plus fonctionnelles que les définitions générées ; il suffira donc de passer par une preuve d’équivalence entre définitions, comme on le fait dans la bibliothèque standard. Si le traducteur coq-of-rust actuel, en particulier lib/ [1], fait 6 350 lignes de Rust, écrire tout le traducteur en Coq et prouver son équivalence avec la version générée semble aussi réaliste.
Ensuite, si l’on exécute la version Coq prouvée de coq-of-rust sur le code source Rust de coq-of-rust, les définitions Coq produites devraient coïncider avec la sortie du traducteur coq-of-rust en Rust utilisé au départ.
À part ça, c’est agréable de voir un financement industriel pour ce genre de travail. Je suis plutôt cynique vis-à-vis des cryptomonnaies, mais il est vrai que leurs exigences de correction poussent à améliorer des domaines qui m’intéressent, comme Rust, Coq et le soutien à des étudiants de master que je connais.
[0]: https://dwheeler.com/trusting-trust/wheelerd-trust.pdf
[1]: https://github.com/formal-land/coq-of-rust/tree/main/lib
Cela dit, même si le code lui-même est court, il dépend du compilateur Rust pour parser et typer le code Rust en entrée. Il faudrait donc aussi vérifier cette partie, ou au minimum en donner une spécification formelle sans preuve. Le fait que l’API de rustc soit assez vaste et instable est un obstacle, mais cela pourrait quand même être un moyen d’accroître la confiance.
Bien sûr, cela introduisait le problème de devoir vérifier la sortie ou le convertisseur, mais le code C obtenu était assez lisible à des fins de vérification, son style était restreint, et le niveau de confiance dans les outils était élevé. L’analyse statique de SPARK faisait partie de l’ensemble du processus de vérification et de validation, tandis que les tests et d’autres activités apportaient des couches de confiance supplémentaires. Dans l’ensemble, c’était une approche qui fonctionnait plutôt bien.
Elles dépensent cet argent dans la recherche en informatique non seulement parce que cela leur profite, mais aussi parce que c’est une forme de philanthropie liée à leurs centres d’intérêt.
Une autre approche serait le code porteur de preuve. Le compilateur Rust émettrait en plus une preuve Coq montrant que l’exécutable produit correspond à la sémantique du code source en entrée.
Bien sûr, on pourrait aussi écrire un compilateur pour un sous-ensemble de Rust, par exemple sans borrow checker ni optimisations, vers le langage machine d’une architecture donnée, puis tout amorcer à partir de là.
Les programmes vérifiés de bout en bout avec un système de preuve déductive semi-automatique comme Coq sont de petite taille. Les bibliothèques peuvent être plus faciles, car le degré d’interaction entre les morceaux de code y est plus faible, mais ce n’est pas le cas des programmes ordinaires.
En pratique, la taille des programmes vérifiables de cette manière a augmenté plus lentement que la taille moyenne des programmes dans leur ensemble. La vérification logicielle solide, c’est-à-dire montrer une conformité à 100 % avec la spécification, est évidemment utile pour des choses comme les preuves de correction d’algorithmes centraux, mais elle passe mal à l’échelle.
C’est aussi pour cela qu’une partie de la recherche s’est déplacée vers des méthodes non solides. Le coût d’une garantie à 100 % peut être dix fois supérieur à celui d’une garantie à 99,9999 %, et l’écart de probabilité peut devenir inférieur à la probabilité de défaillances extérieures au logiciel. Les systèmes physiques ne peuvent de toute façon pas être prouvés conformes à leur spécification, et il existe aussi une probabilité que la spécification elle-même ne corresponde pas suffisamment à la réalité.
Prouver que seul le code unsafe est correct demande beaucoup moins d’efforts que de prouver tout le code Rust. C’est une réponse à la critique qu’on entend souvent au sujet des garanties de sûreté de Rust : « et le code unsafe, alors ? ». Les lacunes que le système de types de Rust n’est pas assez puissant pour traiter peuvent être comblées par un système plus fort comme Coq.
Il y a une partie que je ne comprends pas bien dans ce genre de tentative. S’il faut convertir le code en Coq manuellement ou semi-manuellement, le risque d’erreur dans ce processus n’est-il pas bien plus grand que le bénéfice apporté par la vérification formelle ?
Autrement dit, comment savoir que ce que nous avons prouvé reste valable pour le code d’origine ?
La vérification formelle est souvent présentée comme si elle éliminait totalement la possibilité de bugs, mais en pratique elle consiste plutôt à la réduire fortement. Cela dit, une traduction automatique entre Rust et Coq réduit beaucoup la complexité de ce à quoi il faut faire confiance, et devrait donc être préférée à une traduction manuelle.
Dans de nombreux cas, un bug de traduction rend tout simplement la preuve impossible. Quelqu’un cherche alors pourquoi la preuve ne passe pas, et finit par trouver le bug de traduction.
Le vrai problème, c’est le cas où un bug de traduction annule exactement un bug du code d’origine. S’il n’y a pas de risque systémique, la probabilité que deux bugs s’annulent ainsi mutuellement est assez faible.
Pour les lecteurs intéressés : j’ai soumis ce billet parce qu’il est moins directement lié aux cryptomonnaies que d’autres articles du même blog, mais on y trouve beaucoup d’articles techniquement plus intéressants.
En particulier, les deux articles récents appliquent la même approche non pas à Rust, mais à Python.
Je me souviens d’un cours [1] où un fuzzer avait trouvé un bug dans un compilateur C formellement vérifié. C’était parce que la vérification formelle n’incluait pas le front-end ni le back-end.
Je comprends aussi les questions sur le degré de confiance qu’on peut accorder à Coq lui-même ou à la traduction, et je me demande comment cela restera synchronisé avec les mises à jour de Rust, mais même une vérification formelle parfaite ne signifie pas une exactitude à 100 % de bout en bout.
[1] https://youtu.be/Ux0YnVEaI6A
Je ne suis pas du tout spécialiste de la vérification formelle, mais si la bibliothèque de base de Rust est formellement vérifiée et n’utilise pas de code unsafe, est-ce que tous les programmes Rust qui utilisent cette bibliothèque formellement vérifiée obtiennent, pour la gestion mémoire, une qualité pratiquement équivalente à une vérification formelle ?
Rust a sa propre définition de “safe”, qu’on peut voir comme un sous-ensemble de la sûreté mémoire. Même dans du code Rust entièrement safe, il peut y avoir des data races, des interblocages, un épuisement de mémoire, sans parler des erreurs logiques.
Premièrement, il faut formaliser la sémantique du Rust unsafe. Les travaux pionniers RustBelt[1] de Ralf Jung ont constitué une grande avancée, mais ce n’est pas encore complet. En particulier, la provenance des pointeurs s’avère être un élément délicat.
Deuxièmement, dans ce cadre, il faut un modèle formel du borrow checker. Stacked borrows[2] était une bonne tentative mais comporte des défauts, et Tree borrows[3] pourrait les corriger, même si quelque chose de plus sophistiqué pourrait aussi apparaître.
Troisièmement, il faut un modèle mémoire formel. Il concerne surtout le comportement des opérations atomiques et de la synchronisation, et il est donc très important pour des composants de la bibliothèque standard comme Arc. Il est largement admis que le modèle mémoire de Rust devrait être proche de celui de C++ et interopérable avec lui, mais il reste des problèmes comme “out of thin air” et des fonctionnalités manquantes comme seqlock. C’est l’une des raisons pour lesquelles le noyau Linux utilise encore son propre modèle.
Quatrièmement, il faut prouver que les garanties de sûreté se composent bien. En particulier, la composition de code correct écrit en Rust unsafe avec du code Rust safe doit préserver les garanties de sûreté. Il existe jusqu’ici de bons résultats, mais cela doit être prouvé pour l’ensemble du système.
Cinquièmement, pour que ce type de preuve soit valable pour tout le code, il faut fermer tous les bugs de solidité[1] restants dans Rust. Beaucoup de ces problèmes sont théoriques ou ne comptent vraiment que pour du code hostile[5], ce qui ralentit les progrès.
Même une fois tout cela terminé, il ne s’agira toujours que de garanties partielles. Une énorme partie de la surface de contact avec le système repose sur du code unsafe. Si l’on ne fait que du calcul pur, peut-être, mais si l’on construit par exemple une interface graphique, il reste encore beaucoup de choses qui peuvent mal tourner.
Il existe néanmoins une voie d’amélioration pragmatique, qui mène à une situation bien meilleure que l’état général actuel des systèmes criblés de vulnérabilités.
[1]: https://people.mpi-sws.org/~dreyer/papers/rustbelt/paper.pdf
[2]: https://plv.mpi-sws.org/rustbelt/stacked-borrows/
[3]: https://www.ralfj.de/blog/2023/06/02/tree-borrows.html
[4]: https://github.com/rust-lang/rust/issues?q=is%3Aissue+is%3Ao...
[5]: https://github.com/Speykious/cve-rs
https://github.com/search?q=repo%3Arust-lang%2Frust+unsafe+l...
Je ne pense pas que l’approche Coq présentée ici puisse vérifier tous les appels unsafe.
Peut-on comparer en quoi cette approche diffère de Aeneas ou de RustHornBelt ? Comment les pointeurs et les emprunts mutables sont-ils traités ?
Par rapport à Aeneas, l’objectif est très similaire, puisque les deux cherchent à vérifier des programmes Rust avec un assistant de preuve interactif. Cela dit, dans coq-of-rust, on écrit à la main la version purement fonctionnelle du code qui servira de cible à la preuve, ou bien, comme c’est un travail répétitif, avec l’aide de GitHub Copilot, puis on prouve qu’elle est équivalente au résultat de la traduction automatique. Aeneas vise à générer directement la version fonctionnelle.
Tous les pointeurs sont traités comme des pointeurs mutables, c’est-à-dire comme étant de type
*. Nous n’utilisons pas les informations du borrow checker de Rust ; cela simplifie la traduction, mais on en paie le prix au moment de la preuve.Pour raisonner sur les pointeurs dans la preuve, nous permettons à l’utilisateur de fournir un allocateur personnalisé, conçu selon la façon dont la mémoire sera utilisée. Par exemple, si le programme utilise trois variables globales mutables, on peut représenter la mémoire comme un enregistrement à trois champs. Ces champs valent initialement
Nonepour indiquer qu’ils ne sont pas encore alloués.Nous ne savons pas encore jusqu’où cette technique passera à l’échelle, mais pour l’instant au moins, elle permet d’éviter le raisonnement en logique de séparation. Nous nous attendons à ce que la plupart des programmes que nous voulons vérifier, en particulier côté applicatif, aient une discipline mémoire relativement « simple ».
Rédiger une spécification de vérification formelle ressemble-t-il à l’utilisation de tests fondés sur les propriétés plus complexes ? Au-delà des programmes simples, écrire des tests fondés sur les propriétés devient aussi assez difficile et chronophage.
Lorsqu’on fait de la vérification formelle au même niveau, cela peut sembler assez proche. Mais les outils de vérification formelle peuvent aller plus en profondeur. Par exemple, ils peuvent répondre à des questions sur l’état interne du système pendant un calcul, comme : « J’ai cet invariant de boucle ; peut-on prouver qu’il est effectivement préservé à chaque itération ? » ou « Peut-on prouver qu’aucun sous-dépassement ni dépassement ne se produit jamais dans aucun calcul intermédiaire au cours de ce calcul ? »
Le premier cas est aussi possible avec des tests fondés sur les propriétés, en extrayant le cœur de la boucle dans une procédure séparée et en l’exécutant sur de nombreux états intermédiaires pour tester la propriété d’invariant. Le second est beaucoup plus difficile, sauf à découper le programme de façon extrême jusqu’à rendre chaque ligne exécutable séparément.
Je ne savais même pas que ce genre de chose était possible.
Je me demande si ce type d’initiative pourrait accélérer l’adoption de Rust dans des parties centrales de son introduction dans le noyau.
Quelqu’un pourrait-il expliquer très simplement le concept de « vérification » ? Je me demande pourquoi un langage entier comme Coq existe uniquement pour cet objectif, et quel sens concret cela peut avoir pour la société au sens large.