Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Généralités sur l'utilisation de unsafe

Comportements ajoutés par Rust unsafe

Les capacités du langage peuvent être étendues en utilisant du code unsafe. La liste complète de ces capacités est donnée dans le [manuel de référence de Rust][r-unsafety]. On notera les capacités suivantes :

  • Déréférencer un raw pointer
  • Lire ou écrire une variable statique mutable et/ou externe
  • Accéder aux champs d'une union
  • Implémenter un trait unsafe
  • Déclarer un bloc extern

D'autres exemples peuvent être trouvés dans le nomicon

Si ces capacités sont nécessaires à la programmation système, elles font perdre au langage ses propriétés de sûreté, et permettent l'apparition de comportements indéfinis.

AUCUN comportement indéfini NE DOIT se produire.

Un mot-clé, deux usages

Comme décrit dans rust-reference, le mot-clé unsafe a deux usages : le marquage dans une API et le déverrouillage dans une implémentation.

Marquage unsafe

Le marquage unsafe est une délégation de responsabilité sur la sûreté mémoire du programme en développement.

L'usage de ce mot-clé dans une API met en garde l'utilisateur de l'API contre les potentiels effets néfastes de l'usage de l'API.

  • Dans une signature de fonction (r-unsafe.fn), unsafe signifie que le comportement de la fonction peut conduire à des UB si le contrat d'usage de la fonction (dans sa documentation) n'est pas respecté.
  • Dans la déclaration d'un trait (r-unsafe.trait), unsafe signifie qu'une implémentation erronée de ce trait peut conduire à des UB si le contrat d'implémentation du trait (de préférence documenté) n'est pas respecté.

Déverrouillage unsafe

Le déverrouillage unsafe est une prise de responsabilité sur la sûreté mémoire du programme en développement.

L'usage d'un bloc unsafe dans le corps d'une fonction ou dans la définition d'une constante est imposé par le compilateur (r-unsafe.block) pour empêcher l'usage par inadvertance d'opérations unsafe. Parmi ces opérations, on trouve :

  • l'utilisation de fonctions marquées unsafe
  • la modification de variable mutables statiques
  • l'utilisation de fonctions externes

De manière similaire, l'implémentation d'un trait marqué unsafe nécessite unsafe (r-unsafe.impl) pour indiquer la prise en compte explicite par le développeur des contrats de sûreté du trait. Il permet donc de déverrouiller l'implémentation de traits unsafe.

Enfin, depuis l'édition 2024 de Rust, il est nécessaire également de déverrouiller à l'aide du mot-clé unsafe :

Limitations et précautions d'usage

Paraphrasant le Rustonomicon, le principe fondamental de Rust pourrait se résumer à :

un code sans unsafe ne peut pas mal se comporter

L'utilisation conjointe du système de types et du système d'ownership assure l'absence de comportement indéfini (UB) et apporter un haut niveau de sûreté quant à la gestion de la mémoire dans les programmes écrits en Rust. Le langage permet alors d'éviter les débordements mémoire, la construction de pointeurs nuls ou invalides, et les problèmes d'accès concurrents à la mémoire.

Cette promesse faite au développeur de code sans unsafe est perdue lors de l'utilisation de code unsafe. C'est donc au développeur de s'assurer qu'aucun UB ne peut se produire dans son code. Aussi, il est important de limiter l'usage de unsafe au strict nécessaire :

Pour un développement sécurisé, les blocs unsafe DEVRAIENT être évités, ou DOIVENT être justifiés par au moins l'une des raisons suivantes :

  • L'interfaçage entre Rust et d'autres langages (FFI) permet la déclaration de fonctions dont l'implantation est faite en C, en utilisant le préfixe extern "C". Pour utiliser une telle fonction, le mot-clé unsafe est requis. Un wrapper "sûr" doit être défini pour que le code C soit finalement appelé de façon souple et sûre.

  • Pour la programmation des systèmes embarqués, on accède souvent aux registres et à d'autres ressources au travers d'adresses mémoire fixées. Dans ce cas, des blocs unsafe sont nécessaires afin de pouvoir initialiser et déréférencer des pointeurs en Rust pour ces adresses. Afin de minimiser le nombre de déclarations unsafe pour permettre au développeur de facilement identifier les accès critiques, une abstraction adaptée (structure de données ou module) doit être mise en place.

  • Lorsque les performances sont impactées sur une petite partie de code (Buffer zero-copy modifié directement en mémoire, gestion des allocations, etc.).

À l'exception de l'un ou plusieurs de ces cas #![forbid(unsafe_code)] DOIT apparaître dans à la racine de la crate (typiquement main.rs ou lib.rs) afin de générer des erreurs de compilation dans le cas où le mot-clé unsafe est utilisé dans le projet.

En cas d'usage d'unsafe, il est donc de la responsabilité du développeur du programme:

  • de s'assurer qu'aucun UB n'est possible en cas de déverrouillage ;
  • de s'assurer que les conditions d'usage (invariants) soient exhaustives et correctes en cas de marquage.

Au delà du code unsafe lui-même, il est important d'encapsuler correctement les opérations unsafe dans un composant (crate ou module) de manière à rétablir les garanties usuelles de sûreté mémoire de Rust:

Dans un développement sécurisé d'un composant logiciel Rust (crate ou module), tout code unsafe DOIT être encapsulé de manière :

  • soit à exposer un comportement safe à l'utilisateur dans lequel aucune interaction safe ne peut aboutir à un UB (comportement indéfini) ;
  • soit à exposer des fonctionnalités marquées unsafe et dont les conditions d'usages (préconditions, séquencements, etc.) sont commentées exhaustivement et correctement (c'est-à-dire qu'elles impliquent la sûreté mémoire).

Ainsi, une fonction utilisant des opérations unsafe peut-être sûre (et donc sans marque unsafe) si les opérations unsafe ne présentent pas d'UB étant donnés les invariants du composant (typiquement l'invariant de type pour une méthode). Inversement, une fonction sans bloc unsafe doit être marquée unsafe si elle casse ces invariants. Le choix et la connaissance de ces invariants sont donc cruciaux pour le développement sécurisé.

Exemple : préservation d'un invariant de type

La protection des invariants d'une bibliothèque est primordiale pour se prémunir de bugs en général, d'UB en particulier.

L'exemple qui suit est extrait du Rustonomicon.

Si l'on souhaite réimplémenter le type Vec, on pourrait utiliser le code suivant :

#![allow(unused)]
fn main() {
use std::ptr;

pub struct Vec<T> {
    ptr: *mut T,
    len: usize,
    cap: usize,
}

// Note this implementation does not correctly handle zero-sized types.
impl<T> Vec<T> {
    pub fn push(&mut self, elem: T) {
        if self.len == self.cap {
            // reallocate new array with bigger capacity
        }
        unsafe {
            ptr::write(self.ptr.add(self.len), elem);
            self.len += 1;
        }
    }
}
}

La sûreté de ce code repose sur plusieurs invariants, dont l'un stipule que la plage d'octets allant de self.ptr à self.ptr + self.cap * size_of<T>() est allouée.

Or, il est possible de casser cet invariant avec du code safe. Par exemple, considérons la méthode suivante :

#![allow(unused)]
fn main() {
impl<T> Vec<T> {
    pub fn make_room(&mut self) {
        // grow the capacity
        self.cap += 1;
    }
}
}

Si elle peut être tout à fait légitime pour du code interne à l'API, cette méthode ne doit pas être exposée par l'API ou alors doit être annotée par unsafe car elle peut conduire à des UB (même si elle ne comporte pas de blocs unsafes).

Références