Skip to content
← Todas las publicaciones
· Delta1 Labs Nebula.NETOfuscación.NETAnálisis a fondo

Ofuscar un generador de código fuente de Roslyn: por qué el generador se renombra libremente pero el atributo y el constructor público están fijos

Un generador de código fuente es una cosa extraña de ofuscar: es un ensamblado .NET Standard 2.0 que el compilador carga por reflexión y ejecuta dentro de la compilación de tu cliente, y es donde vive tu lógica de tiempo de compilación más valiosa, exactamente el código que distribuyes y que preferirías no entregar a un decompilador. La buena noticia es que Roslyn descubre los generadores por atributo, no por nombre, así que Nebula puede renombrar el tipo del generador y toda su superficie privada sin ningún efecto sobre el descubrimiento. Las partes que están fijas son pocas y concretas: el atributo [Generator] debe sobrevivir, el constructor público sin parámetros debe seguir siendo público (el compilador lo instancia con Activator.CreateInstance) y el método de interfaz al que llama debe conservar su slot. Esta entrada lee sobre qué reflexiona realmente el GeneratorDriver, traza la línea entre lo que se renombra y lo que es estructural, muestra la configuración de Nebula y aclara lo único que la gente entiende al revés: ofuscar el generador no ofusca el código que emite.

Un generador de código fuente es algo incómodo de proteger. Es un ensamblado .NET Standard 2.0, se carga por reflexión, se ejecuta dentro de la compilación de tu cliente en lugar de dentro de tu propia aplicación, y tiende a contener parte de la lógica de tiempo de compilación más valiosa que distribuyes: el análisis, las plantillas, las decisiones que hacen que el generador merezca venderse. Así que «¿puedo ofuscarlo?» es una pregunta razonable, y la respuesta es sí, con una lista agradablemente corta de cosas con las que tener cuidado.

La razón de que la lista sea corta es que Roslyn encuentra los generadores por atributo, no por nombre. Ese único hecho significa que Nebula puede renombrar casi todo el ensamblado —la clase del generador, su namespace, cada helper privado— y el compilador sigue encontrándolo y ejecutándolo. Esta entrada traza la línea exacta entre lo que se renombra libremente y lo que es estructural.

Sobre qué reflexiona realmente el compilador

Aquí tienes un generador incremental mínimo, con la forma que distribuyes:

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 ... */ }
}

Cuando el compilador construye un proyecto que referencia este analizador, el GeneratorDriver hace algo muy concreto: carga el ensamblado, enumera sus tipos y se queda con los decorados con GeneratorAttribute. Para cada uno llama a Activator.CreateInstance(type) para obtener una instancia y luego invoca Initialize. Fíjate en lo que no está en esa secuencia: el nombre del tipo. Nadie —ni el driver, ni el .csproj del consumidor, ni los metadatos del NuGet— busca jamás el generador por JsonContextGenerator. Se encuentra por su atributo y se instancia por reflexión.

Cómo carga el GeneratorDriver tu ensambladocargar ensambladodel analizadorbuscar tipos con[Generator]Activator.CreateInstanceInitialize(ctx)Renombrable (Nebula puede renombrar)• el nombre del tipo generador• su namespace• todos los helpers / campos privadosFijo (debe sobrevivir)• el atributo [Generator]• ctor público sin parámetros• el slot de interfaz de Initialize• cadenas de ID de diagnóstico (valor)Por qué cada uno está fijoatributo: es la marca que eldriver busca — quítalo yel generador desaparece.ctor: Activator necesita un ctorpúblico nulario o lanza.Initialize: la impl de interfazconserva su slot; no lo elimines.IDs: el valor debe ser idéntico paraque la supresión en .editorconfigsiga coincidiendo (cifrar está bien).

Lo que se renombra libremente

Como el descubrimiento es por atributo, el nombre de la clase del generador JsonContextGenerator es peso muerto para el mundo exterior: renómbralo a a y nadie lo nota. Lo mismo vale para el namespace Acme.Generators, los métodos estáticos privados BuildModel y Emit, el tipo Model y sus campos, y cualquier helper interno que use la lógica de plantillas. Aquí es donde está el valor, y todo es terreno libre. En la práctica, la gran mayoría de un ensamblado de generador se ofusca exactamente como cualquier otra biblioteca.

