Einen Roslyn-Source-Generator obfuskieren: Warum der Generator frei umbenannt wird, das Attribut und der öffentliche Konstruktor aber fixiert bleiben
Ein Source-Generator ist ein seltsames Objekt für die Obfuskierung: eine .NET-Standard-2.0-Assembly, die der Compiler per Reflection lädt und innerhalb des Builds Ihres Kunden ausführt – und zugleich der Ort, an dem Ihre wertvollste Compile-Zeit-Logik lebt, genau der Code also, den Sie ausliefern und lieber keinem Decompiler überlassen würden. Die gute Nachricht: Roslyn findet Generatoren über ein Attribut, nicht über ihren Namen, sodass Nebula den Generatortyp und seine gesamte private Oberfläche umbenennen kann, ohne dass die Erkennung davon berührt wird. Die fixierten Teile sind klein und klar umrissen: Das [Generator]-Attribut muss erhalten bleiben, der öffentliche parameterlose Konstruktor muss öffentlich bleiben (der Compiler instanziiert ihn mit Activator.CreateInstance), und die Interface-Methode, die er aufruft, muss ihren Slot behalten. Dieser Beitrag liest nach, worauf der GeneratorDriver tatsächlich reflektiert, zieht die Grenze zwischen dem, was umbenannt werden darf, und dem, was tragend ist, zeigt die Nebula-Konfiguration und räumt mit dem einen Missverständnis auf, das viele verkehrt herum haben – den Generator zu obfuskieren obfuskiert nicht den Code, den er erzeugt.
Ein Source-Generator ist ein sperriges Objekt, wenn es um Schutz geht. Er ist eine .NET-Standard-2.0-Assembly, er wird per Reflection geladen, er läuft innerhalb des Builds Ihres Kunden statt innerhalb Ihrer eigenen App, und er enthält in der Regel einen Teil der wertvollsten Compile-Zeit-Logik, die Sie ausliefern – die Analyse, die Templates, die Entscheidungen, die den Generator überhaupt erst verkaufbar machen. „Kann ich ihn obfuskieren?“ ist also eine berechtigte Frage, und die Antwort lautet ja, mit einer erfreulich kurzen Liste von Dingen, auf die man achten muss.
Der Grund dafür, dass die Liste kurz ist: Roslyn findet Generatoren über ein Attribut, nicht über ihren Namen. Diese eine Tatsache bedeutet, dass Nebula nahezu die gesamte Assembly umbenennen kann – die Generatorklasse, ihren Namespace, jeden privaten Helfer – und der Compiler sie trotzdem findet und ausführt. Dieser Beitrag zieht die exakte Grenze zwischen dem, was frei umbenannt werden darf, und dem, was tragend ist.
Worauf der Compiler tatsächlich reflektiert
Hier ein minimaler inkrementeller Generator, in der Form, in der Sie ihn ausliefern:
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 ... */ }
}
Wenn der Compiler ein Projekt baut, das diesen Analyzer referenziert, tut der GeneratorDriver etwas sehr Spezifisches: Er lädt die Assembly, enumeriert ihre Typen und behält diejenigen, die mit GeneratorAttribute dekoriert sind. Für jeden davon ruft er Activator.CreateInstance(type) auf, um eine Instanz zu erhalten, und ruft dann Initialize auf. Beachten Sie, was in dieser Sequenz nicht vorkommt: der Name des Typs. Niemand – weder der Driver noch die .csproj des Konsumenten noch die NuGet-Metadaten – schlägt den Generator jemals unter JsonContextGenerator nach. Er wird über sein Attribut gefunden und per Reflection instanziiert.
Was frei umbenannt werden darf
Weil die Erkennung über das Attribut läuft, ist der Klassenname JsonContextGenerator für die Außenwelt totes Gewicht – benennen Sie ihn in a um, und niemand merkt etwas. Dasselbe gilt für den Namespace Acme.Generators, die privaten statischen Methoden BuildModel und Emit, den Typ Model samt seiner Felder und alle internen Helfer, die die Template-Logik verwendet. Hier steckt der Wert, und all das ist Freiwild. In der Praxis obfuskiert sich der große Großteil einer Generator-Assembly genau wie jede andere Bibliothek.
Was fixiert ist – und warum
Drei Dinge sind tragend, und alle drei betreffen das Laden und Aufrufen des Generators, nicht seine Benennung.
Das [Generator]-Attribut muss auf dem Typ erhalten bleiben. Es ist der Marker, nach dem der Driver sucht; entfernt Ihr Renamer oder ein Durchlauf zum Entfernen benutzerdefinierter Attribute es, wird der Generator schlicht nie entdeckt, und Ihr Build erzeugt stillschweigend nichts – kein Fehler, nur fehlende Ausgabe, ein elender Bug bei der Fehlersuche. Nebula entfernt keine Attribute, die es als extern bedeutsam erkennt, aber dieses ist dasjenige, bei dem man in der Konfiguration explizit sein sollte.
Der öffentliche parameterlose Konstruktor muss öffentlich und parameterlos bleiben. Der Driver ruft Activator.CreateInstance(type) auf, was einen nullären public Ctor braucht. Würde die Obfuskierung ihn privat machen oder fügten Sie einen Parameter hinzu, wirft die Instanziierung beim Laden des Generators eine Exception. Lassen Sie ihn public.
Die Methode Initialize ist eine Interface-Implementierung (IIncrementalGenerator.Initialize), sodass das Umbenennen ihren V-Table-Slot automatisch bewahrt – der Driver ruft sie über das Interface auf, nicht über den Namen. Die einzige Regel lautet: nicht entfernen und die Signatur nicht ändern.
Noch eines, leicht zu übersehen: Meldet Ihr Generator auch Diagnosen, sind die ID-Strings der DiagnosticDescriptor („ACME001“) ein öffentlicher Vertrag – Konsumenten unterdrücken sie in .editorconfig mit dotnet_diagnostic.ACME001.severity. String-Verschlüsselung ist in Ordnung (der Wert wird zur Laufzeit rekonstruiert, sodass die ausgegebene Diagnose weiterhin die richtige ID trägt), aber der Wert darf sich nicht ändern. „Räumen“ Sie keine ID auf.
Die Konfiguration
In Nebula-Begriffen ist das ein weitgehend standardmäßiges Bibliotheksprofil mit ein paar Absicherungen:
{
"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 hält externe Interface-Implementierungen bereits aufrufbar; die expliziten preserve-Einträge sind doppelter Boden für das Attribut und den Konstruktor – die beiden Fehlerfälle, die stillen Bruch statt eines Absturzes erzeugen. Initialize von der Control-Flow-Abflachung auszunehmen ist eine Performance-Entscheidung, keine Korrektheitsfrage – die Methode läuft bei jedem Build in der IDE, also halten Sie ihren Pfad schlank.
Das, was viele verkehrt herum haben
Den Generator zu obfuskieren obfuskiert nicht den Code, den der Generator ausgibt. Der Quelltext, den Ihre Emit-Methode zurückgibt, wird Teil der Kompilierung Ihres Kunden; er wird in dessen Assembly kompiliert und sieht genau so aus, wie Sie ihn im Template geschrieben haben – unabhängig davon, wie Ihre Generator-Assembly obfuskiert ist. Die beiden leben auf entgegengesetzten Seiten des Builds:
- Ihre Generator-Assembly wird in Ihrem NuGet ausgeliefert, läuft im Compiler des Kunden und ist das, was Sie obfuskieren, um zu schützen, wie der Generator arbeitet.
- Der erzeugte Quelltext ist jetzt der Code des Kunden; will er ihn geschützt haben, obfuskiert er seine Ausgabe.
Wer beides verwechselt, erwartet, dass ein geschützter Generator geschützten generierten Code liefert, und meldet dann einen Bug „der generierte Code ist immer noch lesbar“, der keiner ist. Sie verbergen die Maschine, nicht das, was sie ausstanzt.
Das Fazit
Ein Roslyn-Source-Generator lässt sich gerade deshalb gut obfuskieren, weil der Compiler ihn so lädt, wie er es tut: durch das Scannen nach dem [Generator]-Attribut und das Instanziieren des Typs per Reflection, niemals über den Namen. Das verschafft Ihnen kostenlose Umbenennungsabdeckung über den gesamten Generator – Klasse, Namespace, Helfer, Templates –, also genau dort, wo Ihre Compile-Zeit-Logik tatsächlich lebt. Die fixierte Menge ist winzig und dreht sich ausschließlich um den Ladepfad: Behalten Sie das Attribut, behalten Sie den öffentlichen nullären Konstruktor, behalten Sie den Initialize-Slot und alle Diagnose-ID-Werte. Nehmen Sie die schweren Transformationen vom heißen Pfad, der bei jedem Build läuft, denken Sie daran, dass Sie den Generator schützen und nicht den Code, den er schreibt, und verifizieren Sie, indem Sie ein echtes Konsumentenprojekt gegen den obfuskierten Analyzer bauen, statt die DLL isoliert zu laden.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.