Améliorer la sécurité de la mémoire en C#, Richard LanderC# est un langage de programmation de haut niveau à usage général prenant en charge plusieurs paradigmes. C# englobe le typage statique, le typage fort, la portée lexicale, la programmation impérative, déclarative, fonctionnelle, générique, orientée objet (basée sur les classes) et orientée composants.
Les principaux concepteurs du langage de programmation C# étaient Anders Hejlsberg, Scott Wiltamuth et Peter Golde, de Microsoft. Il a été largement diffusé pour la première fois en juillet 2000 et a ensuite été approuvé comme norme internationale par l'Ecma (ECMA-334) en 2002 et par l'ISO/IEC (ISO/IEC 23270 et 20619) en 2003. Microsoft a lancé C# en même temps que le .NET Framework et Microsoft Visual Studio, qui sont tous deux, techniquement parlant, des logiciels à code source fermé. À l'époque, Microsoft ne proposait aucun produit open source. Quatre ans plus tard, en 2004, un projet libre et open source appelé Mono a vu le jour, fournissant un compilateur multiplateforme et un environnement d'exécution pour le langage de programmation C#. Une décennie plus tard, Microsoft a lancé Visual Studio Code (éditeur de code), Roslyn (compilateur) et la plateforme .NET unifiée (framework logiciel), qui prennent tous en charge C# et sont gratuits, open source et multiplateformes. Mono a également rejoint Microsoft, mais n'a pas été intégré à .NET.
Nous sommes en train d’améliorer considérablement la sécurité de la mémoire en C#. Le mot-clé unsafe est en cours de refonte afin d’indiquer aux appelants qu’ils ont des obligations à respecter pour garantir la sécurité, documentées via un nouveau style de commentaire de sécurité. Le champ d’application de ce mot-clé s’étendra du simple marquage des pointeurs à tout code interagissant avec la mémoire d’une manière que le compilateur ne peut pas valider comme sûre. Le compilateur veillera à ce que le mot-clé unsafe soit utilisé pour encapsuler les opérations non sécurisées. Il en résultera que les contrats et les hypothèses de sécurité deviendront visibles et vérifiables, au lieu d’être simplement implicites par convention.
Nous prévoyons de publier le nouveau modèle et la nouvelle syntaxe (officiellement une fonctionnalité de C# 16) en avant-première dans .NET 11 et en version finale dans .NET 12. Cette fonctionnalité sera initialement optionnelle et pourrait devenir la valeur par défaut dans une version ultérieure. Nous mettrons à jour les modèles pour activer le nouveau modèle, tout comme nous l’avons fait avec les types de référence nullables. La première implémentation du compilateur a été intégrée dans la branche principale et prend forme.
C# 1.0 a introduit le mot-clé unsafe comme moyen d’établir un contexte non sécurisé sur les types, les méthodes et les blocs de méthodes internes, permettant aux développeurs de choisir la portée la plus pratique. Un contexte non sécurisé accorde l’accès aux fonctionnalités des pointeurs. Une méthode marquée comme unsafe peut utiliser ces fonctionnalités dans sa signature et son implémentation, contrairement aux méthodes non marquées. Nous avons également exposé un ensemble de types « unsafe » tels que System.Runtime.CompilerServices.Unsafe et System.Runtime.InteropServices.Marshal, dont l’utilisation devait être prudente par convention.
Le mot-clé unsafe a depuis été réutilisé et réinterprété dans Rust et Swift, où les équipes de ces langages lui ont donné une sémantique plus stricte, axée sur la propagation. C# 16 suit la même voie, applique unsafe de manière uniforme (y compris sur les membres Unsafe et Marshal) dans les bibliothèques d'exécution .NET, et ressemble le plus à l'implémentation de Rust. Résultat : unsafe cesse de marquer un type de syntaxe et commence à marquer un type de contrat ; un contrat que le compilateur ne peut pas vérifier, mais qu'un développeur expérimenté doit lire et respecter.
C# bloque déjà le code unsafe par défaut. La plupart des développeurs ne remarqueront aucun changement lorsqu’ils activeront le nouveau modèle, car ils n’activent ni n’utilisent pas d’API unsafe. Le blocage par défaut couvrira une surface beaucoup plus large lorsque le modèle de sécurité de C# 16 sera activé. Le nouveau modèle établit des garde-fous solides qui sont visibles, vérifiables et appliqués par le compilateur. C’est également un outil important pour faire respecter les normes d’ingénierie et de la chaîne d’approvisionnement. La sécurité de la mémoire est une priorité croissante dans l’industrie et au sein des administrations depuis plusieurs années, et la génération de code assistée par l’IA ajoute une nouvelle dimension à mesure que la production de logiciels s’accélère plus vite que la révision humaine.
Sécurité
Un article précédent traite des mécanismes de sécurité structurelle dans .NET :
la sécurité est appliquée par une combinaison du langage et du runtime… Les variables font référence à des objets actifs, sont nulles ou sont hors de portée. La mémoire est auto-initialisée par défaut, de sorte que les nouveaux objets n'utilisent pas de mémoire non initialisée. La vérification des limites garantit que l'accès à un élément avec un index invalide ne permettra pas de lire de la mémoire indéfinie — souvent causée par des erreurs de décalage d'un — mais entraînera à la place une exception IndexOutOfRangeException.
La sécurité en programmation est peut-être plus facile à comprendre si l’on prend un autre domaine comme exemple. Les concepteurs de routes améliorent la sécurité en peignant des lignes continues jaunes ou blanches qui interdisent de passer sur la voie opposée. Les conducteurs comprennent et respectent cette convention. Les autoroutes à grande vitesse utilisent des barrières pour assurer la sécurité grâce à une séparation structurelle qui continue de fonctionner même en l’absence de respect des règles. L’exemple de l’autoroute nous montre que des vitesses plus élevées s’accompagnent de risques plus importants.
La programmation a ses propres types d’accidents, liés à la mémoire. Chaque application a potentiellement accès à des gigaoctets de mémoire virtuelle. Écrire ou lire dans une mémoire arbitraire entraîne un comportement arbitraire (le terme technique est « comportement indéfini », ou UB) et est la cause de la plupart des bogues de sécurité. L’accès à une mémoire arbitraire n’est pas possible dans un code sûr, mais reste une possibilité omniprésente dans un code non sûr.
Le modèle en bref
Les programmes .NET doivent respecter une invariante fondamentale : tout accès à la mémoire doit viser de la mémoire active, c'est-à-dire de la mémoire allouée, initialisée et disponible au moment de l'accès. Le code sécurisé garantit cela de par sa conception : les règles du compilateur et les vérifications d'exécution se combinent pour rendre impossible tout accès erroné. Le code non sécurisé désigne toute opération susceptible de violer cette invariante, généralement en lisant ou en écrivant dans de la mémoire qui n'est pas active, ou en laissant la mémoire dans un état où un accès ultérieur échouera.
Le code non sécurisé peut lire ou écrire dans de la mémoire arbitraire accessible via l'interopérabilité, par NativeMemory, ou gérée manuellement par le développeur. L'invariante doit néanmoins être respectée. Le compilateur ne peut pas détecter d'UB dans ce cas, la charge de la validation incombe donc au développeur.
La solution à ce risque consiste en un ensemble de mécanismes en couches qui propagent de manière intentionnelle et transparente l'insécurité à travers le graphe d'appel, chaque couche permettant à la suivante de fonctionner :
1. Bloc inner unsafe { } : toute opération non sécurisée (appel d'un membre unsafe, déréférencement d'un pointeur et autres actions non sécurisées) doit apparaître à l'intérieur d'un bloc inner unsafe { }. Il s'agit du mécanisme de base. Les opérations non sécurisées sont marquées syntaxiquement, ont une portée définie et sont vérifiables.
2. Propagation : l'ajout de unsafe à la signature de la méthode englobante republie les obligations du bloc interne à ses propres appelants, à moins qu'elles ne soient levées. Cela scinde le graphe d'appel en méthodes sûres, méthodes unsafe et méthodes de frontière entre elles. Les développeurs peuvent enchaîner la propagation à travers un nombre illimité d'intermédiaires jusqu'à ce que quelqu'un décide de l'arrêter.
3. Documentation de sécurité : chaque élément unsafe doit comporter un bloc /// <safety> : le contrat formel entre l'appelé et l'appelant. Sa rédaction est une bonne pratique fortement encouragée, et les analyseurs peuvent signaler son absence.
4. Suppression à la frontière : une méthode qui contient un bloc unsafe interne mais ne marque pas sa propre signature comme unsafe constitue la frontière entre le code unsafe et le code safe. Elle s'acquitte des obligations documentées de l'appelé, par le biais de gardes d'exécution sur les entrées, de raisonnements statiques ou d'invariants documentés provenant d'API en amont (par exemple, malloc garantissant que le pointeur renvoyé est valide pour au moins size octets). C'est cette exécution correcte qui rend les appelants sûrs réellement sûrs.
Il faut parcourir chaque couche pour en tirer la valeur. Si vous ne faites que la moitié du travail, vous n'obtiendrez bien moins de la moitié de la valeur. Parcourez correctement chaque couche et vous disposerez d'un raisonnement cohérent à travers un graphe d'appels que d'autres pourront examiner et éventuellement améliorer.
L'écriture de code non sécurisé est une compétence particulière qui nécessite une solide compréhension de cet invariant et de nombreux pièges. Le nouveau modèle facilite le raisonnement et la révision du code non sécurisé, mais pas son écriture — il impose une structure formelle et visible. Les mots-clés et l'application par le compilateur ne constituent pas la sécurité ; ils sont l'échafaudage qui permet aux développeurs de l'articuler et de la respecter.
C# 1.0 regroupait une catégorie de « fonctionnalités de pointeurs » sous unsafe : la déclaration et la déréférencement de types de pointeurs, la récupération de l'adresse de variables, stackalloc vers un pointeur, sizeof sur des types arbitraires, et d'autres capacités ajoutées au fil des ans, y compris la suppression de certaines erreurs de compilation. Le nouveau modèle est plus sélectif.
Les changements par rapport aux règles de C# 1.0 incluent :
- Le modificateur de type unsafe génère une erreur. La portée unsafe est désormais limitée aux méthodes, propriétés et champs individuels, où son contrat est visible et spécifié de manière plus concise. Les délégués ne peuvent pas non plus être unsafe car ils sont de type défini.
- unsafe n'est pas autorisé sur les constructeurs statiques ou les finaliseurs. Leurs invocations ne présentent pas de modèle de site d'appel pouvant être encapsulé dans un bloc unsafe { }, de sorte que le marqueur de signature n'a rien à propager.
- La contrainte générique new() ne correspond qu'à un constructeur sans paramètre sûr ; un type dont le constructeur sans paramètre est unsafe ne peut pas satisfaire new().
- Un nouveau mot-clé safe permet à un développeur d’attester qu’une déclaration est correcte lorsque le compilateur exige que ce choix soit explicite. À l’heure actuelle, le seul cas de figure concerné est celui des déclarations extern, qui doivent être marquées safe ou unsafe, y compris les déclarations de méthodes partielles LibraryImport.
- L’utilisation de unsafe sur un membre n’établit plus un contexte non sécurisé. Des blocs unsafe internes sont désormais requis aux sites d’appel non sécurisés.
- Les types de pointeurs dans les signatures ne propagent plus la non-sécurité. Seules les déréférencements de pointeurs sont non sécurisés ; ainsi, un paramètre de type byte* ne propage pas en soi la non-sécurité à ses appelants. Pour le nouveau code, évitez IntPtr pour les pointeurs ; privilégiez les pointeurs typés comme byte*, ou void* pour les pointeurs véritablement opaques. Pour les API existantes basées sur IntPtr, envisagez d’ajouter des surcharges de type pointeur et de masquer ou de rendre obsolètes les versions IntPtr. Pour les descripteurs opaques, privilégiez SafeHandle. nint et IntPtr sont indiscernables dans les métadonnées ; par conséquent, lorsqu'un paramètre est véritablement un entier de taille native, indiquez-le explicitement.
L'adoption se fait via une nouvelle propriété au niveau du projet, accessible par option. Voir Option au niveau du projet pour plus de détails.
Le modèle en pratique
Un code non sécurisé augmente considérablement les risques et présente toujours des limites illimitées dans une certaine mesure. Les meilleures API non sécurisées sont conçues pour réduire au maximum ces limites illimitées : elles intègrent tout ce qu'elles peuvent dans la signature, traitent tout ce qu'elles peuvent dans le corps de la fonction, et laissent à l'appelant un résidu restreint et bien défini à gérer lui-même.
La méthode Encoding.GetString(byte*, int) en est un bon exemple.
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 | public unsafe string GetString(byte* bytes, int byteCount) { ArgumentNullException.ThrowIfNull(bytes); ArgumentOutOfRangeException.ThrowIfNegative(byteCount); return string.CreateStringFromEncoding(bytes, byteCount, this); } |
La méthode indique clairement ce qu'attend l'API : le paramètre byte* spécifie un tampon brut non géré, et la valeur associée byteCount précise exactement le nombre d'octets que l'API va lire. Le corps de la méthode gère ce qu'il peut : un pointeur nul ou une longueur négative sont rejetés et provoquent une exception. Les conditions de sécurité éliminent un sous-ensemble de cas où string.CreateStringFromEncoding lirait silencieusement de la mémoire arbitraire. GetString renvoie une nouvelle chaîne, éliminant ainsi tout problème d'aliasing ou de durée de vie lié au tampon.
L'appelant n'a qu'une seule obligation stricte : les byteCount octets commençant à l'adresse bytes doivent correspondre à de la mémoire lisible. Passer une longueur supérieure à celle du tampon entraîne un comportement indéfini : le décodeur peut rencontrer de la mémoire illisible et planter, ou il peut lire tout ce qui se trouve au-delà de la fin et renvoyer une chaîne construite à partir d'octets étrangers arbitraires. Dans le modèle existant, c'est le byte* dans la signature qui empêche cette API d'être appelée depuis du code sûr. Dans le nouveau modèle, un pointeur dans une signature n'implique plus en soi un manque de sécurité ; GetString sera explicitement annoté comme unsafe afin qu'il reste inaccessible depuis du code sécurisé.
« Mieux non sécurisé » ne se définit pas par plus ou moins dangereux, mais par plus ou moins descriptif du manque de sécurité ; les couteaux tranchants coupent le mieux, tandis que les couteaux émoussés déchirent.
Marshal.ReadByte est un cas qui appelle davantage à la prudence.
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 | public static unsafe byte ReadByte(IntPtr ptr, int ofs) { try { byte* addr = (byte*)ptr + ofs; return *addr; } catch (NullReferenceException) { throw new AccessViolationException(); } } |
Les appelants de Marshal.ReadByte transmettent un IntPtr et un décalage qui, combinés, désignent un octet que le programme est autorisé à lire. La différence importante par rapport à GetString réside dans le fait que ReadByte n'effectue aucune validation des entrées et peut aujourd'hui être appelée à partir de code sécurisé. La clause try/catch n'offre aucune sécurité, mais sert à modifier le type d'exception, et ce pour un seul scénario de comportement incorrect. La raison pour laquelle cela est considéré comme acceptable est que Marshal et Unsafe sont traditionnellement considérés comme dangereux à appeler.
Nous pouvons analyser la méthode un peu plus en détail. La signature unsafe actuelle de ReadByte établit un contexte non sécurisé pour l’implémentation, mais ne crée pas de contrat d’appel ni ne documente d’avertissement à l’appelant. Le modèle existant propage l’insécurité via les types de pointeurs dans les signatures, mais IntPtr contourne cette règle ; l’API constitue en fait un contournement des règles relatives aux pointeurs.
Le nouveau modèle comble cette lacune. Il élargit la notion de non-sécurité pour couvrir toute opération susceptible de violer l'invariance de la mémoire active (et pas seulement les opérations impliquant des types de pointeurs), et fait du marqueur de signature unsafe le contrat de membre, avec des blocs non sécurisés internes encapsulant les opérations non sécurisées. Il aligne également le caractère de sécurité d'IntPtr et des pointeurs tels que byte* : les deux peuvent être détenus, assignés et exposés dans des signatures en dehors d'un bloc unsafe ; c'est la déréférence du pointeur qui est non sécurisée.
ReadByte change avec le nouveau modèle, selon la maquette suivante :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | /// <summary>Reads a single byte from unmanaged memory.</summary> /// <safety> /// The sum of <paramref name="ptr"/> and <paramref name="ofs"/> must address a byte /// the caller is permitted to read. /// </safety> public static unsafe byte ReadByte(IntPtr ptr, int ofs) { try { byte* addr = (byte*)ptr; unsafe { // SAFETY: relies on caller obligation. return addr[ofs]; } } catch (NullReferenceException) { throw new AccessViolationException(); } } |
Examinons l'implémentation. Le transtypage (byte*)ptr est une manipulation de pointeur, pas une déréférence ; IntPtr et byte* ont la même forme, mais une représentation différente ; les deux ne sont qu’un nombre. Le danger réside dans une seule ligne : return addr[ofs]. C’est là que le développeur doit s’assurer que addr + ofs pointe vers une mémoire lisible, car l’indexation déréférence cette adresse. byte* → byte nécessite de copier la mémoire de l’adresse du pointeur vers une valeur. C’est là l’opération dangereuse.
Le nouveau modèle fonctionne parce que la déréférence du pointeur, addr[ofs], est encapsulée dans un bloc unsafe, mettant ainsi en évidence le risque. La signature unsafe devient un contrat pour l'appelant, obligeant ce dernier à encapsuler également ses appels dans un bloc unsafe, et lui rappelant de consulter la documentation sur la sécurité de l'appelé.
Une interprétation stricte du « plus petit bloc non sécurisé » placerait l'opération arithmétique + ofs en dehors du bloc, car l'arithmétique en soi n'est pas une déréférence. Nous préférons garder addr[ofs] ensemble : l'indexation est l'indirection (addr[0] est, par spécification, identique à *addr), et le regroupement rend l'adresse exacte lue visible au point d'accès. Nous nous attendons à ce que ce type de choix soit codifié dans les directives de codage non sécurisé au fil du temps.
Les violations sont des erreurs de compilation, pas des avertissements. Le modèle n'est pas un « système basé sur la confiance ». Prenons l'exemple de Marshal.ReadByte ci-dessus : il est marqué comme non sécurisé car son implémentation déréférence un pointeur opaque fourni par l'appelant. Dans le nouveau modèle, il continuera d'être marqué comme non sécurisé car il transfère l'obligation de validité du pointeur aux appelants. Cette obligation était auparavant comprise par convention. Le compilateur exige désormais que Marshal.ReadByte expose cette obligation sous forme de contrat.
Propagation et suppression
Le système de marquage de sécurité mis en place par Rust constitue un bon guide en matière de propagation et de suppression. C# 16 adopte la même approche et la même syntaxe. Le mot-clé unsafe est utilisé de deux manières. La première consiste en un bloc unsafe interne qui encapsule une opération non sécurisée, généralement due à l'appel d'une autre méthode non sécurisée et/ou à la déréférence d'un pointeur. La seconde consiste en un marqueur de signature unsafe externe qui définit un contrat d'appel.
Pour propager l'insécurité à l'appelant, le développeur ajoute unsafe à la signature du membre ; pour supprimer l'insécurité en tant que détail d'implémentation, il omet unsafe. La présence ou l'absence de unsafe dans une signature de membre (pour les méthodes comportant un bloc unsafe interne) constitue le signal du compilateur pour la propagation ou la suppression. La propagation transmet l'insécurité à l'appelant supérieur, tandis que la suppression limite l'insécurité en offrant une interface compatible avec les appelants sûrs.
Modèle C# 1.0
C# 1.0 utilise unsafe sur un type ou un membre pour signifier « contexte non sécurisé à partir de ce point ». Cela n’informe ni ne modifie le contrat d’appel. Les pointeurs constituent le seul mécanisme de propagation en C# 1.0. inner unsafe peut être utilisé pour restreindre la portée de la non-sécurité.
Commençons par un code qui est valide aujourd’hui, dans le modèle C# 1.0.
| Code : | Sélectionner tout |
1 2 3 4 5 6 | void Caller() { M(); } unsafe void M() { } |
Caller peut appeler unsafe M sans aucune formalité.
La raison est double :
- unsafe est utilisé pour créer un bloc inner unsafe pour l’ensemble de la méthode, et non pour définir un contrat d’appelant.
- M n’expose pas de pointeurs, et ne propage donc pas l’insécurité.
Cet exemple est analogue à ReadByte. Caller pourrait appeler ReadByte aussi librement qu'il appelle M. Il ne pourrait pas appeler Encoding.GetString de la même manière en raison de l'utilisation de pointeurs.
Nous devons critiquer le modèle existant pour comprendre pourquoi nous nous en éloignons. Les rôles et responsabilités de M et de Caller ne sont spécifiés que par convention. Il n'existe aucune norme concernant les préoccupations de sécurité ou les obligations que M devrait communiquer à Caller, ni sur la manière dont Caller répond aux attentes de ses appelants en matière de sécurité. En bref, il n'existe aucun système global qui pousse les développeurs vers une sécurité réelle ou qui permette un audit simple. La sécurité est actuellement assurée par des ingénieurs qualifiés qui savent définir les obligations et les risques, sans l'aide du compilateur.
Modèle C# 16
Le nouveau modèle utilise la clé unsafe dans la signature d'une méthode comme mécanisme de propagation vers l'appelant. L'absence de la clé unsafe sert à indiquer une suppression.
La méthode Caller de l'exemple précédent devrait être adaptée pour devenir soit Caller1 , soit Caller2 ci-dessous.
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | /// <safety> /// Caller must satisfy obligation 1 /// </safety> unsafe void Caller1() { unsafe { // SAFETY: Obligation is passed to caller. M(); } } void Caller2() { if (/* obligation 1 not satisfied */) throw new Exception(); unsafe { // SAFETY: obligation 1 is discharged by the check above M(); } } /// <safety> /// Caller must satisfy obligation 1 /// </safety> unsafe void M() { } |
M et Caller1 propagent toutes deux l'insécurité à leurs appelants. Caller2 supprime l'insécurité de ses appelés et constitue une méthode de limite unsafe. L'une ou l'autre forme constitue un remplacement valable pour Caller. Le développeur décide laquelle est appropriée en fonction de la possibilité ou de l'opportunité de valider l'obligation 1. Si les obligations de l'appelant subsistent, alors Caller1 est le bon choix. Le choix entre propagation et suppression n'est pas imposé (ni suggéré) par le compilateur, mais nécessite un jugement prudent.
Caller1 comporte deux marqueurs unsafe par conception : le marqueur externe projette le contrat de l'appelant, tandis que le marqueur interne délimite la portée des opérations unsafe. À l'intérieur d'un membre unsafe, omettre le bloc non sécurisé interne lors d'une opération non sécurisée entraîne une erreur de compilation ; le marqueur de signature n'établit plus à lui seul un contexte non sécurisé. Cette structure de propagation externe / portée interne correspond à unsafe fn / unsafe { } de Rust et à @unsafe / unsafe expr de Swift.
Caller2 est « safe-callable », n’imposant aucune obligation à ses appelants et ne nécessitant aucun bloc unsafe à leurs points d’appel.
Le modèle s’applique à tout appelant. L’exemple ci-dessus illustre des appelants du même type. Le modèle s’applique de manière uniforme à tous les types, projets et paquets. Il s’applique également aux générateurs de code source. Aucun mécanisme d’exclusion par portée n’est prévu.
L'application se fait uniquement au moment de la compilation. Le modèle n'introduit aucune nouvelle vérification d'exécution et n'a aucun impact sur les performances ; les vérifications d'exécution existantes qui entraînent des exceptions telles que IndexOutOfRangeException et ArgumentNullException restent inchangées.
Les bibliothèques d'exécution .NET adopteront ce modèle. Cela est nécessaire en tant que base du modèle pour les appelants. L'utilisation d'une bibliothèque qui a adopté ce modèle n'oblige pas votre projet à l'adopter, et vice versa. Le comportement inter-assemblages dépend du côté qui a opté pour l'activation :
- Appelant ayant opté, appelé ayant opté (Opted-in caller, opted-in callee). Le nouveau modèle. Les marqueurs unsafe de l'appelé sont transmis via les métadonnées, et l'appelant doit encapsuler les appels dans un bloc unsafe { } ; sans cela, l'appel génère une erreur de compilation.
- Appelant ayant opté, appelé n'ayant pas opté (hérité) (Opted-in caller, non-opted-in (legacy) callee). Mode Compat. Le compilateur traite tout membre de l'appelé dont la signature contient un type pointeur comme unsafe, ce qui nécessite un bloc unsafe { } englobant l'appel. Les surfaces non sécurisées non pointeur (paramètres IntPtr/nint, signatures P/Invoke, etc.) ne sont pas signalées, car l'assembly hérité ne contient pas de métadonnées permettant de les distinguer. Le mode Compatibilité empêche une « baisse de sécurité » où les API non sécurisées d’un package hérité perdraient silencieusement leur propagation non sécurisée pilotée par des pointeurs lorsque le nouveau modèle est activé.
- Appelant n'ayant pas opté, appelé ayant opté (Non-opted-in caller, opted-in callee). Aucune application des marqueurs unsafe du nouveau modèle ; l’appelant hérité ne peut pas les interpréter. Les règles de pointeurs héritées de C# 1.0 s'appliquent toujours : un appelé qui expose un type de pointeur dans sa signature exige toujours que l'appelant hérité se trouve dans un contexte unsafe. La nouveauté réside dans les méthodes non sécurisées du nouveau modèle qui ne comportent pas de types de pointeurs dans leur signature (par exemple, unsafe byte ReadByte(IntPtr, int)). Celles-ci deviennent appelables à partir de code sécurisé hérité.
La migration des bibliothèques d'exécution est déjà en cours : l'étiquette « reduce-unsafe » suit la liste des tickets de pull (PR) supprimant le code non sécurisé des bibliothèques, y compris des remplacements comme le #127394 (remplacement de MemoryMarshal.Read/Write par des équivalents BitConverter) et le #127485 (suppression du code non sécurisé de IBinaryInteger.TryReadBigEndian). Cette migration montre également que le code industriel peut être converti en modèles sécurisés. Votre code non sécurisé le peut probablement aussi.
Pour résumer les changements par rapport à C# 1.0 :
- Le mot-clé unsafe sur la signature d'un membre définit désormais un contrat vis-à-vis de l'appelant qui propage l'insécurité vers le haut de l'arborescence d'appel. Dans C# 1.0, il servait uniquement à établir un contexte non sécurisé.
- Un bloc unsafe est obligatoire à chaque appel à un membre non sécurisé.
Comparaison entre les langages : propagation
Les différences entre C#, Rust et Swift sont à la fois subtiles et instructives. C# 16 ne propage l'insécurité que lorsque le mot-clé unsafe apparaît sur le membre ; les types de pointeurs et autres paramètres de type unsafe ne se propagent pas d'eux-mêmes. Rust se comporte de la même manière : un paramètre *const u8 sur une fonction fn simple ne propage rien. Swift est l'exception : tout type @unsafe apparaissant dans une signature rend implicitement la déclaration @unsafe, en plus de l'attribut explicite @unsafe.
Le modèle implicite de Swift conduit à la nécessité d’utiliser @safe comme option d’exclusion largement applicable pour les API qui encapsulent l’insécurité (par exemple, Array.withUnsafeBufferPointer). C# et Rust incluent tous deux une forme de sécurité positive restreinte pour l’interopérabilité (FFI), mais pour des raisons différentes. La fonction safe fn de Rust à l’intérieur d’un bloc extern non sécurisé est une redéfinition de la valeur par défaut. Le bloc est par défaut non sécurisé et safe permet d’exclure une déclaration individuelle, de manière analogue à @safe de Swift. Le safe extern de C# 16 pour les déclarations LibraryImport n’est pas une redéfinition. Il s’agit d’une déclaration concernant l’ensemble de la déclaration et elle est requise car le langage privilégie les marquages explicites et ne permet pas à un développeur de laisser la sécurité d’une déclaration étrangère implicite.
Chaque méthode partielle LibraryImport doit être marquée comme safe ou unsafe :
| Code : | Sélectionner tout |
1 2 3 4 5 | [LibraryImport("libc")] internal static safe partial int getpid(); [LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)] internal static unsafe partial nint strlen(byte* str); |
getpid n’a pas de paramètres et renvoie une primitive ; l’auteur certifie que l’appel est correct et que les appelants peuvent l’utiliser sans crainte. strlen prend un pointeur brut que le code natif déréférencera ; l’auteur n’a aucun moyen de s’acquitter de cette obligation à la limite, donc la déclaration se propage comme unsafe et un bloc <safety> nomme l’obligation de l’appelant. Omettre les deux modificateurs entraîne une erreur de compilation — le développeur doit faire un choix.
Examinons un exemple de propagation. Un court programme Rust (édition 2024) déclenche à la fois un avertissement unsafe_op_in_unsafe_fn (une opération non sécurisée à l’intérieur du corps d’une fonction non sécurisée sans bloc unsafe interne) et une erreur E0133 (un appel à une fonction non sécurisée depuis un contexte sécurisé sans bloc unsafe) :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | $ cat main.rs /// # Safety /// /// `bytes` must be non-null and point to at least one readable byte. pub unsafe fn first_byte(bytes: *const u8) -> u8 { // No inner `unsafe { }`: warns under `unsafe_op_in_unsafe_fn` (edition 2024). *bytes } fn main() { let data = [42u8]; // No `unsafe { }` around the call: hard error E0133. let value = first_byte(data.as_ptr()); println!("{value}"); } $ cargo build Compiling unsafe_demo v0.1.0 (/private/tmp/unsafe-demo) warning[E0133]: dereference of raw pointer is unsafe and requires unsafe block --> src/main.rs:6:5 | 6 | *bytes | ^^^^^^ dereference of raw pointer | = note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behavior note: an unsafe function restricts its caller, but its body is safe by default --> src/main.rs:4:1 | 4 | pub unsafe fn first_byte(bytes: *const u8) -> u8 { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = note: for more information, see <https://doc.rust-lang.org/edition-guide/rust-2024/unsafe-op-in-unsafe-fn.html> = note: `#[warn(unsafe_op_in_unsafe_fn)]` (part of `#[warn(rust_2024_compatibility)]`) on by default error[E0133]: call to unsafe function `first_byte` is unsafe and requires unsafe block --> src/main.rs:12:17 | 12 | let value = first_byte(data.as_ptr()); | ^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function | = note: consult the function's documentation for information on how to avoid undefined behavior For more information about this error, try `rustc --explain E0133`. warning: `unsafe_demo` (bin "unsafe_demo") generated 1 warning error: could not compile `unsafe_demo` (bin "unsafe_demo") due to 1 previous error; 1 warning emitted |
Cette expérience est très similaire à ce que nous avons prévu. La principale différence est que ces deux cas seront considérés comme des erreurs dans C# 16.
En résumé, les codes C# et Rust privilégient des règles explicites simples et nécessitent sans doute moins de connaissances du domaine. Un exemple typique est qu’il est raisonnable d’utiliser grep comme outil d’audit de sécurité avec C# 16 et Rust, car les mots-clés explicites agissent comme des repères auxquels les requêtes peuvent facilement s’accrocher.
Opt-in au niveau du projet
Le modèle de sécurité de C# 16 comporte deux commutateurs au niveau du projet. Ils sont indépendants et ont des objectifs différents.
Le premier commutateur est une nouvelle propriété d'activation (dont le nom définitif sera annoncé avec la préversion de .NET 11). Lorsqu'il est désactivé, les règles héritées de C# 1.0 continuent de s'appliquer ; lorsqu'il est activé, les nouvelles règles « caller-unsafe » s'appliquent. Ce commutateur détermine ce qui est considéré comme non sécurisé et comment cela se propage.
Le deuxième commutateur est la propriété existante <AllowUnsafeBlocks>. Elle est définie par défaut sur false (dans toutes les versions de C#) et contrôle chaque occurrence du mot-clé unsafe dans le code source du projet : signatures de membres, blocs internes, champs et déclarations safe extern selon les nouvelles règles. L'appel d'une API non sécurisée depuis un autre projet est pris en compte, car le site d'appel nécessite un bloc interne unsafe { }. Ainsi, un projet avec les paramètres par défaut ne peut utiliser aucune API non sécurisée.
Ces deux propriétés se combinent comme suit :
- Nouvelle propriété activée, <AllowUnsafeBlocks> désactivée (par défaut). Il s'agit de la configuration la plus sûre. Le projet adhère au nouveau modèle et n'autorise aucun code non sécurisé. Vous savez que votre code n'appelle pas Marshal.ReadByte ni aucun autre membre non sécurisé.
- Nouvelle propriété activée, <AllowUnsafeBlocks> activée. Le projet adhère au nouveau modèle et autorise le code non sécurisé.
- Nouvelle propriété désactivée, <AllowUnsafeBlocks> désactivé. L'ancien modèle continue de s'appliquer. Le projet ne peut pas utiliser de types de pointeurs.
- Nouvelle propriété désactivée, <AllowUnsafeBlocks> activé. L'ancien modèle continue de s'appliquer. Le projet peut utiliser des types de pointeurs.
Nous souhaitons que tout le monde passe au nouveau modèle. Nous prévoyons également que, avec le temps, moins de projets activeront <AllowUnsafeBlocks>. C'est ce que nous faisons avec notre propre code.
Pour faciliter cette transition, nous prévoyons de fournir un correcteur de dotnet format qui effectuera une migration au mieux de ses capacités sur les projets qui n'ont pas encore activé la nouvelle propriété : en encapsulant les sites d'appel non sécurisés dans des blocs unsafe { }, en déplaçant le modificateur unsafe des types vers leurs membres, et en effectuant d'autres réécritures mécaniques similaires. Le correcteur ne peut pas déduire les obligations de sécurité ni écrire de blocs <safety> ; ce travail reste à la charge du développeur. Il s'agit d'un point de départ permettant de compiler le code selon les nouvelles règles, et non d'une migration achevée.
La question centrale concernant les agents générateurs de code est de savoir à qui incombe la responsabilité de déterminer si du code non sécurisé a été écrit. Avec le nouveau modèle, c'est celle du compilateur. En supposant que vous n'ayez pas défini AllowUnsafeBlocks=true, le compilateur refusera de compiler tout code non sécurisé. Aucune révision de code ne peut égaler l'efficacité d'une erreur de compilation. L'audit de la sécurité de la mémoire passe de l'inspection de chaque diff à la vérification d'une seule propriété de projet.
Comparaison entre les langages : valeurs par défaut
Les différences sont ici aussi subtiles et importantes. Nous pouvons situer les trois langages selon deux axes de sécurité : la propagation stricte (la manière dont l’insécurité se propage et ce qui est considéré comme dangereux) et l’interdiction pure et simple du code dangereux. Pour chaque axe, la posture la plus sûre est soit la valeur par défaut, soit disponible en option.
C# 16 activera le modèle strict avec le nouveau mot-clé safety. AllowUnsafeBlocks=false reste la valeur par défaut. Sous le nouveau modèle, il effectue un travail encore plus lourd, car l'ensemble des actions non sécurisées qu'il contrôle est beaucoup plus vaste.
Rust ne dispose que d’un seul modèle de sécurité, un modèle strict. Le compilateur autorise par défaut l’utilisation de unsafe dans n’importe quel crate et nécessite l’utilisation de la directive #![forbid(unsafe_code)] pour le désactiver.
Swift propose également un mode strict en option (-strict-memory-safety, SE-0458), qui peut être défini par fichier ou par module pour transformer les dangers implicites en diagnostics.
Ces comparaisons ne sont pas tout à fait équivalentes, car elles sont multidimensionnelles. Rust a la position par défaut la plus stricte. Notre point de vue s’aligne sur le continuum de sécurité mémoire : des valeurs par défaut plus strictes sont préférables. Notre intention est de faire du nouveau modèle de sécurité de C# la nouvelle norme. Nous commencerons par l’activer avec les modèles. Il est plus simple pour nous d’introduire un modèle de sécurité plus strict étant donné que le code non sécurisé est déjà interdit par défaut, et nous nous attendons à une bonne adoption pour cette raison.
Documentation relative à la sécurité
Il est facile d’interpréter le terme « non sécurisé » au sens littéral, mais cela prête à confusion. Il signifie « désactiver les mécanismes de sécurité ». Le compilateur sait que le code sécurisé respecte un modèle de sécurité défini, contrairement au code non sécurisé. Avec le code non sécurisé, c’est au développeur qu’incombe la responsabilité de s’informer. Pour s’informer, il faut commencer par lire la documentation dédiée à la sécurité. Un code non sécurisé correctement rédigé documente les obligations de l'appelant : les conditions que celui-ci doit remplir pour que le code se comporte correctement.
Un code non sécurisé dont la documentation est manquante ou mal rédigée n'est pas sûr à appeler, car l'appelant est laissé dans l'incertitude. Les auditeurs de code y prêtent une attention particulière. C'est déjà le cas dans la communauté Rust : Google et Mozilla.
Un analyseur signalera les blocs /// <safety> manquants.
Commentaires de sécurité Rust
Nous nous appuierons sur Rust pour les exemples canoniques, car ce langage est bien établi. Rust utilise des commentaires de sécurité pour démontrer que le code non sécurisé est correct.
Une fonction Rust non sécurisée, as_bytes_mut :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | /// Converts a mutable string slice to a mutable byte slice. /// /// # Safety /// /// The caller must ensure that the content of the slice is valid UTF-8 /// before the borrow ends and the underlying `str` is used. /// /// Use of a `str` whose contents are not valid UTF-8 is undefined behavior. /// /// ... pub unsafe fn as_bytes_mut(&mut self) -> &mut [u8] { // SAFETY: the cast from `&str` to `&[u8]` is safe since `str` // has the same layout as `&[u8]` (only libstd can make this guarantee). // The pointer dereference is safe since it comes from a mutable reference which // is guaranteed to be valid for writes. unsafe { &mut *(self as *mut str as *mut [u8]) } } |
Clippy applique cette convention. Une fonction non sécurisée sans section # Safety déclenche le lint missing_safety_doc :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | $ cat main.rs #![deny(clippy::missing_safety_doc)] pub unsafe fn first_byte(bytes: *const u8) -> u8 { unsafe { *bytes } } fn main() { let data = [42u8]; let value = unsafe { first_byte(data.as_ptr()) }; println!("{value}"); } $ cargo clippy Checking unsafe_demo v0.1.0 (/private/tmp/unsafe-demo) error: unsafe function's docs are missing a `# Safety` section --> src/main.rs:3:1 | 3 | pub unsafe fn first_byte(bytes: *const u8) -> u8 { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.95.0/index.html#missing_safety_doc note: the lint level is defined here --> src/main.rs:1:9 | 1 | #![deny(clippy::missing_safety_doc)] | ^^^^^^^^^^^^^^^^^^^^^^^^^^ error: could not compile `unsafe_demo` (bin "unsafe_demo") due to 1 previous error |
Si vous débutez avec Rust, oui, il dispose de commentaires de documentation ///. Il dispose également d’attributs, qui sont utilisés pour les balises de sécurité proposées.
Le bloc /// # Safety au-dessus de la fonction documente les obligations formelles et contractuelles de l’appelant. Il incombe à l’appelant de lire les commentaires de sécurité. Négliger de le faire peut entraîner l’écriture d’un code non sécurisé incorrect aux conséquences indéfinies. Si des problèmes surviennent, la responsabilité incombe à l’appelant. C’est pourquoi nous qualifions cette fonctionnalité de « non sécurisée pour l’appelant ».
Les commentaires /// sont directement copiés dans la documentation publique de Rust pour as_bytes_mut. Les commentaires de sécurité sont extraits du code et placés sur un portail public où les appelants peuvent les consulter. Cela montre clairement leur importance et explique pourquoi ils doivent être distincts des commentaires habituels.
L'exemple comprend également un deuxième type de commentaire de sécurité, plus interne. Les notes // SAFETY: à l'intérieur du corps de la fonction s'adressent aux développeurs ou aux auditeurs du code ; elles décrivent les hypothèses de sécurité, et non les obligations de l'appelant. Le compilateur ne lit pas, n'exige pas et ne prend pas en compte ces commentaires. Il s'agit d'une convention.
Ces deux styles de commentaires sont importants. Ensemble, ils racontent une histoire à double facette sur la sécurité, ancrée dans le graphe d'appel.
Avec le bloc unsafe, nous affirmons à Rust que nous avons lu la documentation de la fonction, que nous comprenons comment l'utiliser correctement et que nous avons vérifié que nous respectons le contrat de la fonction.
Commentaires de sécurité C#
C# utilise deux styles de commentaires de sécurité, illustrés ici dans la maquette ReadByte :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | /// <summary>Reads a single byte from unmanaged memory.</summary> /// <safety> /// The sum of <paramref name="ptr"/> and <paramref name="ofs"/> must address a byte /// the caller is permitted to read. /// </safety> public static unsafe byte ReadByte(IntPtr ptr, int ofs) { try { byte* addr = (byte*)ptr; unsafe { // SAFETY: relies on caller obligation. return addr[ofs]; } } catch (NullReferenceException) { throw new AccessViolationException(); } } |
Le bloc /// <safety> au-dessus de la signature constitue le contrat formel de l'appelant. Le commentaire // SAFETY: à l'intérieur du corps est une note interne indiquant sur quoi repose l'opération non sécurisée.
La signature seule, unsafe byte ReadByte(IntPtr, int), vous indique la forme, mais pas le contrat de sécurité. Le bloc /// <safety> constitue le contrat, c'est pourquoi un analyseur signalera son absence. La leçon à retenir est que connaître la forme d'une API non sécurisée est nécessaire mais pas suffisant pour écrire un code correct. L'écriture de code non sécurisé nécessite des lunettes de sécurité.
Une seule obligation résiduelle est mentionnée : ptr + ofs doit pointer vers un octet lisible. L'appelant doit s'en acquitter. Le mot-clé unsafe sur la signature est ce qui met en évidence cette obligation pour les appelants. Le commentaire // SAFETY: précise sur quoi repose la déréférence : le fait que l'appelant dispose de garde-fous de sécurité pour cette obligation.
Considérez les états dans lesquels un paramètre IntPtr peut se trouver lorsqu'un appelant le transmet :
- IntPtr.Zero (null) : la déréférence se heurte aux pages de protection contre les valeurs nulles du runtime et se traduit par une exception NullReferenceException, que le bloc catch convertit en AccessViolationException. Supprimer le bloc catch ne modifierait pas la sécurité, mais uniquement le type d'exception.
- Un pointeur vers de la mémoire non mappée (non initialisée, libérée ou contenant une valeur aléatoire) : la déréférence provoque une violation d'accès matériel. Sur la plupart des plateformes, cela met fin au processus ; le bloc catch peut même ne pas s'exécuter.
- Un pointeur vers de la mémoire mappée dont l'appelant n'est pas propriétaire (le tampon de quelqu'un d'autre, le tas du GC, un segment de code) : la déréférence peut réussir. Les pages mappées peuvent tout de même être illisibles (les pages de garde, par exemple), auquel cas le comportement correspond au point précédent. En cas de réussite, ReadByte renvoie un octet arbitraire de la mémoire avec une valeur arbitraire. Aucune exception, aucun avertissement. Il s'agit là d'un résultat UB classique ; le programme continue en se basant sur des hypothèses corrompues. Dans le pire des cas, il lit de la mémoire qui est interprétée comme une valeur valide pour le programme.
- Un pointeur dont l'appelant sait avec certitude qu'il pointe vers un octet lisible : fonctionne comme prévu.
Le try/catch gère le premier cas, échoue de manière non gracieuse dans le deuxième et est invisible dans le troisième. Rien de tout cela n'est de la validation. Le contrat remonte jusqu'à l'appelant, où les informations concernant l'origine, la longueur et la durée de vie du tampon peuvent être utilisées pour exclure les états dangereux. C'est le bloc /// <safety> qui rend ce contrat visible. L'appelant doit comprendre ces cas et s'en prémunir.
Garde de sécurité
La documentation énonce les obligations. Les gardes les remplissent. Ce modèle est particulièrement important à la frontière de l'insécurité, là où un développeur atteste que le code non sécurisé a été mis en conformité avec la sécurité fournie par le compilateur. C'est également à cette frontière que la révision doit commencer. Avec une bonne documentation comme guide, le réviseur peut déterminer si le code est conforme.
On pourrait se demander pourquoi les méthodes non sécurisées n’incluent pas suffisamment de vérifications if pour supprimer la nécessité des obligations de l’appelant. Pour ReadByte, aucune vérification if à l’intérieur de la méthode ne peut valider qu’un IntPtr fourni par l’appelant pointe vers une mémoire lisible : le runtime ne sait tout simplement pas ce que l’appelant a alloué, où, ni pour combien de temps. Les appelants sont les seuls à pouvoir déterminer l’ensemble minimal de vérifications qui maintiennent la sécurité tout en maximisant les performances.
Remarque : il n’existe pas de nom standard pour ces méthodes/fonctions de frontière. La documentation Rust les appelle « éléments sûrs ». Cet article les appelle « méthodes de frontière non sûres » : des méthodes situées à la frontière entre le code sûr et le code non sûr, où la non-sécurité est supprimée. Le terme « non sûr » est délibéré : ces méthodes conservent toutes les capacités dangereuses des méthodes marquées « unsafe » ; elles ne les propagent simplement pas à leurs appelants.
Garde de sécurité Rust
Autre exemple en Rust, str.split_at :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 | pub fn split_at(&self, mid: usize) -> (&str, &str) { // is_char_boundary checks that the index is in [0, .len()] if self.is_char_boundary(mid) { // SAFETY: just checked that `mid` is on a char boundary. unsafe { (self.get_unchecked(0..mid), self.get_unchecked(mid..self.len())) } } else { slice_error_fail(self, 0, mid) } } |
Les fonctions de limite non sûres ne comportent généralement que des commentaires // SAFETY: ; elles n’imposent pas d’obligations propres. Le style formel /// est réservé aux méthodes unsafe, dont la limite se charge alors de remplir les obligations. Les fonctions qui propagent doivent être marquées comme unsafe.
La vérification if self.is_char_boundary(mid) dans split_at est une garde qui garantit la sécurité du code non sûr qu’elle appelle. Il garantit que la division s'effectue à une limite de caractère, car les caractères Unicode peuvent être multioctets. Si ce test échoue, le programme entre en panique via slice_error_fail. Une panique provoquera le plantage du programme afin d'empêcher tout comportement indéfini.
Un programme qui entre en panique pour éviter un comportement indéfini est bien plus fiable qu'un programme qui laisse ce comportement se produire.
Garde de sécurité en C#
Le même modèle de délimitation que dans Rust s'applique en C# : même convention // SAFETY:, même absence de marqueur « unsafe » dans la signature.
String.CopyTo :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // Converts a substring of this string to an array of characters. Copies the // characters of this string beginning at position sourceIndex and ending at // sourceIndex + count - 1 to the character array buffer, beginning // at destinationIndex. // public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count) { ArgumentNullException.ThrowIfNull(destination); ArgumentOutOfRangeException.ThrowIfNegative(count); ArgumentOutOfRangeException.ThrowIfNegative(sourceIndex); ArgumentOutOfRangeException.ThrowIfGreaterThan(count, Length - sourceIndex, nameof(sourceIndex)); ArgumentOutOfRangeException.ThrowIfGreaterThan(destinationIndex, destination.Length - count); ArgumentOutOfRangeException.ThrowIfNegative(destinationIndex); unsafe { // SAFETY: the bounds checks above ensure that `count` characters // starting at `sourceIndex` are in range of this string, and that // `count` characters starting at `destinationIndex` fit in `destination`. Buffer.Memmove( destination: ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(destination), destinationIndex), source: ref Unsafe.Add(ref _firstChar, sourceIndex), elementCount: (uint)count); } } |
Chaque appel ThrowIf* ici est une mesure de sécurité mémoire. Chacun d'entre eux garantit une invariante que l'appel brut Buffer.Memmove suppose :
- ThrowIfNull(destination) : sans cela, MemoryMarshal.GetArrayDataReference(null) est UB.
- ThrowIfNegative(count) : sans cela, (uint)count convertit silencieusement une valeur négative en un elementCount énorme, et la copie hors limites qui en résulte est UB.
- ThrowIfNegative(sourceIndex) et ThrowIfNegative(destinationIndex) : sans elles, Unsafe.Add(ref , negativeIndex) fait sortir la référence de la zone de stockage, et la lecture ou l'écriture qui en résulte est UB.
- Les deux vérifications ThrowIfGreaterThan s’ajoutent aux vérifications négatives ci-dessus (et s’appuient sur l’invariance d’exécution selon laquelle Length est dans [0, int.MaxValue], de sorte que Length - sourceIndex ne déborde pas) pour limiter count par rapport à la capacité restante de source et de destination. Sans elles, la copie peut dépasser la fin de l’un ou l’autre des tampons, et la lecture ou l’écriture qui en résulte est UB.
Les vérifications s’enchaînent. Chacune n’est suffisante que parce que les précédentes ont déjà écarté certaines classes d’entrées. Modifiez n’importe quel maillon de cette chaîne (passez à un type d’index non signé, ou modifiez ce que le runtime garantit concernant Length), et le raisonnement de sécurité doit être redéfini.
Les méthodes ThrowIf* sont l'équivalent en C# des aides à la panique de Rust telles que slice_error_fail ; toutes deux provoquent le plantage du programme à la limite plutôt que de laisser un comportement indéfini se produire, et toutes deux sont factorisées en fonctions distinctes afin de maintenir les chemins froids hors du code chaud.
Champs unsafe
Les champs méritent une discussion. Un champ doit être Unsafe lorsque son type déclaré n'exprime pas une invariante que le type englobant maintient et dont le code en aval dépend. La non-sécurité réside dans l'écart entre ce que le système de types voit et ce que le type englobant promet.
Le cas le plus simple est un champ contenant un pointeur natif. L'exemple ci-dessous est une maquette ; il ne provient pas de dotnet/runtime comme les autres exemples.
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | public class NativeBuffer : IDisposable { /// <safety> /// Must be null or point to a buffer of Length bytes. /// </safety> private unsafe byte* _ptr; public int Length { get; } public NativeBuffer(int length) { ArgumentOutOfRangeException.ThrowIfNegative(length); unsafe { // SAFETY: NativeMemory.Alloc throws OutOfMemoryException on failure rather than // returning null (unlike the malloc it wraps), so on return _ptr points to `length` bytes. _ptr = (byte*)NativeMemory.Alloc((nuint)length); } Length = length; } public byte ReadAt(int index) { ArgumentOutOfRangeException.ThrowIfNegative(index); ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Length); unsafe { ObjectDisposedException.ThrowIf(_ptr is null, this); // SAFETY: bounds checked above; null check just above; _ptr therefore points to Length bytes return _ptr[index]; } } public void Dispose() { unsafe { // SAFETY: _ptr is null or was returned by NativeMemory.Alloc; Free accepts both NativeMemory.Free(_ptr); _ptr = null; } } } |
Un cas plus courant dans les bibliothèques d'exécution est celui d'un champ dont le type déclaré est correct mais moins spécifique que ce que la classe gère réellement. Le document de conception donne une version simplifiée de ce modèle : une classe générique contient un champ Array qui doit toujours contenir un T[]. Array est l'objet des types de tableaux ; chaque T[] est un Array, donc déclarer le champ comme Array est correct du point de vue du type, et cela évite les coûts de spécialisation générique. Le système de types C# permet d'affecter n'importe quel tableau à ce champ, alors que la classe garantit toujours exactement T[]. Le risque réside dans cet écart : le système de types ne peut pas voir l'invariant plus strict, et la classe est chargée de le respecter.
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | public class ArrayWrapper<T> { /// <safety> /// Must always hold a value whose runtime type is T[]. /// </safety> private readonly unsafe Array _array; public ArrayWrapper(T[] items) { ArgumentNullException.ThrowIfNull(items); unsafe { // SAFETY: items is statically T[], so the field invariant holds. _array = items; } } public T GetItem(int index) { unsafe { // SAFETY: _array is always a T[] per the field's <safety> block var typedArray = Unsafe.As<T[]>(_array); return typedArray[index]; } } } |
Le modèle est le même que celui de NativeBuffer : un champ unsafe avec un invariant documenté, des blocs non sécurisés à la limite qui le libèrent, et une interface publique pouvant être appelée en toute sécurité.
Rust travaille sur le même problème, et la proposition relative aux champs non sécurisés utilise Vec<T> comme cas d'étude. Vec<T> comporte une invariante selon laquelle les éléments à data[i] pour i < len sont initialisés. Aujourd'hui, cette invariante n'existe que dans les commentaires et la documentation. Rien n'empêche une méthode (même privée) de désynchroniser len et data dans un code entièrement sûr :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 | pub struct Vec<T> { data: Box<[MaybeUninit<T>]>, len: usize, } impl<T> Vec<T> { // Safe code, but the next read is undefined behavior. pub fn evil(&mut self) { self.len += 2; } } |
La forme proposée pour l'avenir intègre l'invariance dans le système de types en marquant les deux champs comme non sécurisés :
| Code : | Sélectionner tout |
1 2 3 4 5 6 | struct Vec<T> { // SAFETY: The elements `data[i]` for // `i < len` are in a valid state. unsafe data: Box<[MaybeUninit<T>]>, unsafe len: usize, } |
Avec ce changement, toute écriture sur len ou data doit désormais s'effectuer à l'intérieur d'un bloc unsafe ; evil ne se compile plus tel quel. Les deux champs sont vérifiés ensemble, au même endroit, par rapport au même contrat. C'est exactement le même avantage dont bénéficie NativeBuffer en associant un pointeur unsafe de type byte* _ptr à une longueur fixe, et dont bénéficie ArrayWrapper<T> en associant readonly unsafe Array _array à la promesse toujours de type T[].
Vous pourriez dire que l'on peut toujours écrire evil avec unsafe et que cela aboutit toujours à un comportement indéfini (UB). Oui. L'idée générale est que le code unsafe est marqué et facile à auditer. C'est le fondement de la sécurité dans tous ces langages.
Quelques règles empiriques pour l’utilisation de unsafe sur les champs :
- Les écritures constituent la motivation principale. L’utilisation de unsafe sur le champ force chaque écriture dans un contexte vérifiable où le contrat est visible, établissant (au moins) la discipline entre membres qui préserve l’invariant. Par exemple, une écriture sur _ptr dans l’exemple NativeBuffer violerait Length.
- Les champs readonly répondent en grande partie au même besoin. Il est utile de considérer unsafe readonly comme le contrat plus une protection intégrée : unsafe nomme l’invariant, et readonly est la protection de sécurité qui empêche les écritures post-construction de le violer. Supprimez readonly et le contrat reste en place ; il doit simplement être respecté de la manière la plus difficile, en vérifiant chaque site d'écriture. L'exemple ArrayWrapper<T> ci-dessus est readonly unsafe précisément pour cette raison. Rust converge vers la même forme via les axiomes de conception des champs unsafe : le marqueur reste, mais les opérations qu'il contrôle (écritures, réinitialisation) sont exactement celles que l'immuabilité empêche déjà.
- Private n'est pas un passe-droit. Il est tentant de supposer que, puisqu’un champ est privé, on peut faire confiance aux méthodes propres au type pour maintenir l’invariance. C’était l’ancien modèle de type unsafe. Dans le nouveau modèle, l’interaction entre membres est elle-même une surface de contrat ; l’écriture correcte d’une méthode peut être annulée par l’écriture non coordonnée d’une autre méthode. La non-sécurité consiste à protéger le contrat contre tout code susceptible de le violer, y compris le code au sein du type lui-même.
Guide de migration
La meilleure façon de comprendre le modèle est de migrer du code existant vers celui-ci. C’est ce que fait l’équipe .NET à travers les bibliothèques d’exécution. Choisissez une API unsafe, suivez-la jusqu’à un appelant, et déterminez si la migration peut décharger les obligations de l’appelé en ligne ou si elle doit les propager vers le haut. Chaque appelant est un emplacement candidat pour la limite ; la migration permet de déterminer si c’est là que la limite doit se situer.
Cette section est spéculative. Le modèle n’est pas finalisé et les bibliothèques d’exécution n’ont pas encore été migrées. Les exemples sont des hypothèses éclairées, destinées à montrer où nous allons et ce que le nouveau modèle implique pour le code existant.
Nous allons migrer dans cette section certaines méthodes qui aboutissent à NativeMemory.Alloc et NativeMemory.Free. Voici à quoi ressemblent les deux méthodes NativeMemory dans le nouveau modèle :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 | public static void* Alloc(nuint byteCount); /// <safety> /// The caller must ensure: /// /// - <paramref name="ptr"/> was returned by <see cref="Alloc(nuint)"/> (or a /// compatible allocator) and has not already been freed. /// - No live pointer or span aliases the storage at the time of this call. /// </safety> public static unsafe void Free(void* ptr); |
L'asymétrie est intentionnelle. Alloc devient sûr. Il renvoie un void*, mais le fait de détenir un pointeur n'est pas dangereux en soi ; le danger réside dans la déréférence finale, que l'appelant encapsule. Ne pas libérer la mémoire entraîne une fuite, pas un problème de sécurité. (Alloc diffère également de malloc en ce qu'il lève une exception OutOfMemoryException en cas d'échec plutôt que de renvoyer null, de sorte que les appelants n'ont pas à vérifier la valeur renvoyée.) Free reste dangereux car il comporte de véritables conditions préalables : le pointeur doit être celui renvoyé par un allocateur compatible et ne pas avoir déjà été libéré, et rien d’autre ne peut être un alias de la mémoire. Le bloc <safety> rend ces obligations visibles pour tous les appelants et réviseurs.
Passons maintenant à un appelant. Voici une maquette du constructeur de FileVersionInfo sous le nouveau modèle. Le constructeur analyse un blob d'informations de version natif pour le convertir en champs de type chaîne et entier de cet objet (_companyName, _fileVersion, _fileMajor, etc.) ; l'allocation correspond simplement au tampon temporaire qui contient le blob pendant que GetVersionInfoForCodePage le lit.
Signature actuelle : private unsafe FileVersionInfo(string fileName). Elle est unsafe uniquement pour établir un contexte non sécurisé.
Voici la signature et l'implémentation mises à jour, accompagnées de commentaires internes sur la sécurité.
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | private FileVersionInfo(string fileName) { _fileName = fileName; uint infoSize = Interop.Version.GetFileVersionInfoSizeEx( Interop.Version.FileVersionInfoType.FILE_VER_GET_LOCALISED, _fileName, out _); if (infoSize != 0) { unsafe { // SAFETY: // - bounds: `infoSize` is the size returned by GetFileVersionInfoSizeEx // and is the same value passed to both Alloc and GetFileVersionInfoEx, // so all reads through `memPtr` stay within the allocated range. // - lifetime: `memPtr` is freed in the finally before this constructor // returns, and never escapes; every consumer (GetLanguageAndCodePage, // GetVersionInfoForCodePage) is called from within this method and // writes its results into this object's fields. void* memPtr = NativeMemory.Alloc(infoSize); try { if (Interop.Version.GetFileVersionInfoEx( /* flags */ default, _fileName, 0U, infoSize, memPtr)) { uint lcp = GetLanguageAndCodePage(memPtr); _ = GetVersionInfoForCodePage(memPtr, lcp.ToString("X8")) || (lcp != 0x040904B0 && GetVersionInfoForCodePage(memPtr, "040904B0")) || (lcp != 0x040904E4 && GetVersionInfoForCodePage(memPtr, "040904E4")) || (lcp != 0x04090000 && GetVersionInfoForCodePage(memPtr, "04090000")); } } finally { NativeMemory.Free(memPtr); } } } } |
Le constructeur constitue une limite de sécurité solide. Les autres risques de sécurité (les appels d'interopérabilité qui lisent via memPtr et l'appel à Free à la fin) sont gérés en ligne :
- Limites : une seule valeur infoSize est transmise depuis l'appel de requête de taille vers Alloc et vers chaque appel d'interopérabilité qui lit via memPtr ; les trois utilisations sont liées entre elles par leur nom, de sorte que les lectures restent dans la plage allouée.
- Durée de vie : le bloc try/finally garantit que Free s'exécute avant que le constructeur ne renvoie une valeur, même en cas d'exception provenant des appels d'interopérabilité. Le pointeur ne s'échappe jamais ; chaque aide qui l'utilise est appelée à l'intérieur de cette méthode, donc aucun alias ne survit au-delà de Free.
Pas de marqueur unsafe sur le constructeur, pas de bloc <safety> ; l'insécurité est entièrement confinée à l'intérieur du corps. Les constructeurs unsafe sont possibles dans le nouveau modèle (ils propagent l'obligation vers tout code qui instancie le type), mais celui-ci n'a pas besoin d'être unsafe.
Voici maintenant une maquette de FixedMemoryKeyBox :
| Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | internal sealed class FixedMemoryKeyBox : SafeHandle { /// <safety> /// Must equal the byte size of the allocation pointed to by <c>handle</c>. /// </safety> private readonly unsafe int _length; internal FixedMemoryKeyBox(ReadOnlySpan<byte> key) : base(IntPtr.Zero, ownsHandle: true) { void* memory; unsafe { // SAFETY: // - alloc: NativeMemory.Alloc returns a pointer to key.Length writable bytes. // - span: new Span<byte>(memory, key.Length) addresses exactly those bytes. // - lifetime: ownership of the pointer transfers to this SafeHandle // via SetHandle below; ReleaseHandle frees the allocation when // the ref-count reaches zero. // - _length: paired with the allocation made on this line. memory = NativeMemory.Alloc((nuint)key.Length); key.CopyTo(new Span<byte>(memory, key.Length)); _length = key.Length; } SetHandle((IntPtr)memory); } /// <safety> /// The returned span aliases storage owned by this SafeHandle. /// The caller must ensure: /// /// - the span is not used after this SafeHandle is disposed; /// - access is bracketed by <see cref="SafeHandle.DangerousAddRef"/> and /// <see cref="SafeHandle.DangerousRelease"/> (or equivalent), so /// disposal on another thread can't free the buffer mid-use. /// </safety> internal unsafe ReadOnlySpan<byte> DangerousKeySpan { get { unsafe { // SAFETY: // - bounds: `_length` matches the allocation made in the ctor. // - lifetime: NOT discharged here; propagated to the caller // via the <safety> block above. The `Dangerous` prefix // echoes that contract in the API name. return new ReadOnlySpan<byte>((void*)handle, _length); } } } internal TRet UseKey<TState, TRet>(TState state, Func<TState, ReadOnlySpan<byte>, TRet> func) { bool addedRef = false; unsafe { // SAFETY: AddRef holds the SafeHandle alive for the duration of // the callback, so `DangerousKeySpan` aliases live storage. The // span is not retained beyond `func`'s return. try { DangerousAddRef(ref addedRef); return func(state, DangerousKeySpan); } finally { if (addedRef) { DangerousRelease(); } } } } protected override bool ReleaseHandle() { unsafe { // SAFETY: SafeHandle's ref-counting guarantees no live span // aliases `handle` at this point; the new Span<byte>(handle, _length) // addresses the allocation made in the ctor. CryptographicOperations.ZeroMemory(new Span<byte>((void*)handle, _length)); NativeMemory.Free((void*)handle); } return true; } public override bool IsInvalid => handle == IntPtr.Zero; } |
FixedMemoryKeyBox regroupe deux limites dans un seul type, illustrant les deux sens :
- DangerousKeySpan est un appelant unsafe. Bounds est libéré en ligne par l'invariance du champ _length. L' int est sûr en soi ; le problème de sécurité réside dans son couplage avec handle : ils forment une paire qui doit correspondre. La durée de vie n'est pas gérée. Le span fait référence à la mémoire détenue par le SafeHandle, et cette mémoire survit à l'appel de la propriété par conception. Le bloc <safety> énonce deux obligations résiduelles : ne pas survivre au SafeHandle, et encadrer l'accès avec DangerousAddRef/Release. Le compilateur ne peut imposer aucune de ces deux obligations. Le marqueur indique aux appelants qu'ils ont du travail à faire.
- UseKey est la limite de sécurité construite par-dessus. Elle remplit l'obligation de durée de vie en encadrant le rappel avec DangerousAddRef/Release dans un try/finally. Le ReadOnlySpan<byte> passé à func est sûr en vertu des règles de durée de vie des ref struct. De l'extérieur, UseKey est sûr à appeler ; l'insécurité est scellée à l'intérieur de l'encadrement.
Distribution binaire
Les bibliothèques .NET sont souvent distribuées sous forme de binaires. Une bibliothèque populaire publiée sur nuget.org peut comporter zéro ou mille avertissements, mais vous savez qu’elle ne contient aucune erreur. Les erreurs sont l’un des rares aspects de la compilation qui sont communiqués de manière fiable entre le producteur et le consommateur.
Le mode unsafe de C# 16 s’appuie fortement sur les nouvelles erreurs de compilation. Opter pour le nouveau modèle signifie que le travail d’annotation a été effectué. Il sera simple d’inspecter un projet et de voir s’il utilise du code unsafe.
Swift, par exemple, s'appuie davantage sur les avertissements pour l'adoption de la sécurité mémoire. La charge de travail pour Swift est bien moindre puisque les dépendances sont distribuées sous forme de code source. Vous pouvez voir les erreurs et les avertissements des dépendances avec la même précision lors de la compilation. Rust dispose également de dépendances distribuées sous forme de code source, mais s'appuie fortement sur les erreurs.
Nous envisageons d'ajouter des badges sur nuget.org pour encourager l'adoption de la nouvelle mise en œuvre de la sécurité de la mémoire et pour faciliter la recherche des bibliothèques qui l'ont adoptée. Les bibliothèques et les paquets ayant adopté le modèle seront marqués en conséquence, ce qui facilitera l'inspection et la compréhension de l'état de sécurité de votre chaîne d'approvisionnement (tel que le compilateur le perçoit).
Il sera courant que des projets compilés avec l'ancien modèle utilisent des paquets construits avec le nouveau, et vice versa. Comme décrit dans la section « En bref », les deux sens sont asymétriques. Un projet ayant opté pour le nouveau modèle applique les règles du mode compat aux paquets hérités : tout type de pointeur dans une signature d’appelé nécessite un bloc unsafe { } englobant au site d’appel. Un projet hérité, en revanche, considère les paquets ayant opté pour le nouveau modèle comme des assemblages ordinaires et n’est soumis à aucun nouveau diagnostic. Cette asymétrie est délibérée. Le côté ayant opté pour le nouveau modèle apporte la garantie de sécurité, et le mode compat empêche cette garantie de se dégrader silencieusement lors de l’utilisation de code hérité.
Espace de conception restant
L'intention de notre projet est de déployer tous les aspects du nouveau modèle en une seule fois, à la fois parce qu'il n'est cohérent qu'en tant qu'ensemble, et pour éviter aux développeurs d'avoir à adopter une série progressive de changements rompant la compatibilité. Cependant, il existe quelques aspects de conception que nous n'avons pas pu traiter dans C# 16.
Le premier est la réflexion, qui constitue une exception dans le modèle. Le code peut appeler des API unsafe via MethodInfo.Invoke sans bloc unsafe englobant, et les écritures de réflexion peuvent enfreindre les invariants documentés sur les champs unsafe. Le code faisant un usage intensif de la réflexion doit être examiné pour détecter les appels d'API non sécurisés et les écritures qui contournent les contrats exprimés par le nouveau modèle. Nous aborderons peut-être l'utilisation de la réflexion dans une version ultérieure.
Le deuxième point concerne les durées de vie. Rust gère les durées de vie via son vérificateur d’emprunt ; nous n’envisageons pas de mettre en place un système omniprésent comme celui des emprunts. C# s’appuie sur un ramasse-miettes et un système de propriété basé sur les références pour couvrir en partie le même domaine. Nous envisageons un modèle de propriété ciblé, comme mentionné dans la section « Sécurité de la mémoire dans .NET ». Nous publierons ultérieurement des plans de conception à ce sujet.
Le principal cas d'utilisation d'une application plus stricte des durées de vie est ArrayPool, en particulier pour les méthodes Rent et Return. Le scénario clé consiste à renvoyer un tableau et à continuer de l'utiliser. Il s'agit d'une violation de type « utilisation après libération ». Il est facile de se tromper sur la convention, et nous avons commis cette erreur dans notre propre code. En revanche, une utilisation de Rent sans Return constitue une fuite et non une violation de la sécurité de la mémoire.
Analogies
La fonctionnalité « Caller-unsafe » invite à des analogies. La plupart ne tiennent pas la route à y regarder de plus près.
Affirmation — Les types de référence nullables sont similaires à « Caller-unsafe ». Les types de référence nullables nécessitent une inspection des méthodes et des mises à jour potentielles des signatures pour s’intégrer correctement au modèle. Ils transfèrent également la nullabilité de l’appelé vers l’appelant et incluent un mécanisme de suppression.
Réalité. Les types de référence nullables sont une préoccupation liée au site d'utilisation qui affecte le type d'une expression. Ils n'affectent en rien la nature de l'appelant. La suppression nullable (!) s'applique à une seule expression ; elle indique au compilateur que la valeur est non nulle à cet endroit. Avec unsafe, il n’y a pas de raccourci au niveau de l’expression ; chaque appel à un membre unsafe nécessite un bloc unsafe { } englobant. La suppression dans le modèle unsafe est une région délimitée et vérifiable, et non une annotation par valeur.
Affirmation — Async est similaire à caller-unsafe. Async se propage de méthode en méthode. Le mot-clé async force la propagation, tout comme unsafe. Task.Wait() est le mécanisme de suppression.
Réalité. Le mot-clé async s'apparente davantage à unsafe de C# 1.0 en ce qu'il établit un contexte async pour la méthode dans lequel await peut être utilisé ; le type de retour de la méthode (Task/ValueTask) est ce que voient les appelants. Le mécanisme de propagation est un type de retour attendable : le système de types lui-même. Task.Wait() force une transition de async vers sync, ce qui s'accompagne de compromis importants. Ce n’est pas un mécanisme de suppression formel.
Affirmation — Le @unsafe au niveau du type de Swift n’est autre que l’unsafe au niveau du type de C# 1.0. Les deux langages utilisent le mot-clé sur un type, donc la suppression de l’unsafe au niveau du type dans C# 16 semble abandonner quelque chose que Swift a conservé.
Réalité. Les deux marqueurs partagent un mot-clé et une cible, mais sont presque orthogonaux sur le plan sémantique. Le @unsafe de Swift sur un type est un contrat orienté vers l’appelant : toute déclaration utilisant ce type devient implicitement @unsafe, et les appelants doivent encapsuler les accès dans des expressions unsafe. L’unsafe de C# 1.0 sur un type relevait de la portée d’implémentation : il permettait aux corps des membres du type d’utiliser des pointeurs, mais ne propageait rien aux appelants. C# 16 supprime la forme de C# 1.0 car elle ne comportait aucune information pour l’appelant. Les deux diffèrent également par leur nature : le marqueur de Swift est non permissif, imposant une obligation aux appelants, tandis que celui de C# 1.0 était permissif, débloquant des capacités au sein des membres du type. La forme Swift est la plus axée sur la sécurité des deux. Interpréter le marqueur au niveau du type de Swift à travers le prisme de C# 1.0 est une erreur.
Activation de l'IA
Le modèle ajoute deux éléments qu'un agent ne peut ignorer : un graphe d'appel partitionné en méthodes sûres, unsafe et limites ; et un compilateur qui rejette les appels unsafe sans bloc unsafe englobant. Un analyseur émettra également des avertissements en cas d’absence de documentation <safety>. Chacun de ces éléments restreint le code qu’un agent peut générer tout en garantissant la réussite de la compilation, en particulier si TreatWarningsAsErrors est activé. Un agent générant du code pour MemoryMarshal.ReadByte doit soit propager unsafe vers le haut jusqu’à son appelant, soit la supprimer à l’aide de gardes à la limite.
Les documents <safety> font office d’instructions spécifiques à chaque API. Malgré cela, un agent de génération de code peut parfois omettre une condition de sécurité, sans que le compilateur ne s’en aperçoive. La limite informative reste toutefois utile : elle indique à un humain ou à un agent de révision de code exactement où les conditions de sécurité doivent être placées et ce qu’elles doivent protéger. Le même principe s’applique aux types de référence nullables et aux analyseurs AOT : des grammaires plus strictes réduisent l’espace de recherche, et la sortie du modèle s’adapte en conséquence.
Il existe deux moyens principaux par lesquels les agents peuvent contourner le modèle :
- Générer du code qui ne compile pas.
- Revenir à l'ancien modèle pour le projet et/ou activer AllowUnsafeBlocks. Cela s'apparente aux cas où les agents souhaitent parfois désactiver TreatWarningsAsErrors ou IsAotCompatible.
Ces deux catégories sont faciles à détecter lors de la révision du code ou à identifier dans l'historique Git. « Plus facile à détecter lors de la révision du code » est le slogan de toute cette initiative.
La migration vers le nouveau modèle convient également bien aux agents. Migrer le code existant vers le modèle et écrire du nouveau code dans le cadre de ce modèle ne sont pas vraiment des activités différentes. Une fois que le premier ensemble d’API de la bibliothèque d’exécution .NET aura été migré, la conformité deviendra une tâche uniforme pour l’ancien et le nouveau code.
Les modèles bien établis en Rust (unsafe fn, unsafe {}) se transposent clairement dans le code de type C#. Les agents peuvent effectuer une correspondance de modèles sur les corpus existants (std de Rust, bibliothèque standard de Swift) et sur les bibliothèques d'exécution .NET au fur et à mesure de leur migration. On peut soutenir que la correspondance de modèles la plus utile concerne la structure et les idiomes de la documentation de sécurité. Cet aspect de la migration serait le plus difficile à transformer en compétences. Comme indiqué précédemment, la documentation de sécurité est l'aspect le plus critique du nouveau modèle.
Des recherches connexes aboutissent aux mêmes conclusions :
- CRUST-Bench : A Comprehensive Benchmark for C-to-safe-Rust Transpilation (Khatry et al., COLM ’25) a montré que les agents bénéficiant du retour d’information du compilateur doublent approximativement le taux de réussite de la génération en une seule passe lors de la traduction de dépôts C vers Safe Rust.
- Gorilla: Large Language Model Connected with Massive APIs (Patil et al., NeurIPS ’24) a montré que les grands modèles de langage (LLM) disposant d’une documentation API accessible appellent les API de manière plus fiable et produisent moins d’erreurs que les modèles de référence sans assistance.
- Do Users Write More Insecure Code with AI Assistants? (Perry et al., CCS ’23) a révélé que les développeurs utilisant des assistants de codage IA produisaient un code nettement moins sûr qu’un groupe témoin sans assistance, tout en jugeant leur propre production plus sûre. C’est l’écart que l’application de la sécurité au niveau du langage est censée combler.
- Type-Constrained Code Generation with Language Models (Mündler et al., PLDI ’25) a montré que l’intégration des contraintes du système de types dans le décodage des LLM (plutôt que de s’appuyer sur le retour d’information a posteriori du compilateur) réduit de plus de moitié les erreurs de compilation et améliore la correction fonctionnelle à travers la synthèse, la traduction et la réparation. Des règles linguistiques plus riches façonnent la génération, et ne se contentent pas de la valider.
- MultiPL-E: A Scalable and Extensible Approach to Benchmarking Neural Code Generation (Cassano et al., TSE ’23) a montré que les performances de génération de code des LLM dépendent de la proximité syntaxique avec des langages riches en ressources, et pas seulement du volume d'entraînement dans le langage cible. C'est ce levier qui permet au corpus unsafe fn / unsafe {} de Rust de se transposer en code de type C#.
- LLM Assistance for Memory Safety (Rastogi et al., ICSE ’25) s’est attaqué à la version « migration » de ce problème : déduire les annotations au niveau source nécessaires pour adapter le C hérité au dialecte sécurisé Checked C. Leur outil a déduit 86 % des annotations que les outils symboliques ne pouvaient pas fournir, sur des bases de code réelles comptant jusqu’à 20 000 lignes de code. C'est exactement le même type de travail (nommer les contraintes sur lesquelles le code existant s'appuie déjà implicitement) que nécessitera la migration vers le nouveau modèle C#.
Conclusion
Le nouveau modèle superpose un ensemble de modifications de compatibilité (opt-in) au code qui utilise actuellement unsafe : unsafe sur une signature de membre définit un contrat face à l'appelant, un bloc unsafe est requis à chaque appel à un membre unsafe, et chaque membre unsafe doit comporter un bloc /// <safety>. Trois modifications mineures complètent le modèle : le modificateur de type unsafe devient une erreur, le nouveau mot-clé safe marque les déclarations extern dont le compilateur ne peut pas déterminer la sécurité par lui-même, et les types de pointeurs dans les signatures ne propagent plus l'insécurité de leur propre chef.
Nous envisageons un avenir où C# figurera parmi un ensemble de langages choisis et reconnus pour leur application de la sécurité des types et de la mémoire. Avec ce changement de modèle, C#, Rust et Swift partagent un vocabulaire et un workflow de sécurité plus communs. Nous imaginons des équipes adoptant une vision complète de la chaîne d’approvisionnement de leurs dépendances, qu’il s’agisse de C# de bout en bout ou de C# au niveau de la couche application par-dessus Rust au niveau de la couche système. Notre propre équipe a migré de grands blocs de C++ vers C# au fil des ans précisément pour cette raison : le C# sécurisé ne s’accompagne pas d’une charge de travail liée à la vérification de la sécurité de la mémoire.
Une fois qu'une équipe aura migré une partie de sa base de code vers le nouveau modèle de sécurité, elle sera probablement plus motivée à tout migrer, y compris ses dépendances. Cela pourrait s'avérer plus facile qu'il n'y paraît pour de nombreux studios de développement. Le nouveau modèle conserve C# en grande partie tel quel et modifie les modèles unsafe que la plupart des développeurs n'utilisent pas, tout en améliorant considérablement les capacités et la posture globales du langage en matière de sécurité. Nous pensons que cette fonctionnalité figure parmi les changements les plus efficaces que nous puissions apporter pour renforcer la confiance des développeurs dans cette nouvelle ère du codage.
Ce projet bénéficie des contributions de : Andy Gocke, Egor Bogatov, Fred Silberberg, Jan Jones, Jan Kotas, Julien Couvreur, Mads Torgersen, Rich Lander, Tanner Gooding et d’autres.
Source : Improving C# Memory Safety
Et vous ?
Pensez-vous que cette présentation est crédible ou pertinente ?
Quel est votre avis sur le sujet ?Voir aussi :
Découvrez les types union dans C# 15, par Bill Wagner
Exploration des membres d'extension en C# 14, préservation de l'énorme quantité de méthodes d'extension paramètre "This" existantes tout en introduisant de nouveaux types
Microsoft présente les nouvelles fonctionnalités du langage de programmation de C# 14 : Membres d'extension, affectation par condition nulle, propriétés field stockées
Vous avez lu gratuitement 2 995 articles depuis plus d'un an.
Soutenez le club developpez.com en souscrivant un abonnement pour que nous puissions continuer à vous proposer des publications.
Soutenez le club developpez.com en souscrivant un abonnement pour que nous puissions continuer à vous proposer des publications.