Skip to content

Commit e98d867

Browse files
sunnamed434claude
andcommitted
Plugins: warn-and-skip on incompatible BitMono.API + NuGet docs (#227)
Follow-ups to the plugin system from review feedback: - Detect when a plugin was built against a newer BitMono.API than this build provides (compare the referenced contract's Major.Minor to the host's) and skip it with a clear warning, instead of letting it fail later with a cryptic MissingMethodException mid-obfuscation. Reading a plugin's references is wrapped so a malformed assembly never aborts the run. A locally-built BitMono (version 0.0.x) can't be compared, so the check is skipped there. - Log each plugin's version when it loads ("Loaded plugin: MyPlugin v1.2.0.0"). - Docs: reference BitMono from NuGet with ExcludeAssets="runtime" instead of raw DLL HintPaths (a clean build then leaves just your plugin DLL), document the --plugins CLI override, and add a versioning/compatibility section - no custom attribute, the plugin's assembly version is its version. There's deliberately no [ProtectionVersion]/[RequiresApiVersion] attribute: ConfuserEx has nothing like it, and the assembly version already carries this. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7fd18c9 commit e98d867

5 files changed

Lines changed: 174 additions & 46 deletions

File tree

docs/source/developers/plugins.rst

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,50 @@
11
Plugins
22
=======
33

4-
BitMono can load **plugins** - external assemblies that contribute your own protections - without
5-
rebuilding BitMono. Drop a plugin DLL into the plugins directory and BitMono discovers and runs the
6-
protections inside it, exactly like the built-in ones. (`#227 <https://github.com/sunnamed434/BitMono/issues/227>`_)
4+
BitMono can load **plugins**, external assemblies that add your own protections, without rebuilding
5+
BitMono. Drop a plugin DLL into the plugins directory and BitMono finds the protections inside it and
6+
runs them exactly like the built-in ones. (`#227 <https://github.com/sunnamed434/BitMono/issues/227>`_)
77

88
.. warning::
99

10-
Plugins are loaded into the BitMono process with full trust - .NET cannot sandbox them. Only drop
11-
in plugins from sources you trust, just as you would with any other executable.
10+
Plugins run inside the BitMono process with full trust, .NET can't sandbox them. Only drop in
11+
plugins from sources you trust, same as you would with any other executable.
1212

1313

1414
Where plugins live
1515
------------------
1616

17-
By default BitMono scans a ``plugins`` directory next to the BitMono executable. You can change the
18-
directory name (or point it at an absolute path) with ``PluginsDirectoryName`` in ``obfuscation.json``:
17+
By default BitMono scans a ``plugins`` directory next to the BitMono executable. You can rename it (or
18+
point it at an absolute path) with ``PluginsDirectoryName`` in ``obfuscation.json``:
1919

2020
.. code-block:: json
2121
2222
{
2323
"PluginsDirectoryName": "plugins"
2424
}
2525
26-
Two layouts are supported:
26+
or override it for a single run from the CLI with ``--plugins``:
2727

28-
- **Flat** - drop the DLL straight into the directory: ``plugins/MyProtections.dll``.
29-
- **Per-plugin folder** - give each plugin its own folder: ``plugins/MyProtections/MyProtections.dll``.
30-
Put any extra dependencies in a nested folder (for example ``plugins/MyProtections/libs/``); they are
31-
resolved on demand and are not themselves treated as plugins.
28+
.. code-block:: bash
29+
30+
BitMono.CLI -f MyApp.dll --plugins "C:\path\to\my-plugins" -p HelloPlugin
31+
32+
Two layouts work:
3233

33-
If the directory does not exist, BitMono simply skips plugin loading.
34+
- **Flat**, drop the DLL straight in: ``plugins/MyProtections.dll``.
35+
- **Per-plugin folder**, give each plugin its own folder: ``plugins/MyProtections/MyProtections.dll``.
36+
Put any extra dependencies in a nested folder (say ``plugins/MyProtections/libs/``), they're resolved
37+
on demand and aren't treated as plugins themselves.
38+
39+
If the directory doesn't exist, BitMono just skips plugin loading.
3440

3541

3642
Writing a plugin
3743
----------------
3844

3945
A plugin is an ordinary class library that references ``BitMono.API`` (and usually ``BitMono.Core`` for
40-
the ``Protection`` base class) and exposes one or more public protections. Writing a protection is the
41-
same as :doc:`creating a built-in one <first-protection>`:
46+
the ``Protection`` base class) and exposes one or more public protections. Writing the protection itself
47+
is the same as writing a :doc:`built-in one <first-protection>`:
4248

4349
.. code-block:: csharp
4450
@@ -66,46 +72,58 @@ same as :doc:`creating a built-in one <first-protection>`:
6672
6773
.. note::
6874

