Contact salesFree trial
Blog

Présentation de Vinego à Upsun

VinegoGo
Partager

Vinego est un nouvel ensemble d'analyseurs Go liés à la sécurité que nous utilisons en interne. Si vous n'êtes pas familier avec la scène de linting Go, Go fournit des interfaces officielles pour développer des analyseurs de code qui forment la base des frameworks de linter communautaires et des bundles comme golangci-lint.

Ce qui rend les nouveaux linters de Vinego uniques, c'est qu'ils se concentrent sur le rejet de certains idiomes en faveur de modèles alternatifs non idiomatiques mais plus explicites et, espérons-le, plus sûrs. Voyons cela de plus près.

Les valeurs zéro et le linter varinit

Lesvaleurs zéro font référence au fait qu'en Go, toutes les variables ont des valeurs valides même si elles ne sont pas explicitement initialisées, y comprisles chaînes de caractères (la chaîne vide) et les structures (une structure dont tous les champs ont des valeurs zéro). Fondamentalement, les deux cas suivants sont équivalents :

var a int
var a int = 0

Que se passe-t-il si une variable doit être explicitement initialisée ? Dans la philosophie générale de Go (ne me citez pas), vous devriez soit :

  1. Écrire un code qui traite la valeur zéro comme une sentinelle (en supposant que 0 signifie non initialisé).
  2. Travailler de manière à ce que toutes les valeurs produisent un comportement correct (les valeurs zéro sont gérées naturellement par l'implémentation choisie).

Mais parfois, ce n'est pas possible. Par exemple, considérons ce calcul :

var desiredInstances uint32 if using_process1 { desiredInstances = process1(currentInstances, 45) } else { process2() } adjustInstances(desiredInstances)

Imaginez qu'il s'agit d'un vrai code avec un ensemble complexe de branches déterminant l'algorithme utilisé pour calculer la valeur de desiredInstances et non d'un exemple de jouet trop abstrait.

La branche else du code ci-dessus oublie de définir la valeur de desiredInstances, de sorte que lorsqu'elle est utilisée, elle se termine par desiredInstances == 0 et adjustInstances provoque un arrêt inattendu.

Le compilateur Go ne fera pas d'erreur sur ce code car, de son point de vue, var desiredInstances int est complètement initialisé, et si la valeur zéro pose un problème, c'est parce que le code n'est pas idiomatique en Go. Mais dans cet exemple, le domaine de desiredInstances est la gamme complète de uint32, il n'y a donc pas de valeur que nous pouvons utiliser comme sentinelle de désinitialisation, et toute valeur par défaut pourrait causer un ajustement inattendu, il n'y a donc pas de valeur par défaut sûre.

Une façon de contourner ce problème est de changer la déclaration en var desiredInstances *int, augmentant ainsi le nombre de valeurs disponibles d'une unité (en ajoutant nil) que nous pouvons utiliser comme sentinelle. Toutes les utilisations doivent être déréférencées en premier(*desiredInstances), mais cela nous permettra de paniquer au lieu de détruire accidentellement toutes nos instances si nous manquons une branche, ce qui est une amélioration.

Paniquer n'est toujours pas une bonne chose. Cela finit par être une sauvegarde à l'exécution contre quelque chose qui est statiquement incorrect. (Notez que même si nous pouvions utiliser 0 comme sentinelle de désinitialisation, il s'agirait toujours d'une sauvegarde à l'exécution).

La solution de Vinego

Vinego fournit le linter varinit pour ce scénario. Le linter varinit rejette l'idiome de la valeur zéro et considère les initialisations zéro implicites comme var x int comme étant réellement non initialisées, et le rapport lit de telles valeurs comme incorrectes.

var x int if something() { x = 4 } else { somethingElse() } consume(x)

L'exemple ci-dessus signale que x dans consume(x) n'a pas été initialisé dans la branche else ci-dessus. Pour ce faire, il suit les variables à travers le graphe de flux de contrôle du code et enregistre les ruptures de chemin avec un statut d'initialisation mixte.

Cela vous permet d'effectuer sans crainte des branchements et des initialisations complexes, des calculs, etc., sans avoir à vous soucier des valeurs zéro inattendues qui se faufilent et à concevoir votre code de manière à détecter et à atténuer les dommages dans de tels scénarios.

Valeurs zéro littérales de la structure et allfields

Mais il existe une autre source de valeurs nulles : les littéraux de structure.

Chaque fois que vous spécifiez partiellement une structure avec un littéral de structure, Go initialisera tous les champs non spécifiés. Par exemple, pendant que varinit vérifie que x est initialisé :

type MyStruct struct { Important int } var x MyStruct x = MyStruct{}

x.Important n'est pas pris en compte et reçoit une valeur nulle.

La solution de Vinego

allfields introduit une nouvelle annotation que vous pouvez utiliser pour indiquer au linter que tous les champs doivent être explicitement initialisés dans le littéral :

// check:allfields type MyStruct struct { Important int } x := MyStruct{}

Ce qui précède provoquera une erreur du linter qui communiquera que Important n'a pas été explicitement initialisé. Vous pouvez marquer les champs comme optionnels (permettant une initialisation implicite à valeur nulle) comme suit :

// check:allfields type MyStruct struct { Unimportant int `optional : "1"` }

Entre varinit et allfields, vous ne devriez plus avoir à vous soucier des bogues à valeur nulle !

Se souvenir de gérer les erreurs avec le captureur lint

Il y a déjà un linter(staticcheck) dans golangci-lint qui vérifie que toutes les erreurssont consommées, mais le linter abandonne l'analyse aux frontières des fonctions.

Ainsi, si vous capturez accidentellement un err dans une portée extérieure, l'analyse cesse silencieusement de vous alerter sur le mauvais code:

x, err := operation1() if err != nil { ... } go func() { err = operation2() // pas d'erreur ! }

Dans l'exemple ci-dessus, err n'est jamais utilisé dans func() mais staticcheck ne le détectera pas car err a été déclaré à l'extérieur.

La solution de Vinego

Ce que capturederr fait dans des cas comme celui-ci est simplement de marquer toutes les utilisations de variables de type erreur dans les définitions de fonctions locales où la variable d'erreur a été capturée à partir de la fonction extérieure plutôt que déclarée localement. Dans la grande majorité des cas, il s'agit clairement d'un mauvais comportement- les erreurs doivent être gérées pour interrompre l'exécution localement avant que le code dépendant ne s'exécute, et la plupart des callbacks ont un retour d'erreur.

Dans cet exemple, vous obtiendrez une erreur indiquant que err est capturé dans go func() .... Si vous corrigez cette erreur en la remplaçant par err := operation2(), staticcheck recommencera à fonctionner et s'assurera que vous gérez correctement l'erreur.

Confusions accidentelles de types et l'indice explicite (explicitcast lint)

Il existe un paradigme "newtype" qui consiste à utiliser des enveloppes de types minces autour de types existants, comme par exemple

type Mebibyte uint64 type Gibibyte uint64

Ces types fournissent une meilleure documentation que l'utilisation directe de uint64 dans les déclarations de variables, les signatures de types de fonctions et les définitions de structures, puisque l'unité est clairement communiquée. Idéalement, ils devraient également faire l'objet d'une vérification stricte afin d'éviter de passer accidentellement Mebibyte là où Gibibyte est attendu et vice-versa.

Dans certaines circonstances, go vous donnera une erreur si vous utilisez le mauvais type, par exemple :

func Resize(newSize Mebibyte) { ... } newSize := 4 Resize(newSize) // erreur !

Cependant, dans d'autres situations, Go convertit implicitement le type, comme le montre l'exemple suivant :

func ResizeAfterDelay(newSize Mebibyte, delay time.Duration) { ... } ResizeAfterDelay(3600, 256) // Oups ! Mélange du délai et de la taille, le délai n'a pas la bonne unité, pas d'erreur.

Cela ne tient pas compte de la sécurité supplémentaire offerte par les nouveaux types uniques.

La façon dont Go distingue ces scénarios est liée aux valeurs littérales. Si vous n'êtes pas familier avec les valeurs littérales, sachez qu'il s'agit de la partie de la syntaxe qui introduit des valeurs non calculées : 43, true, "tout dans cet article est un mensonge !", MyStruct{}.

Ces valeurs n'ont pas encore de types concrets. Si 43 est un entier, il pourrait s'agir d'un int32 ou d'un uint64 ou de tout autre type d'int, par exemple.

Lorsque vous utilisez un littéral dans une expression, que vous l'affectez à une variable, que vous le renvoyez ou que vous le passez en tant qu'argument d'une fonction, Go lui attribue un type à partir du contexte.

Dans l'exemple ci-dessus, Mebibyte et time.Duration sont des nouveaux types sur des entiers primitifs, donc 3600 prend le type Mebibyte et 256 time.Duration. Lorsque nous avons fait newSize := 4, comme il n'y a pas d'information explicite sur le type contextuel, Go a utilisé le type entier par défaut int (les variables ont toujours des types concrets).

La solution de Vinego

Le lint explicitcast détecte les littéraux qui sont typés contextuellement et les signale, nécessitant un cast explicite pour l'apaiser :

ResizeAt(time.Seconds * 3600, Mebibyte(256))

Cela expose alors les problèmes de sécurité de type (mauvaise unité de temps, transport d'argument) dans ce qui précède - vous obtiendrez maintenant une erreur disant que le premier argument devrait être Mebibyte et le second Duration.

L'indice loopvariableref

Avec un mauvais timing, je vous présente le dernier lint : loopvariableref.

Si vous avez suivi les annonces de versions de Go, vous savez que Go 1.22 corrige le problème à la racine et que ce linter n'est donc plus nécessaire, mais il peut toujours être utile pour les bases de code qui ne peuvent pas mettre à jour pour une raison ou une autre (peut-être ?).

Ce linter vérifie et signale les références ou les captures de variables de boucle qui permettent à la durée de vie de la variable de boucle de dépasser la boucle et d'avoir un comportement inattendu.

Mises en garde

Aucun de ces linters n'a un signal parfait. Par exemple, varinit suppose le pire des cas en présence de captures et de goroutines, puisque dans ces cas l'ordre d'initialisation peut changer à l'exécution. Vous pouvez donc avoir un code parfaitement valide (par exemple, en utilisant un WaitGroup pour s'assurer qu'une goroutine se termine avant d'utiliser une variable), mais le linter le signalera quand même.

De plus, certains, comme capturederr, peuvent signaler du code légitime et nécessiter un effort supplémentaire pour le contourner : par exemple, si vous avez besoin de passer l'erreur verbatim d'une opération dans un callback out et que d'autres mécanismes ne sont pas fournis.

Cela dit, ces mécanismes devraient permettre de combler des lacunes assez importantes en matière de sécurité et d'obtenir de meilleures garanties de base. Et les solutions de contournement, lorsqu'elles sont nécessaires, ne sont pas trop onéreuses.

Allez le Git

Vous pouvez l'obtenir sur https://github.com/upsun/vinego - leREADME contient les instructions d'installation et d'utilisation ainsi que plus de détails sur chaque linter.

Il est distribué sous la forme d'une image docker contenant l'intégralité de golangci-lint en tant que solution complète de construction de Go, ou vous pouvez le construire vous-même.

Pour l'instant, gardez l'œil ouvert pour d'autres articles de blog sur l'architecture de l'analyseur Go, les trucs et astuces pour travailler avec les interfaces et golangci-lint, et comment les linters Vinego effectuent leurs analyses.

Votre meilleur travail
est à l'horizon

Essai gratuit
Discord
© 2025 Platform.sh. All rights reserved.