Lo que está fijo, y por qué

Tres cosas son estructurales, y las tres tienen que ver con cargar y llamar al generador, no con nombrarlo.

El atributo [Generator] debe sobrevivir sobre el tipo. Es la marca que el driver busca; si tu renombrador o una pasada de eliminación de atributos personalizados lo quita, el generador simplemente nunca se descubre y tu compilación produce nada en silencio: sin error, solo salida ausente, que es un bug miserable de perseguir. Nebula no elimina atributos que reconoce como significativos hacia el exterior, pero este es el que conviene dejar explícito en la configuración.

El constructor público sin parámetros debe seguir siendo público y sin parámetros. El driver llama a Activator.CreateInstance(type), que necesita un ctor público nulario. Si la ofuscación lo hiciera privado o tú le añades un parámetro, la instanciación lanza una excepción en el momento de cargar el generador. Mantenlo público.

El método Initialize es una implementación de interfaz (IIncrementalGenerator.Initialize), así que el renombrado conserva su slot en la v-table automáticamente: el driver lo llama a través de la interfaz, no por nombre. La única regla es: no lo elimines ni cambies su firma.

Una más, fácil de pasar por alto: si tu generador también reporta diagnósticos, las cadenas de ID del DiagnosticDescriptor («ACME001») son un contrato público: los consumidores las suprimen en .editorconfig con dotnet_diagnostic.ACME001.severity. El cifrado de cadenas es inocuo (el valor se reconstruye en tiempo de ejecución, así que el diagnóstico emitido sigue llevando el ID correcto), pero el valor no debe cambiar. No «ordenes» un ID.

La configuración

En términos de Nebula, esto es un perfil de biblioteca casi por defecto con un par de salvaguardas:

{
  "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 ya mantiene invocables las implementaciones de interfaces externas; las entradas preserve explícitas son un cinturón y tirantes para el atributo y el constructor, que son los dos modos de fallo que producen una rotura silenciosa en lugar de un crash. Excluir Initialize del aplanamiento del flujo de control es una decisión de rendimiento, no de corrección: se ejecuta en cada compilación en el IDE, así que mantén su ruta ligera.

Lo que la gente entiende al revés

Ofuscar el generador no ofusca el código que el generador emite. El texto fuente que devuelve tu método Emit pasa a formar parte de la compilación de tu cliente; se compila en su ensamblado y tiene el aspecto de lo que hayas escrito en la plantilla, independientemente de cómo esté ofuscado tu ensamblado de generador. Los dos viven en lados opuestos de la compilación:

  • Tu ensamblado de generador se distribuye en tu NuGet, se ejecuta en el compilador del cliente, y es lo que ofuscas para proteger cómo funciona el generador.
  • El código fuente emitido es ahora código del cliente; si quiere protegerlo, es él quien ofusca su salida.

Confundir ambas cosas lleva a la gente a esperar que un generador protegido produzca código generado protegido, y después a abrir un bug de «el código generado sigue siendo legible» que no es un bug. Estás ocultando la máquina, no lo que sale de ella.

La conclusión

Un generador de código fuente de Roslyn se ofusca bien precisamente porque el compilador lo carga como lo hace: buscando el atributo [Generator] e instanciando el tipo por reflexión, nunca por nombre. Eso te da cobertura de renombrado gratuita sobre todo el generador —clase, namespace, helpers, plantillas—, que es donde realmente vive tu lógica de tiempo de compilación. El conjunto fijo es diminuto y gira por completo en torno a la ruta de carga: conserva el atributo, conserva el constructor público nulario, conserva el slot de Initialize y los valores de los ID de diagnóstico. Aparta las transformaciones pesadas de la ruta caliente de cada compilación, recuerda que estás protegiendo el generador y no el código que escribe, y verifica compilando un proyecto consumidor real contra el analizador ofuscado en lugar de cargar la DLL de forma aislada.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.