69-
The container builds your protection by calling its **first** constructor and resolving each
70-
parameter. Take ``IBitMonoServiceProvider`` (as above) to reach BitMono's services. If a parameter
71-
cannot be resolved the protection is skipped and the error is logged - it never aborts the run.
75+
The container builds your protection through its **first** constructor, resolving each parameter.
76+
Take ``IBitMonoServiceProvider`` (like above) to reach BitMono's services. If a parameter can't be
77+
resolved the protection is skipped and the error is logged, it never aborts the run.
7278

7379

7480
.. _plugins-reference-the-host:
7581

7682
Reference BitMono, don't ship it
7783
--------------------------------
7884

79-
Reference the BitMono assemblies with ``Private="false"`` so they are **not** copied next to your
80-
plugin. Your plugin must bind to the *same* ``BitMono.API`` the host is running; if you ship your own
81-
copy, your protection implements a *different* ``IProtection`` type and BitMono will ignore it (and warn
82-
you in the log).
85+
Reference BitMono from NuGet and mark it ``ExcludeAssets="runtime"`` so its assemblies are **not** copied
86+
into your build output (BitMono itself provides them at runtime). Your plugin has to bind to the *same*
87+
``BitMono.API`` the host is running. Ship your own copy and your protection implements a *different*
88+
``IProtection`` type, so BitMono ignores it (and warns you in the log).
89+
90+
``BitMono.Core`` pulls in ``BitMono.API``, ``BitMono.Shared`` and ``AsmResolver`` for you, and
91+
``ExcludeAssets="runtime"`` keeps the whole graph out of your output, so a successful build leaves just
92+
your own plugin DLL behind.
8393

8494
.. code-block:: xml
8595
8696
<ItemGroup>
87-
<Reference Include="BitMono.API">
88-
<HintPath>path\to\BitMono.API.dll</HintPath>
89-
<Private>false</Private>
90-
</Reference>
91-
<Reference Include="BitMono.Core">
92-
<HintPath>path\to\BitMono.Core.dll</HintPath>
93-
<Private>false</Private>
94-
</Reference>
95-
<Reference Include="BitMono.Shared">
96-
<HintPath>path\to\BitMono.Shared.dll</HintPath>
97-
<Private>false</Private>
98-
</Reference>
97+
<!-- Use the version that matches the BitMono you run the plugin against. -->
98+
<PackageReference Include="BitMono.Core" Version="0.40.1">
99+
<ExcludeAssets>runtime</ExcludeAssets>
100+
</PackageReference>
99101
</ItemGroup>
100102
101103
104+
Versioning and compatibility
105+
----------------------------
106+
107+
There's no special "plugin version" attribute - your plugin's own assembly version is its version, and
108+
BitMono prints it when it loads it (``Loaded plugin: MyPlugin v1.2.0.0``).
109+
110+
What actually matters is the ``BitMono.API`` version you build against (``BitMono.Core`` pulls it in). It's
111+
the contract, and it follows `semver <https://semver.org>`_: a new minor only adds things, a new major can
112+
break ``IProtection``/``Protection``. If a plugin is built against a *newer* ``BitMono.API`` than the
113+
BitMono you drop it into, it'd probably call something that isn't there - so BitMono skips it and logs a
114+
warning telling you to rebuild against the version you're running. Same or older builds load fine.
115+
116+
So pin ``BitMono.Core`` to the version you ship against and bump it when you move to a newer BitMono.
117+
118+
102119
External dependencies
103120
---------------------
104121

105-
If your plugin uses external libraries (NuGet packages, your own helpers), ship those DLLs alongside the
106-
plugin - either next to it or in a nested folder such as ``libs``. BitMono installs an
107-
``AppDomain.AssemblyResolve`` handler that probes the plugin directories and loads dependencies on
108-
demand, so they don't have to sit in BitMono's own folder.
122+
If your plugin uses external libraries (other NuGet packages, your own helpers), reference them
123+
**normally** (without ``ExcludeAssets`` - that switch is only for BitMono itself) so they're copied next
124+
to your plugin, then ship those DLLs next to the plugin or in a nested folder like ``libs``. BitMono
125+
installs an ``AppDomain.AssemblyResolve`` handler that probes the plugin directories and loads
126+
dependencies on demand, so they don't have to sit in BitMono's own folder.
109127

110128
A typical per-plugin layout::
111129

@@ -119,7 +137,7 @@ A typical per-plugin layout::
119137
Enabling a plugin protection
120138
----------------------------
121139

