Rust et Miri : au-delà du compilateur pour la sécurité mémoire
En Rust, le compilateur est votre première ligne de défense. Ownership, borrowing et lifetimes empêchent la plupart des erreurs mémoire à la compilation. Mais certaines erreurs peuvent encore passer entre les mailles du filet, notamment avec unsafe ou les pointeurs bruts. C'est là que Miri entre en jeu.
Le compilateur Rust ne suffit pas toujours
Rust est réputé pour sa sécurité mémoire, et à juste titre. Le système de types, les règles d'ownership et le borrow checker forment un filet de sécurité remarquablement efficace. Pourtant, dès qu'on utilise le mot-clé unsafe, on entre dans un territoire où le compilateur ne peut plus tout vérifier.
Les blocs unsafe permettent des opérations que le compilateur ne peut pas prouver comme sûres : déréférencement de pointeurs bruts, appels à des fonctions C via FFI, manipulation directe de la mémoire. Ces opérations sont parfois indispensables dans du code système ou des bibliothèques de bas niveau, mais elles ouvrent la porte à des comportements indéfinis (Undefined Behavior, ou UB).
Le problème : rustc compilera votre code sans broncher, même si un comportement indéfini s'y cache.
Miri : un interpréteur pour traquer les comportements indéfinis
Miri est un interpréteur Rust qui exécute une représentation interne du compilateur appelée MIR (Mid-level Intermediate Representation). Cette représentation est celle que le compilateur utilise pour le borrow checker et les optimisations.
Au lieu d'exécuter votre programme nativement, Miri le simule instruction par instruction, en vérifiant à chaque étape que les règles de la mémoire sont respectées. Il peut ainsi détecter des UB que le compilateur ne peut pas vérifier statiquement :
- Use-after-free : accès à de la mémoire qui a déjà été libérée
- Accès hors limites : lecture ou écriture au-delà d'un buffer
- Violations d'aliasing : références mutables et immutables coexistant sur la même donnée
- Pointeurs pendants (dangling pointers)
- Lectures de mémoire non initialisée
Exemple concret
Considérons ce code Rust :
let s = String::from("hello"); let r: *const String = &s; unsafe { drop(s); // Rust ne voit pas que r devient dangling println!("{}", &*r); // UB : use-after-free }
Ici, on crée une String, on prend un pointeur brut vers elle, puis on la détruit avec drop. Le pointeur r devient un dangling pointer. En essayant de le déréférencer, on accède à de la mémoire libérée.
rustccompile ce code sans aucune erreur ni avertissement.cargo miri rundétecte immédiatement le use-after-free et signale le problème.
Quand utiliser Miri
Miri est particulièrement utile dans les situations suivantes :
- Code utilisant
unsafe: dès que vous écrivez un blocunsafe, Miri devrait faire partie de votre pipeline de tests. - Bibliothèques de bas niveau : les crates qui manipulent la mémoire directement (allocateurs, structures de données lock-free, wrappers FFI) bénéficient énormément de Miri.
- Validation avant mise en production : même si votre code fonctionne en debug et en release, Miri peut révéler des UB latents qui ne se manifestent que sous certaines conditions.
Pour l'utiliser, il suffit d'installer le composant via rustup et de lancer :
cargo miri run # ou pour les tests cargo miri test
Limites de Miri
Miri n'est pas une solution universelle. Il ne peut pas exécuter d'appels système réels ni de code FFI vers du C. Son exécution est aussi significativement plus lente que l'exécution native, puisqu'il simule chaque instruction. Il est donc complémentaire aux tests classiques, pas un remplacement.
Conclusion
Même dans un langage memory-safe comme Rust, l'analyse dynamique avec Miri est essentielle dès qu'on utilise unsafe. Le compilateur garantit la sécurité du code safe, mais Miri étend cette garantie aux zones où le compilateur ne peut pas aller. Pour tout projet sérieux ou toute bibliothèque qui touche à du code unsafe, Miri représente une couche de vérification indispensable qui permet d'attraper des bugs avant qu'ils n'atteignent la production.