Obfusquer un générateur de source Roslyn : pourquoi le générateur se renomme librement alors que l'attribut et le constructeur public sont figés
Un générateur de source est un objet étrange à obfusquer : c'est un assembly .NET Standard 2.0 que le compilateur charge par réflexion et exécute à l'intérieur du build de votre client, et c'est là que réside votre logique de compilation la plus précieuse — précisément le code que vous livrez et que vous préféreriez ne pas confier à un décompilateur. La bonne nouvelle, c'est que Roslyn découvre les générateurs par attribut, et non par nom, si bien que Nebula peut renommer le type du générateur et toute sa surface privée sans aucun effet sur la découverte. Les parties figées sont peu nombreuses et bien précises : l'attribut [Generator] doit survivre, le constructeur public sans paramètre doit rester public (le compilateur l'instancie avec Activator.CreateInstance), et la méthode d'interface qu'il appelle doit conserver son emplacement. Cet article lit ce sur quoi le GeneratorDriver réfléchit réellement, trace la frontière entre ce qui se renomme et ce qui est porteur, montre la configuration Nebula, et dissipe le contresens le plus fréquent — obfusquer le générateur n'obfusque pas le code qu'il émet.
Un générateur de source est une chose délicate à protéger. C’est un assembly .NET Standard 2.0, il est chargé par réflexion, il s’exécute à l’intérieur du build de votre client plutôt que dans votre propre application, et il a tendance à contenir une partie de la logique de compilation la plus précieuse que vous livrez — l’analyse, les modèles, les décisions qui font que le générateur vaut la peine d’être vendu. « Puis-je l’obfusquer ? » est donc une question raisonnable, et la réponse est oui, avec une liste agréablement courte de points de vigilance.
Si la liste est courte, c’est parce que Roslyn trouve les générateurs par attribut, et non par nom. Ce seul fait signifie que Nebula peut renommer la quasi-totalité de l’assembly — la classe du générateur, son espace de noms, chaque assistant privé — et que le compilateur le trouve et l’exécute toujours. Cet article trace la frontière exacte entre ce qui se renomme librement et ce qui est porteur.
Ce sur quoi le compilateur réfléchit réellement
Voici un générateur incrémental minimal, dans la forme que vous livrez :
using Microsoft.CodeAnalysis;
namespace Acme.Generators;
[Generator(LanguageNames.CSharp)]
public sealed class JsonContextGenerator : IIncrementalGenerator
{
// Public, parameterless — the compiler calls Activator.CreateInstance on it.
public JsonContextGenerator() { }
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var models = context.SyntaxProvider
.ForAttributeWithMetadataName(
"Acme.JsonSerializableAttribute",
predicate: static (_, _) => true,
transform: static (ctx, _) => BuildModel(ctx))
.Collect();
context.RegisterSourceOutput(models, static (spc, items) =>
{
foreach (var m in items)
spc.AddSource($"{m.TypeName}.g.cs", Emit(m)); // hintName + source text
});
}
private static Model BuildModel(GeneratorAttributeSyntaxContext ctx) { /* ... */ }
private static string Emit(Model m) { /* ... the template ... */ }
}
Lorsque le compilateur construit un projet qui référence cet analyseur, le GeneratorDriver fait quelque chose de très précis : il charge l’assembly, énumère ses types et conserve ceux décorés de GeneratorAttribute. Pour chacun, il appelle Activator.CreateInstance(type) afin d’obtenir une instance, puis invoque Initialize. Remarquez ce qui n’apparaît pas dans cette séquence : le nom du type. Personne — ni le pilote, ni le .csproj du consommateur, ni les métadonnées NuGet — ne recherche jamais le générateur sous le nom JsonContextGenerator. Il est trouvé par son attribut et instancié par réflexion.
Ce qui se renomme librement
Comme la découverte se fait par attribut, le nom de classe du générateur JsonContextGenerator est un poids mort pour le monde extérieur — renommez-le en a et personne ne s’en aperçoit. Il en va de même pour l’espace de noms Acme.Generators, les méthodes statiques privées BuildModel et Emit, le type Model et ses champs, et tous les assistants internes utilisés par la logique de modèle. C’est là que réside la valeur, et tout cela est permis. En pratique, la grande majorité d’un assembly de générateur s’obfusque exactement comme n’importe quelle autre bibliothèque.
Ce qui est figé, et pourquoi
Trois choses sont porteuses, et toutes trois concernent le chargement et l’appel du générateur, non son nommage.
L’attribut [Generator] doit survivre sur le type. C’est le marqueur que le pilote recherche ; si votre renommeur ou une passe de suppression des attributs personnalisés le retire, le générateur n’est tout simplement jamais découvert et votre build ne produit discrètement rien — aucune erreur, juste une sortie manquante, ce qui est un bogue pénible à traquer. Nebula ne supprime pas les attributs qu’il reconnaît comme porteurs de sens à l’extérieur, mais c’est celui-ci qu’il convient de déclarer explicitement dans la configuration.
Le constructeur public sans paramètre doit rester public et sans paramètre. Le pilote appelle Activator.CreateInstance(type), qui a besoin d’un constructeur public nullaire. Si l’obfuscation venait à le rendre privé, ou si vous lui ajoutez un paramètre, l’instanciation lève une exception au chargement du générateur. Gardez-le public.
La méthode Initialize est une implémentation d’interface (IIncrementalGenerator.Initialize), de sorte que le renommage préserve automatiquement son emplacement de table virtuelle — le pilote l’appelle via l’interface, pas par son nom. La seule règle : ne la supprimez pas et ne changez pas sa signature.
Un dernier point, facile à négliger : si votre générateur signale aussi des diagnostics, les chaînes d’ID des DiagnosticDescriptor (« ACME001 ») sont un contrat public — les consommateurs les suppriment dans .editorconfig avec dotnet_diagnostic.ACME001.severity. Le chiffrement des chaînes ne pose pas de problème (la valeur est reconstruite à l’exécution, le diagnostic émis porte donc toujours le bon ID), mais la valeur ne doit pas changer. Ne « rangez » pas un ID.
La configuration
En termes Nebula, c’est un profil de bibliothèque essentiellement par défaut, avec quelques garde-fous :
{
"rename": { "enabled": true, "autoDetect": true },
"strings": { "encrypt": true },
"preserve": [
{ "attribute": "Microsoft.CodeAnalysis.GeneratorAttribute" },
{ "member": "*..ctor", "where": "public && parameterless" },
{ "interfaceImpl": "Microsoft.CodeAnalysis.IIncrementalGenerator" }
],
"controlFlow": { "enabled": true, "exclude": [ "*Initialize*" ] }
}
autoDetect garde déjà les implémentations d’interfaces externes appelables ; les entrées preserve explicites sont une ceinture et des bretelles pour l’attribut et le constructeur, qui sont les deux modes de défaillance produisant une casse silencieuse plutôt qu’un plantage. Exclure Initialize de l’aplatissement du flux de contrôle est un choix de performance, pas de correction — elle s’exécute à chaque build dans l’IDE, gardez donc son chemin léger.
Le point que les gens prennent à l’envers
Obfusquer le générateur n’obfusque pas le code que le générateur émet. Le texte source que renvoie votre méthode Emit devient partie intégrante de la compilation de votre client ; il est compilé dans son assembly et ressemble à ce que vous avez écrit dans le modèle, quelle que soit la façon dont votre assembly de générateur est obfusqué. Les deux vivent de part et d’autre du build :
- Votre assembly de générateur est livré dans votre NuGet, s’exécute dans le compilateur du client, et c’est lui que vous obfusquez pour protéger la façon dont le générateur fonctionne.
- La source émise est désormais le code du client ; s’il veut la protéger, c’est lui qui obfusque sa sortie.
Confondre les deux amène à s’attendre à ce qu’un générateur protégé produise du code généré protégé, puis à ouvrir un bogue « le code généré est toujours lisible » qui n’en est pas un. Vous cachez la machine, pas ce qu’elle estampe.
À retenir
Un générateur de source Roslyn s’obfusque bien précisément parce que le compilateur le charge comme il le fait : en recherchant l’attribut [Generator] et en instanciant le type par réflexion, jamais par son nom. Cela vous offre une couverture de renommage gratuite sur l’ensemble du générateur — classe, espace de noms, assistants, modèles — c’est-à-dire là où vit réellement votre logique de compilation. L’ensemble figé est minuscule et concerne uniquement le chemin de chargement : gardez l’attribut, gardez le constructeur public nullaire, gardez l’emplacement Initialize et les valeurs d’ID de diagnostic. Tenez les transformations lourdes à l’écart du chemin chaud exécuté à chaque build, rappelez-vous que vous protégez le générateur et non le code qu’il écrit, et vérifiez en compilant un vrai projet consommateur contre l’analyseur obfusqué plutôt qu’en chargeant la DLL isolément.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.