122-
Plugin protections are configured exactly like the built-in ones - by name (the class name, or the
140+
Plugin protections are configured exactly like the built-in ones, by name (the class name, or the
123141
``[ProtectionName("...")]`` value). Add them to ``protections.json``:
124142

125143
.. code-block:: json
@@ -139,5 +157,5 @@ or pass them on the command line:
139157
140158
BitMono.CLI -f MyApp.dll -p HelloPlugin
141159
142-
Execution order follows the configuration order, the same as built-in protections. See
160+
Execution order follows the configuration order, same as built-in protections. See
143161
:doc:`obfuscation-execution-order` for details.

src/BitMono.Host/Extensions/ContainerExtensions.cs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public static class BitMonoContainerExtensions
2222
private const string ProtectionsFileName = "BitMono.Protections.dll";
2323
private const string UnityFileName = "BitMono.Unity.dll";
2424
private const string DefaultPluginsDirectoryName = "plugins";
25+
// The plugin SDK contract: any real protection implements IProtection, which lives here, so the
26+
// compiler always emits this reference into a plugin's IL - making it a reliable compatibility anchor.
27+
private const string ContractAssemblyName = "BitMono.API";
2528

2629
/// <summary>
2730
/// Loads and registers protection assemblies, including any drop-in plugins (see #227).
@@ -43,11 +46,12 @@ public static Container AddProtections(this Container container, string? file =
4346
}
4447

4548
var logger = container.GetService(typeof(ILogger)) as ILogger;
49+
var scanLogger = logger?.ForContext(typeof(BitMonoContainerExtensions));
4650

4751
// Load drop-in plugins before scanning so their protections are discovered alongside the built-ins.
48-
LoadPlugins(container, logger);
52+
var pluginAssemblies = LoadPlugins(container, logger);
53+
var incompatiblePlugins = GetIncompatiblePlugins(pluginAssemblies, scanLogger);
4954

50-
var scanLogger = logger?.ForContext(typeof(BitMonoContainerExtensions));
5155
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
5256

5357
// Collect all protection types (excluding IPhaseProtection)
@@ -73,6 +77,10 @@ public static Container AddProtections(this Container container, string? file =
7377
if (!type.IsClass || type.IsAbstract || !type.IsPublic || type.IsGenericTypeDefinition)
7478
continue;
7579

80+
// Skip protections from a plugin built against a newer BitMono.API (already warned, #227).
81+
if (incompatiblePlugins.Contains(type.Assembly))
82+
continue;
83+
7684
// IPhaseProtection extends IProtection, so it MUST be checked first - phase protections
7785
// are driven by their pipeline, not registered as standalone protections.
7886
if (typeof(IPhaseProtection).IsAssignableFrom(type))
@@ -124,11 +132,11 @@ public static Container AddProtections(this Container container, string? file =
124132
return container;
125133
}
126134

127-
private static void LoadPlugins(Container container, ILogger? logger)
135+
private static IReadOnlyList<Assembly> LoadPlugins(Container container, ILogger? logger)
128136
{
129137
// No logger means the host wasn't fully set up; plugin loading relies on logging for diagnostics.
130138
if (logger == null)
131-
return;
139+
return [];
132140

133141
var obfuscationSettings = container.GetService(typeof(ObfuscationSettings)) as ObfuscationSettings;
134142
var directoryName = obfuscationSettings?.PluginsDirectoryName;
@@ -139,6 +147,41 @@ private static void LoadPlugins(Container container, ILogger? logger)
139147
? directoryName!
140148
: Path.Combine(AppContext.BaseDirectory, directoryName!);
141149

142-
new PluginLoader(pluginsDirectory, logger).LoadPlugins();
150+
return new PluginLoader(pluginsDirectory, logger).LoadPlugins();
151+
}
152+
153+
// Plugins built against a newer BitMono.API than this build provides are skipped with a clear warning,
154+
// instead of failing later with a cryptic MissingMethodException mid-obfuscation (#227).
155+
private static HashSet<Assembly> GetIncompatiblePlugins(IReadOnlyList<Assembly> plugins, ILogger? logger)
156+
{
157+
var incompatible = new HashSet<Assembly>();
158+
var hostVersion = typeof(IProtection).Assembly.GetName().Version;
159+
foreach (var plugin in plugins)
160+
{
161+
Version? contractVersion;
162+
try
163+
{
164+
contractVersion = plugin.GetReferencedAssemblies()
165+
.FirstOrDefault(x => string.Equals(x.Name, ContractAssemblyName, StringComparison.OrdinalIgnoreCase))
166+
?.Version;
167+
}
168+
catch (Exception ex)
169+
{
170+
// Can't read the plugin's metadata - don't let it abort the run; treat as compatible.
171+
logger?.Warning("Could not read references of plugin '{0}': {1}",
172+
plugin.GetName().Name ?? "?", ex.Message);
173+
continue;
174+
}
175+
if (!PluginCompatibility.IsBuiltAgainstNewerContract(hostVersion, contractVersion))
176+
continue;
177+
178+
incompatible.Add(plugin);
179+
logger?.Warning(
180+
"Plugin '{0}' was built against {1} {2}, newer than this build's {3}; its protections are skipped. " +
181+
"Update BitMono or rebuild the plugin against {3}.",
182+
plugin.GetName().Name ?? "?", ContractAssemblyName,
183+
contractVersion!.ToString(2), hostVersion!.ToString(2));
184+
}
185+
return incompatible;
143186
}
144187
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace BitMono.Shared.Plugins;
2+
3+
/// <summary>
4+
/// Decides whether a plugin built against a given BitMono.API version is compatible with the host.
5+
/// BitMono uses standard assembly versioning instead of a custom plugin-version attribute, so this just
6+
/// compares the contract assembly versions. See #227.
7+
/// </summary>
8+
public static class PluginCompatibility
9+
{
10+
/// <summary>
11+
/// Returns <c>true</c> when the plugin was built against a NEWER contract (compared on Major.Minor)
12+
/// than the host provides - it likely uses API this BitMono build doesn't have, so it should be
13+
/// skipped with a clear warning rather than failing cryptically later. Older or equal contracts are
14+
/// considered compatible (best effort). A locally-built BitMono reports version 0.0.x, which can't be
15+
/// compared meaningfully, so the check is skipped (returns <c>false</c>) in that case.
16+
/// </summary>
17+
public static bool IsBuiltAgainstNewerContract(System.Version? hostVersion, System.Version? pluginContractVersion)
18+
{
19+
if (hostVersion == null || pluginContractVersion == null)
20+
{
21+
return false;
22+
}
23+
if (hostVersion.Major == 0 && hostVersion.Minor == 0)
24+
{
25+
return false;
26+
}
27+
if (pluginContractVersion.Major != hostVersion.Major)
28+
{
29+
return pluginContractVersion.Major > hostVersion.Major;
30+
}
31+
return pluginContractVersion.Minor > hostVersion.Minor;
32+
}
33+
}

src/BitMono.Shared/Plugins/PluginLoader.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public IReadOnlyList<Assembly> LoadPlugins()
6262
// dependencies probe correctly and the AssemblyResolve fallback has somewhere to look.
6363
var assembly = Assembly.LoadFrom(file);
6464
loaded.Add(assembly);
65-
_logger.Information("Loaded plugin: {0}", assembly.GetName().Name);
65+
_logger.Information("Loaded plugin: {0} v{1}", assembly.GetName().Name, assembly.GetName().Version);
6666
}
6767
catch (BadImageFormatException)
6868
{
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using BitMono.Shared.Plugins;
2+
3+
namespace BitMono.Shared.Tests.Plugins;
4+
5+
public class PluginCompatibilityTests
6+
{
7+
[Theory]
8+
// Plugin built against a newer minor than the host -> incompatible.
9+
[InlineData("0.40.0.0", "0.41.0.0", true)]
10+
[InlineData("0.40.5.0", "0.41.0.0", true)]
11+
[InlineData("1.0.0.0", "2.0.0.0", true)]
12+
// Equal or older contract -> compatible (best effort).
13+
[InlineData("0.40.0.0", "0.40.0.0", false)]
14+
[InlineData("0.40.0.0", "0.40.9.0", false)] // same Major.Minor, patch differences ignored
15+
[InlineData("0.41.0.0", "0.40.0.0", false)]
16+
[InlineData("2.0.0.0", "1.5.0.0", false)]
17+
// Locally-built host (0.0.x) can't be compared -> never flagged.
18+
[InlineData("0.0.0.0", "0.41.0.0", false)]
19+
// An unversioned/locally-built plugin (0.0.x) against a real host is never "newer".
20+
[InlineData("0.40.0.0", "0.0.0.0", false)]
21+
public void IsBuiltAgainstNewerContract_ComparesMajorMinor(string host, string plugin, bool expected)
22+
{
23+
var result = PluginCompatibility.IsBuiltAgainstNewerContract(new Version(host), new Version(plugin));
24+
25+
result.Should().Be(expected);
26+
}
27+
28+
[Fact]
29+
public void IsBuiltAgainstNewerContract_ReturnsFalse_WhenEitherVersionMissing()
30+
{
31+
PluginCompatibility.IsBuiltAgainstNewerContract(null, new Version(9, 9)).Should().BeFalse();
32+
PluginCompatibility.IsBuiltAgainstNewerContract(new Version(0, 40), null).Should().BeFalse();
33+
}
34+
}

0 commit comments

Comments
 (0)