Ofuscar una aplicación Avalonia: enlaces compilados, la convención del ViewLocator y lo que el compilador de XAML ya hizo seguro ante el renombrado
Avalonia no es WPF con otro logotipo, y la diferencia importa en el momento en que lo ofuscas. Avalonia compila tu .axaml a IL en tiempo de compilación, así que un enlace compilado es un accesor de propiedad tipado que referencia el getter por token de metadatos —sobrevive intacto a un renombrado completo— mientras que un enlace por reflexión es una cadena que el runtime resuelve por nombre y que vuelve vacía en silencio. Lo que se rompe primero no es ninguno de los dos: es el ViewLocator de la plantilla MVVM por defecto, que encuentra una vista tomando el nombre del tipo del view model, sustituyendo "ViewModel" por "View" y llamando a Type.GetType — una convención que un renombrado convierte en un TextBlock de "Not Found" o en una InvalidCastException. Esta entrada lee lo que el compilador de XAML emite realmente para un enlace compilado, muestra por qué INotifyPropertyChanged sigue coincidiendo tras el renombrado, recorre los dos tipos generados por Avalonia que el runtime localiza con Assembly.GetType por sus nombres literales, y da la configuración de Nebula y el mapeo de vistas basado en DataTemplate que te permiten aplanar toda la aplicación sin tocar las partes que Avalonia resuelve por nombre.
Una guía de ofuscación de WPF no se traslada a Avalonia, y la razón es el compilador de XAML. WPF compila XAML a BAML, un grafo de objetos serializado que el runtime instancia por nombre, y una ruta de enlace es una cadena que el runtime recorre con reflexión. Avalonia compila .axaml a IL en tu propio ensamblado. Un enlace compilado es un accesor tipado; un DataTemplate con un DataType es una referencia de tipo; un control en XAML es un newobj. La mayor parte de lo que un ofuscador rompería en WPF ya se ha convertido en tokens de metadatos cuando Nebula ve un ensamblado Avalonia, y los tokens son exactamente lo que un renombrador reescribe correctamente.
Lo que significa que los fallos que quedan son aquellos en los que Avalonia —o tu propio código— sigue pasando por una cadena. Hay tres que importan, y vale la pena ordenarlos por lo rápido que muerden: un enlace por reflexión vuelve vacío con una línea de log y un control en blanco; los dos tipos generados por el compilador que Avalonia localiza por nombre literal hacen que todo include avares:// falle al cargar; y el ViewLocator de la plantilla MVVM por defecto rompe de golpe toda navegación de la aplicación, porque encuentra las vistas editando el nombre del tipo del view model.
Lo que el compilador de XAML emite para un enlace compilado
Empieza por el caso ordinario y lee el código generado, porque el argumento de seguridad depende exactamente de lo que contiene. Una vista con un contexto de datos tipado y un enlace:
<!-- MainWindow.axaml -->
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MyApp.ViewModels"
x:Class="MyApp.Views.MainWindow"
x:DataType="vm:MainWindowViewModel">
<TextBlock Text="{CompiledBinding Greeting}" />
</Window>
Con <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> en el archivo de proyecto, un {Binding Greeting} normal compila de la misma manera; x:DataType es lo que da al compilador un tipo contra el que resolver Greeting. El compilador emite un método de población en tu ensamblado. Decompilado y ligeramente limpiado, la parte que construye el enlace se ve así:
// Generated into MyApp.dll by the Avalonia XAML compiler (cleaned up for reading).
// Namespace CompiledAvaloniaXaml, method !XamlIlPopulate for MainWindow.
var path = new CompiledBindingPathBuilder()
.Property(
new ClrPropertyInfo(
"Greeting", // literal name
o => ((MainWindowViewModel)o).Greeting, // getter: token ref to get_Greeting
null, // no setter: read-only binding
typeof(string)),
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor) // INotifyPropertyChanged accessor
.Build();
textBlock.Bind(TextBlock.TextProperty, new CompiledBindingExtension(path));
Dos cosas referencian Greeting, y son tipos de referencia distintos. La lambda o => ((MainWindowViewModel)o).Greeting compila a callvirt instance string MainWindowViewModel::get_Greeting() — un token de metadatos. Cuando Nebula renombra get_Greeting a a, reescribe cada token que apunta a él, incluido este; la lambda llama al getter renombrado y devuelve el valor correcto. La conversión a MainWindowViewModel también es un token, reescrito de la misma forma.
La segunda referencia es la cadena "Greeting". No se usa para encontrar la propiedad — el accesor ya tiene el getter. Se pasa al accesor INPC, que se suscribe a PropertyChanged y relee el valor cuando llega un evento cuyo PropertyName es igual a "Greeting". Así que la pregunta es qué pone el view model en ese evento:
public sealed partial class MainWindowViewModel : ObservableObject
{
[ObservableProperty] private string _greeting = "Hello";
// the MVVM Toolkit generator emits: OnPropertyChanged("Greeting") — a literal
// or by hand:
private string _title = "";
public string Title
{
get => _title;
set => SetProperty(ref _title, value); // [CallerMemberName] → "Title", baked at compile time
}
}
nameof(Greeting), [CallerMemberName] y los generadores de código se resuelven todos a un literal de cadena en IL —ldstr "Greeting"— antes de que el ofuscador se ejecute. Renombrar la propiedad cambia el nombre en los metadatos y deja el literal intacto. El enlace compilado compara su literal con el literal del evento, y ambos siguen diciendo Greeting. El enlace funciona, la notificación de cambio funciona, y la propiedad puede renombrarse a cualquier cosa.
Por esta misma razón la trampa del nameof del mundo WPF no aplica aquí de la misma manera: en Avalonia el lado compilado también contiene un literal, así que no hay un nombre de metadatos renombrado en ningún lado de la comparación. La única forma de romperlo es una clase base de view model que emita PropertyChanged enumerando GetType().GetProperties() y usando prop.Name — un nombre en tiempo de ejecución que sí cambia. Si tienes una de esas, es una dependencia de reflexión como cualquier otra, y necesita una regla de conservación o una reescritura.
Enlaces por reflexión: la cadena que el runtime recorre
Quita el x:DataType, o deja los enlaces compilados desactivados, y el mismo {Binding Greeting} compila de forma distinta. El compilador no puede resolver Greeting contra un tipo, así que emite una ReflectionBindingExtension que lleva la ruta como cadena. En tiempo de ejecución el motor de enlaces analiza "Greeting", toma el DataContext real y busca la propiedad por nombre — reflexión contra los metadatos renombrados. Tras la ofuscación no hay ninguna propiedad llamada Greeting; la búsqueda devuelve null, Avalonia registra un error de enlace a nivel de advertencia, y el TextBlock queda en blanco. Sin excepción. Tu prueba de humo, que comprueba que la ventana se abre, pasa.
Puedes salir de esto conservando —mantener las propiedades públicas de cada tipo de view model—, pero es la solución equivocada, porque deja la dependencia en su sitio y solo evita que se ejercite. La solución correcta es eliminar la dependencia:
<!-- MyApp.csproj -->
<PropertyGroup>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
Con eso activado, cada {Binding} se compila, y cada vista necesita un x:DataType (o un DataTemplate con un DataType) para que el compilador tenga algo contra lo que resolver. La recompensa no es solo que los enlaces se vuelven seguros ante el renombrado — es que cualquier enlace que el compilador no pueda resolver es ahora un error de compilación. Un enlace que se habría roto con la ofuscación ni siquiera puede compilarse. Has trasladado el fallo en producción al servidor de compilación, que es donde debería vivir todo fallo de ofuscación.
Donde realmente necesites una ruta de enlace tardío —un enlace a un objeto dinámico, un ItemsSource sin tipo procedente de un plugin— escribe {ReflectionBinding Path} explícitamente para que la intención sea visible, y conserva exactamente las propiedades públicas de ese tipo con una regla dirigida en lugar de desactivar los enlaces compilados en todo el proyecto.
El ViewLocator: la convención que rompe toda la aplicación
Crea un proyecto Avalonia MVVM desde la plantilla y obtienes esta clase, y es lo más importante del proyecto que hay que leer antes de ofuscar:
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null) return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
return (Control)Activator.CreateInstance(type)!;
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data) => data is ViewModelBase;
}
Todo ContentControl cuyo contenido es un view model pasa por Build, lo que significa toda navegación, toda pestaña, todo diálogo enlazado a un view model. Y Build funciona editando el nombre del tipo del view model como texto: MyApp.ViewModels.MainWindowViewModel se convierte en MyApp.Views.MainWindowView, y Type.GetType lo encuentra en el ensamblado actual.
Renombra el view model y pasa una de dos cosas. Si Nebula renombró el espacio de nombres además del tipo, FullName es algo como a.b. No hay subcadena "ViewModel", Replace devuelve la entrada sin cambios, y Type.GetType("a.b") —buscando en el ensamblado que llama— encuentra el propio tipo del view model. Activator.CreateInstance construye un view model, y la conversión a Control lanza InvalidCastException. Si solo se renombró el nombre del tipo y se conservó el espacio de nombres, FullName es MyApp.ViewModels.a, la sustitución produce MyApp.Views.a, nada tiene ese nombre, y el usuario ve Not Found: MyApp.Views.a en lugar de cada vista.
Ambos se detectan la primera vez que ejecutas la compilación ofuscada, lo cual es bueno, pero la solución a la que la gente recurre —conservar todo tipo llamado *ViewModel y *View— deja la convención en su sitio y mantiene en texto claro los nombres de tipo más interesantes de tu aplicación, los que describen lo que hace cada pantalla. La solución correcta es dejar de localizar vistas por nombre. Avalonia ya tiene un mecanismo seguro ante el renombrado para ello:
<!-- App.axaml -->
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:MyApp.ViewModels"
xmlns:views="using:MyApp.Views"
x:Class="MyApp.App">
<Application.DataTemplates>
<DataTemplate DataType="vm:MainWindowViewModel"><views:MainWindowView /></DataTemplate>
<DataTemplate DataType="vm:SettingsViewModel"><views:SettingsView /></DataTemplate>
<DataTemplate DataType="vm:ProjectViewModel"><views:ProjectView /></DataTemplate>
</Application.DataTemplates>
</Application>
Cada DataType compila a un typeof —un token— y cada elemento de vista compila a un newobj —también un token. No hay ninguna cadena en ningún punto del mapeo. Renombra ambos lados a letras sueltas y las plantillas siguen emparejando la vista correcta con el view model correcto, porque el emparejamiento nunca fue por nombre. Como beneficio adicional, esta es también la forma que sobrevive al recorte y a Native AOT, que se atragantan con Type.GetType(string) por la misma razón que un ofuscador: el nombre no es una referencia que la cadena de herramientas pueda ver.
Si prefieres código, un diccionario estático indexado por Type da la misma propiedad — typeof(MainWindowViewModel) es un token, y el renombrador lo sigue. Lo que no debes tener, en ningún punto de la aplicación, es una línea que convierta un tipo en una cadena y la cadena de vuelta en un tipo. Borra el ViewLocator de la plantilla una vez que las plantillas estén dentro; no hace falta.
Los dos tipos generados que Avalonia encuentra por nombre
Hay un lugar donde la propia Avalonia resuelve tu ensamblado por cadena, y está en código que no escribiste. El compilador de XAML emite un espacio de nombres CompiledAvaloniaXaml en tu ensamblado con dos tipos, !XamlLoader y !AvaloniaResources, que contienen los métodos de población y construcción compilados de cada archivo .axaml, indexados por ruta de recurso. Cuando un StyleInclude o ResourceInclude tiene un origen avares://MyApp/Styles/Dark.axaml, o cuando llamas a AvaloniaXamlLoader.Load(new Uri(...)), el runtime localiza el XAML compilado de esa ruta llamando a Assembly.GetType con el nombre literal del tipo e invocando el método correspondiente. Renombra el espacio de nombres o cualquiera de los tipos y esa búsqueda devuelve null; el runtime informa de que no se encontró XAML precompilado para el URI y el include no se carga.
InitializeComponent normalmente no se ve afectado, porque el compilador reescribe la llamada AvaloniaXamlLoader.Load(this) de su interior en una llamada directa al método de población generado — un token, no una búsqueda. Pero toda carga por URI pasa por la vía reflexiva, y casi toda aplicación tiene al menos una, en los estilos de App.axaml. Así que el espacio de nombres se queda.
Otros mecanismos de Avalonia parecen basados en cadenas pero son seguros, y vale la pena saber por qué para no conservar de más. x:Name="Submit" con this.FindControl<Button>("Submit") es literal contra literal — el ámbito de nombres registra el literal del XAML y la búsqueda pasa el literal desde C#; el campo generado puede renombrarse. AvaloniaProperty.Register<MyControl, string>(nameof(Text)) registra un literal; los enlaces que apuntan a una AvaloniaProperty se resuelven a través del registro de propiedades por ese literal, no a través del nombre del envoltorio CLR. Los selectores de estilo (Button.primary, :pointerover) son cadenas en ambos lados. Los URI avares:// contienen el nombre del ensamblado, que Nebula no renombra. Ninguno de estos necesita una regla.
La configuración de Nebula
Con los enlaces compilados activados y el ViewLocator sustituido por plantillas, la configuración es corta, porque casi nada en la aplicación se resuelve ya por nombre:
<Nebula>
<Assembly file="MyApp.Desktop.dll">
<Rename enabled="true">
<!-- Avalonia locates these compiler-generated types with Assembly.GetType by literal
name when it loads XAML by avares:// URI. Keep the namespace exactly as emitted. -->
<Preserve names="true" members="true" namespace="CompiledAvaloniaXaml" />
</Rename>
<ControlFlow enabled="true" />
<StringEncryption enabled="true" />
</Assembly>
</Nebula>
El cifrado de cadenas puede dejarse activado para todo el ensamblado sin riesgo. El literal "Greeting" del accesor generado y el literal "Greeting" del OnPropertyChanged del view model se descifran ambos en el punto de uso, y la comparación INPC ocurre sobre los valores descifrados; el enlace ve las mismas dos cadenas de siempre.
Si todavía no estás en posición de eliminar las dependencias de reflexión —una aplicación grande con un ViewLocator y años de enlaces sin tipo— esta es la regla provisional, acotada exactamente a la superficie que tocan las búsquedas por cadena y a nada más:
<Rename enabled="true">
<Preserve names="true" members="true" namespace="CompiledAvaloniaXaml" />
<!-- INTERIM ONLY: a convention-based ViewLocator needs the view model and view TYPE
names; reflection bindings need view model PUBLIC PROPERTY names. Nothing else. -->
<Preserve names="true" members="public" baseType="MyApp.ViewModels.ViewModelBase" />
<Preserve names="true" members="false" baseType="Avalonia.Controls.UserControl" />
<Preserve names="true" members="false" baseType="Avalonia.Controls.Window" />
</Rename>
Observa que members="false" en los tipos de vista conserva solo el nombre del tipo que necesita el localizador; los campos, manejadores y campos x:Name generados de la vista siguen renombrándose. Y la regla del view model conserva los nombres de las propiedades públicas que recorren los enlaces por reflexión mientras renombra los campos privados, los miembros de respaldo de los comandos y todo lo que el motor de enlaces nunca ve. Trata este bloque como una lista de tareas — cada línea es una dependencia que eliminar, y cuando desaparezca la última, también lo hará el bloque.
La prueba que lo demuestra
Los modos de fallo aquí son silenciosos: un TextBlock en blanco, una etiqueta Not Found, un estilo que no se aplicó. La prueba que los captura todos es la que construye cada vista a través de la tubería real de plantillas contra el ensamblado ofuscado, usando la plataforma headless de Avalonia para que se ejecute en CI:
[AvaloniaFact] // Avalonia.Headless.XUnit
public void Every_view_model_resolves_to_a_real_view()
{
var app = Application.Current!; // the OBFUSCATED MyApp.Desktop.dll
var viewModels = new object[]
{
new MainWindowViewModel(), new SettingsViewModel(), new ProjectViewModel()
};
foreach (var vm in viewModels)
{
var template = app.DataTemplates.FirstOrDefault(t => t.Match(vm));
Assert.NotNull(template); // a view model with no template
var view = template!.Build(vm);
Assert.IsNotType<TextBlock>(view); // the "Not Found" fallback
Assert.NotNull(view);
}
}
Añade una aserción por cada enlace compilado que te importe —asigna una propiedad del view model, bombea el dispatcher, lee el control— y la prueba falla ante un accesor renombrado mucho antes de que lo haga un cliente. Con el compilador imponiendo los enlaces compilados y esta prueba imponiendo el mapeo de vistas, no queda ningún nombre en la aplicación que el ofuscador pueda romper, que es exactamente el estado que quieres antes de aplanarla.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.