diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..707dc58 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* amacocian@yahoo.com \ No newline at end of file diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml new file mode 100644 index 0000000..03d4cc9 --- /dev/null +++ b/.github/workflows/cd.yaml @@ -0,0 +1,50 @@ +name: Plumsy CD Pipeline + +on: + push: + branches: + - master + +jobs: + build: + environment: Default + strategy: + matrix: + targetplatform: [x64] + + runs-on: windows-latest + + env: + Configuration: Release + Solution_Path: Plumsy.sln + Test_Project_Path: Plumsy.Tests\Plumsy.Tests.csproj + Source_Project_Path: Plumsy\Plumsy.csproj + Actions_Allow_Unsecure_Commands: true + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Install .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '7.x' + + - name: Setup MSBuild.exe + uses: microsoft/setup-msbuild@v1.0.1 + + - name: Restore project + run: msbuild $env:Solution_Path /t:Restore /p:Configuration=$env:Configuration /p:RuntimeIdentifier=$env:RuntimeIdentifier + env: + RuntimeIdentifier: win-${{ matrix.targetplatform }} + + - name: Build Plumsy project + run: dotnet build Plumsy -c $env:Configuration + + - name: Package Plumsy + run: dotnet pack -c Release -o . $env:Source_Project_Path + + - name: Publish + run: dotnet nuget push *.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3b59c46 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,59 @@ +name: Plumsy CI Pipeline + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + build: + strategy: + matrix: + targetplatform: [x64] + + runs-on: windows-latest + + env: + Solution_Path: Plumsy.sln + Test_Project_Path: Plumsy.Tests\Plumsy.Tests.csproj + Test_Plugin_Project_Path: Plumsy.Tests.SimplePlugin\Plumsy.Tests.SimplePlugin.csproj + Source_Project_Path: Plumsy\Plumsy.csproj + Actions_Allow_Unsecure_Commands: true + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Install .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '6.x' + + - name: Setup MSBuild.exe + uses: microsoft/setup-msbuild@v1.0.1 + + - name: Build SimplePlugin project + run: dotnet build $env:Test_Plugin_Project_Path + + - name: Prepare plugin files # Needs to be done manually in this pipeline + run: | + mkdir ${{ github.workspace }}\SimplePlugin + cp ${{ github.workspace }}\Plumsy.Tests.SimplePlugin\bin\Debug\net6.0\Plumsy.Tests.SimplePlugin.dll ${{ github.workspace }}\SimplePlugin\Plumsy.Tests.SimplePlugin.dll + cp ${{ github.workspace }}\Plumsy.Tests.SimplePlugin\bin\Debug\net6.0\SystemExtensions.NetStandard.dll ${{ github.workspace }}\SimplePlugin\SystemExtensions.NetStandard.dll + + - name: Echo solution path + run: echo /p:SolutionDir=${{ github.workspace }}\ + + - name: Execute Plumsy Unit Tests + run: dotnet test $env:Test_Project_Path /p:SolutionDir=${{ github.workspace }}\ --logger:"console;verbosity=normal" # Need to manually set the SolutionDir, otherwise it's unspecified + + - name: Restore Project + run: msbuild $env:Solution_Path /t:Restore /p:Configuration=$env:Configuration /p:RuntimeIdentifier=$env:RuntimeIdentifier + env: + Configuration: Debug + RuntimeIdentifier: win-${{ matrix.targetplatform }} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5863da8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Macocian Alexandru Victor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Plumsy.Tests.SimplePlugin/Main.cs b/Plumsy.Tests.SimplePlugin/Main.cs new file mode 100644 index 0000000..b21d901 --- /dev/null +++ b/Plumsy.Tests.SimplePlugin/Main.cs @@ -0,0 +1,13 @@ +using System.Extensions; + +namespace Plum.Net.Tests.SimplePlugin; + +public sealed class Main +{ + public Main() + { + this.ThrowIfNull("this"); + } + + public bool ReturnTrue() => true; +} \ No newline at end of file diff --git a/Plumsy.Tests.SimplePlugin/Plumsy.Tests.SimplePlugin.csproj b/Plumsy.Tests.SimplePlugin/Plumsy.Tests.SimplePlugin.csproj new file mode 100644 index 0000000..2620e97 --- /dev/null +++ b/Plumsy.Tests.SimplePlugin/Plumsy.Tests.SimplePlugin.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + true + + + + $(SolutionDir)SimplePlugin + + + + + + + + + + + diff --git a/Plumsy.Tests/PluginManagerTests.cs b/Plumsy.Tests/PluginManagerTests.cs new file mode 100644 index 0000000..3f788b0 --- /dev/null +++ b/Plumsy.Tests/PluginManagerTests.cs @@ -0,0 +1,209 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NSubstitute; +using Plum.Net.Models; +using Plum.Net.Tests.Resolvers; +using Plum.Net.Tests.SimplePlugin; +using Plum.Net.Validators; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Runtime.Loader; + +namespace Plum.Net.Tests; + +[TestClass] +public class PluginManagerTests +{ + private readonly string pluginDirectory; + private readonly PluginManager pluginManager; + + public PluginManagerTests() + { + this.pluginDirectory = Path.Combine(Environment.CurrentDirectory, "Plugins"); + this.pluginManager = new(this.pluginDirectory); + this.pluginManager.WithDependencyResolver(new SimpleDependencyResolver()); + } + + [TestInitialize] + public void TestInitialize() + { + var assemblyLoadEventHandlerField = typeof(AssemblyLoadContext).GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic).First(f => f.Name == "AssemblyLoad"); + assemblyLoadEventHandlerField.SetValue(AssemblyLoadContext.Default, null); + + var resolveDependencyEventHandlerField = typeof(AssemblyLoadContext).GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic).First(f => f.Name == "AssemblyResolve"); + resolveDependencyEventHandlerField.SetValue(AssemblyLoadContext.Default, null); + } + + [TestMethod] + public void PluginManager_MissingPluginDirectory_ShouldReturnNoPlugins() + { + var pluginManager = new PluginManager(Path.Combine(this.pluginDirectory, Guid.NewGuid().ToString())); + + var plugins = pluginManager.GetAvailablePlugins(); + + plugins.Should().HaveCount(0); + } + + [TestMethod] + public void PluginManager_ExistingPlugin_ShouldReturnExpectedPlugin() + { + var plugins = this.pluginManager.GetAvailablePlugins(); + + plugins.Should().HaveCount(1); + plugins.First().Name.Should().Be("Plum.Net.Tests.SimplePlugin"); + } + + [TestMethod] + public void PluginManager_VersionValidator_ValidatesVersion() + { + var called = false; + var validator = Substitute.For(); + this.pluginManager.WithEnvironmentVersionValidator(validator); + validator.Validate(Arg.Any(), Arg.Any()).Returns(callinfo => + { + called = true; + return true; + }); + + var plugins = this.pluginManager.GetAvailablePlugins().ToList(); + + called.Should().BeTrue(); + } + + [TestMethod] + public void PluginManager_VersionValidator_CurrentVersionMatchesEnvironment() + { + var validator = Substitute.For(); + this.pluginManager.WithEnvironmentVersionValidator(validator); + validator.Validate(Arg.Any(), Arg.Any()).Returns(callinfo => + { + var currentVersion = callinfo.ArgAt(0); + currentVersion.Should().Be(Environment.Version); + + return true; + }); + + var plugins = this.pluginManager.GetAvailablePlugins().ToList(); + } + + [TestMethod] + public void PluginManager_VersionValidatorDenies_ReturnsNoPlugins() + { + var validator = Substitute.For(); + this.pluginManager.WithEnvironmentVersionValidator(validator); + validator.Validate(Arg.Any(), Arg.Any()).Returns(callinfo => + { + return false; + }); + + var plugins = this.pluginManager.GetAvailablePlugins().ToList(); + + plugins.Should().HaveCount(0); + } + + [TestMethod] + public void PluginManager_TypeDefinitionsValidator_CallsValidator() + { + var called = false; + var validator = Substitute.For(); + this.pluginManager.WithTypeDefinitionsValidator(validator); + validator.Validate(Arg.Any>(), Arg.Any()).Returns(callinfo => + { + called = true; + return true; + }); + + var plugins = this.pluginManager.GetAvailablePlugins().ToList(); + called.Should().BeTrue(); + } + + [TestMethod] + public void PluginManager_TypeDefinitionsValidator_ReceivesMainType() + { + var validator = Substitute.For(); + this.pluginManager.WithTypeDefinitionsValidator(validator); + validator.Validate(Arg.Any>(), Arg.Any()).Returns(callinfo => + { + var typeDefinitions = callinfo.ArgAt>(0); + var metadataReader = callinfo.ArgAt(1); + typeDefinitions.Select(t => metadataReader.GetString(t.Name)).Any(t => t == "Main").Should().BeTrue(); + return true; + }); + + var plugins = this.pluginManager.GetAvailablePlugins().ToList(); + } + + [TestMethod] + public void PluginManager_MetadataValidator_CallsValidator() + { + var called = false; + var validator = Substitute.For(); + this.pluginManager.WithMetadataValidator(validator); + validator.Validate(Arg.Any()).Returns(callinfo => + { + var metadataReader = callinfo.ArgAt(0); + metadataReader.Should().NotBeNull(); + called = true; + return true; + }); + + var plugins = this.pluginManager.GetAvailablePlugins().ToList(); + called.Should().BeTrue(); + } + + [TestMethod] + public void PluginManager_LoadPlugins_ShouldSucceed() + { + var plugins = this.pluginManager.GetAvailablePlugins(); + + var results = this.pluginManager.LoadPlugins(plugins); + + results.Should().HaveCount(1); + results.First().Should().BeOfType(); + } + + [TestMethod] + public void PluginManager_LoadPlugins_ReturnsExpectedAssembly() + { + this.pluginManager.WithForceLoadDependencies(true); + var plugins = this.pluginManager.GetAvailablePlugins(); + + var results = this.pluginManager.LoadPlugins(plugins); + var assembly = results.OfType().First().Plugin.Assembly; + var mainType = assembly.GetTypes().First(t => t.Name == nameof(Main)); + var main = Activator.CreateInstance(mainType).As
(); + + main.Should().NotBeNull(); + main.ReturnTrue().Should().BeTrue(); + } + + [TestMethod] + [Ignore("This test fails when run together with the other tests, as the dependent assembly is loaded into the AppDomain by other tests")] + public void PluginManager_LoadPlugins_WithNoResolver_FailsToResolveAssembly() + { + var pluginManager = new PluginManager(this.pluginDirectory); + pluginManager.WithForceLoadDependencies(true); + var plugins = pluginManager.GetAvailablePlugins(); + + var results = pluginManager.LoadPlugins(plugins); + + results.First().Should().BeOfType(); + } + + [TestMethod] + public void PluginManager_LoadPlugins_WithNoResolverAndNoForcedDependencyResolve_Succeeds() + { + var pluginManager = new PluginManager(this.pluginDirectory); + pluginManager.WithForceLoadDependencies(false); + var plugins = pluginManager.GetAvailablePlugins(); + + var results = pluginManager.LoadPlugins(plugins); + + results.First().Should().BeOfType(); + } +} \ No newline at end of file diff --git a/Plumsy.Tests/Plumsy.Tests.csproj b/Plumsy.Tests/Plumsy.Tests.csproj new file mode 100644 index 0000000..d112ccc --- /dev/null +++ b/Plumsy.Tests/Plumsy.Tests.csproj @@ -0,0 +1,39 @@ + + + + net6.0 + enable + + false + true + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Plumsy.Tests/Resolvers/SimpleDependencyResolver.cs b/Plumsy.Tests/Resolvers/SimpleDependencyResolver.cs new file mode 100644 index 0000000..66944d6 --- /dev/null +++ b/Plumsy.Tests/Resolvers/SimpleDependencyResolver.cs @@ -0,0 +1,16 @@ +using Plum.Net.Resolvers; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace Plum.Net.Tests.Resolvers; + +public sealed class SimpleDependencyResolver : IDependencyResolver +{ + public bool TryResolveDependency(Assembly? requestingAssembly, string dependencyName, out string? path) + { + var dllName = dependencyName.Split(',').First(); + path = Path.Combine(Path.GetFullPath("Dependencies"), $"{dllName}.dll"); + return true; + } +} diff --git a/Plumsy.sln b/Plumsy.sln new file mode 100644 index 0000000..5b1d161 --- /dev/null +++ b/Plumsy.sln @@ -0,0 +1,46 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.7.34024.191 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Plumsy", "Plumsy\Plumsy.csproj", "{A9C9E517-90BE-4B5D-8B06-491DF4F125EF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Plumsy.Tests.SimplePlugin", "Plumsy.Tests.SimplePlugin\Plumsy.Tests.SimplePlugin.csproj", "{93D702F9-320D-4490-AE7B-8F6D70A1B2DB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Plumsy.Tests", "Plumsy.Tests\Plumsy.Tests.csproj", "{0C18CB86-2495-4713-A27C-C06321CC8539}" + ProjectSection(ProjectDependencies) = postProject + {93D702F9-320D-4490-AE7B-8F6D70A1B2DB} = {93D702F9-320D-4490-AE7B-8F6D70A1B2DB} + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipelines", "Pipelines", "{EB205ABC-DAFB-4F8D-A7E0-E83EC03A2678}" + ProjectSection(SolutionItems) = preProject + .github\workflows\cd.yaml = .github\workflows\cd.yaml + .github\workflows\ci.yaml = .github\workflows\ci.yaml + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A9C9E517-90BE-4B5D-8B06-491DF4F125EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9C9E517-90BE-4B5D-8B06-491DF4F125EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9C9E517-90BE-4B5D-8B06-491DF4F125EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9C9E517-90BE-4B5D-8B06-491DF4F125EF}.Release|Any CPU.Build.0 = Release|Any CPU + {93D702F9-320D-4490-AE7B-8F6D70A1B2DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93D702F9-320D-4490-AE7B-8F6D70A1B2DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93D702F9-320D-4490-AE7B-8F6D70A1B2DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93D702F9-320D-4490-AE7B-8F6D70A1B2DB}.Release|Any CPU.Build.0 = Release|Any CPU + {0C18CB86-2495-4713-A27C-C06321CC8539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C18CB86-2495-4713-A27C-C06321CC8539}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C18CB86-2495-4713-A27C-C06321CC8539}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C18CB86-2495-4713-A27C-C06321CC8539}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D3A216E7-9DC1-445B-8C7F-F080CC413731} + EndGlobalSection +EndGlobal diff --git a/Plumsy/Callbacks/IAssemblyLoadedCallback.cs b/Plumsy/Callbacks/IAssemblyLoadedCallback.cs new file mode 100644 index 0000000..71f30e6 --- /dev/null +++ b/Plumsy/Callbacks/IAssemblyLoadedCallback.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Plum.Net.Callbacks; + +public interface IAssemblyLoadedCallback +{ + void AssemblyLoaded(Assembly assembly); +} diff --git a/Plumsy/Exceptions/PluginLoadException.cs b/Plumsy/Exceptions/PluginLoadException.cs new file mode 100644 index 0000000..09732fe --- /dev/null +++ b/Plumsy/Exceptions/PluginLoadException.cs @@ -0,0 +1,13 @@ +using Plum.Net.Models; + +namespace Plum.Net.Exceptions; + +public sealed class PluginLoadException : Exception +{ + public PluginEntry Plugin { get; } + + public PluginLoadException(string? message, Exception? innerException, PluginEntry plugin) : base(message, innerException) + { + this.Plugin = plugin; + } +} diff --git a/Plumsy/Models/Plugin.cs b/Plumsy/Models/Plugin.cs new file mode 100644 index 0000000..9967340 --- /dev/null +++ b/Plumsy/Models/Plugin.cs @@ -0,0 +1,13 @@ +using System.Reflection; + +namespace Plum.Net.Models; + +public sealed class Plugin +{ + public PluginEntry PluginEntry { get; init; } = default!; + public Assembly Assembly { get; init; } = default!; + + internal Plugin() + { + } +} diff --git a/Plumsy/Models/PluginEntry.cs b/Plumsy/Models/PluginEntry.cs new file mode 100644 index 0000000..6768228 --- /dev/null +++ b/Plumsy/Models/PluginEntry.cs @@ -0,0 +1,11 @@ +namespace Plum.Net.Models; + +public sealed class PluginEntry +{ + public string Name { get; init; } + public string Path { get; init; } + + internal PluginEntry() + { + } +} diff --git a/Plumsy/Models/PluginLoadOperation.cs b/Plumsy/Models/PluginLoadOperation.cs new file mode 100644 index 0000000..eb78f4f --- /dev/null +++ b/Plumsy/Models/PluginLoadOperation.cs @@ -0,0 +1,56 @@ +namespace Plum.Net.Models; + +public abstract class PluginLoadOperation +{ + public PluginEntry PluginEntry { get; init; } = default!; + public abstract string Description { get; } + + public sealed class Success : PluginLoadOperation + { + public Plugin Plugin { get; init; } = default!; + public override string Description => "Plugin loaded successfully"; + + internal Success() + { + } + } + + public sealed class NullEntry : PluginLoadOperation + { + public override string Description => $"Provided {nameof(PluginEntry)} is null"; + + internal NullEntry() + { + } + } + + public sealed class FileNotFound : PluginLoadOperation + { + public string Path { get; init; } = default!; + public override string Description => $"Failed to load plugin. Check {nameof(Path)} property for details"; + + internal FileNotFound() + { + } + } + + public sealed class ExceptionEncountered : PluginLoadOperation + { + public override string Description => "Exception encountered while loading plugin"; + public Exception Exception { get; init; } = default!; + + internal ExceptionEncountered() + { + } + } + + public sealed class UnexpectedErrorOccurred : PluginLoadOperation + { + public override string Description { get; } + + internal UnexpectedErrorOccurred(string description) + { + this.Description = description; + } + } +} diff --git a/Plumsy/PluginManager.cs b/Plumsy/PluginManager.cs new file mode 100644 index 0000000..f6baa9c --- /dev/null +++ b/Plumsy/PluginManager.cs @@ -0,0 +1,361 @@ +using Plum.Net.Callbacks; +using Plum.Net.Exceptions; +using Plum.Net.Models; +using Plum.Net.Resolvers; +using Plum.Net.Validators; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; + +namespace Plum.Net; + +public sealed class PluginManager +{ + private static readonly object Lock = new(); + + private readonly string pluginsBaseDirectory; + private readonly List environmentVersionValidators = new(); + private readonly List metadataValidators = new(); + private readonly List typeDefinitionsValidators = new(); + private readonly List assemblyLoadCallbacks = new(); + private readonly List dependencyResolvers = new(); + + private bool locked = false; + private bool attachedDomainEvents = false; + private bool forceLoadDependencies = true; + + public PluginManager(string pluginsBaseDirectory) + { + this.pluginsBaseDirectory = Path.GetFullPath(pluginsBaseDirectory); + } + + public PluginManager WithEnvironmentVersionValidator(IEnvironmentVersionValidator environmentVersionValidator) + { + if (this.locked) + { + throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore"); + } + + this.environmentVersionValidators.Add(environmentVersionValidator); + return this; + } + + public PluginManager WithMetadataValidator(IMetadataValidator metadataValidator) + { + if (this.locked) + { + throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore"); + } + + this.metadataValidators.Add(metadataValidator); + return this; + } + + public PluginManager WithTypeDefinitionsValidator(ITypeDefinitionsValidator typeDefinitionsValidator) + { + if (this.locked) + { + throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore"); + } + + this.typeDefinitionsValidators.Add(typeDefinitionsValidator); + return this; + } + + public PluginManager WithAssemblyLoadedCallback(IAssemblyLoadedCallback assemblyLoadedCallback) + { + if (this.locked) + { + throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore"); + } + + this.assemblyLoadCallbacks.Add(assemblyLoadedCallback); + return this; + } + + /// + /// Add a to the . To ensure that this is called, + /// call with parameter. + /// + /// + /// + /// + public PluginManager WithDependencyResolver(IDependencyResolver dependencyResolver) + { + if (this.locked) + { + throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore"); + } + + this.dependencyResolvers.Add(dependencyResolver); + return this; + } + + /// + /// When false, loading plugins might not trigger dependency resolvers, as the runtime loads dependencies lazily. + /// By default, this value is true. + /// + /// + public PluginManager WithForceLoadDependencies(bool forceLoadDependencies) + { + if (this.locked) + { + throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore"); + } + + this.forceLoadDependencies = forceLoadDependencies; + return this; + } + + /// + /// Enumerates over all the plugins found in the configured directory. + /// + /// Validated list of plugin entries that can be loaded by the plugin manager. + public IEnumerable GetAvailablePlugins() + { + if (Directory.Exists(pluginsBaseDirectory)) + { + foreach (var potentialPluginPath in Directory.GetFiles(pluginsBaseDirectory, "*.dll", SearchOption.AllDirectories)) + { + if (this.ValidatePlugin(potentialPluginPath, out var plugin)) + { + yield return plugin!; + } + } + } + } + + /// + /// Loads a list of plugins. This operation locks the and no more validators/callbacks/resolvers can be added anymore. + /// + /// List of results in the same order as the provided plugins. + /// Throws when provided plugins are null. + public IEnumerable LoadPlugins(IEnumerable plugins) + { + _ = plugins ?? throw new ArgumentNullException(nameof(plugins)); + try + { + while (!Monitor.TryEnter(Lock)) { } + + this.locked = true; + if (!this.attachedDomainEvents) + { + AppDomain.CurrentDomain.AssemblyLoad += this.AssemblyLoaded; + AppDomain.CurrentDomain.AssemblyResolve += this.AssemblyResolve; + this.attachedDomainEvents = true; + } + + var results = this.LoadPluginsInternal(plugins).ToList(); + return results; + } + finally + { + Monitor.Exit(Lock); + } + } + + private IEnumerable LoadPluginsInternal(IEnumerable plugins) + { + foreach (var plugin in plugins) + { + if (plugin is null) + { + yield return new PluginLoadOperation.NullEntry(); + continue; + } + + if (!File.Exists(plugin.Path)) + { + yield return new PluginLoadOperation.FileNotFound { PluginEntry = plugin, Path = plugin.Path }; + continue; + } + + var success = false; + PluginLoadException pluginLoadException = default!; + Assembly assembly = default!; + try + { + assembly = Assembly.LoadFrom(plugin.Path); + if (this.forceLoadDependencies) + { + ForceLoadDependenciesOfLoadedAssembly(assembly); + } + + success = true; + } + catch (Exception e) + { + success = false; + pluginLoadException = new PluginLoadException("Failed to load plugin. Check inner exception for details", e, plugin); + } + + if (success) + { + if (assembly is not null) + { + yield return new PluginLoadOperation.Success { Plugin = new Plugin { Assembly = assembly, PluginEntry = plugin }, PluginEntry = plugin }; + } + else + { + yield return new PluginLoadOperation.UnexpectedErrorOccurred($"Plugin loaded without any error but no Assembly was obtained from {nameof(Assembly.LoadFrom)}") { PluginEntry = plugin }; + } + } + else + { + yield return new PluginLoadOperation.ExceptionEncountered { Exception = pluginLoadException!, PluginEntry = plugin }; + } + } + } + + private void AssemblyLoaded(object? _, AssemblyLoadEventArgs assemblyLoadEventArgs) + { + foreach (var callback in this.assemblyLoadCallbacks) + { + callback.AssemblyLoaded(assemblyLoadEventArgs.LoadedAssembly); + } + } + + private Assembly AssemblyResolve(object? _, ResolveEventArgs resolveEventArgs) + { + foreach (var resolver in this.dependencyResolvers) + { + if (resolver.TryResolveDependency(resolveEventArgs.RequestingAssembly, resolveEventArgs.Name, out var assemblyPath) && + assemblyPath is string resolvedPath) + { + return Assembly.LoadFrom(resolvedPath); + } + } + + throw new InvalidOperationException($"Unable to resolve dependency {resolveEventArgs.Name}. Requesting assembly: {resolveEventArgs.RequestingAssembly}"); + } + + private bool ValidatePlugin(string path, out PluginEntry? plugin) + { + if (!File.Exists(path)) + { + plugin = default; + return false; + } + + var pluginName = Path.GetFileNameWithoutExtension(path); + plugin = new PluginEntry { Name = pluginName, Path = path }; + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + using var peReader = new PEReader(stream); + var metadataReader = peReader.GetMetadataReader(); + + if (!this.IsValidPlugin(metadataReader)) + { + return false; + } + + if (!this.IsTargetingCorrectDotnetVersion(metadataReader)) + { + return false; + } + + var typeDefinitions = metadataReader.TypeDefinitions.Select(metadataReader.GetTypeDefinition); + if (!this.HasValidTypeDefinitions(typeDefinitions, metadataReader)) + { + return false; + } + + return true; + } + + private bool IsTargetingCorrectDotnetVersion(MetadataReader metadataReader) + { + foreach (var referenceHandle in metadataReader.AssemblyReferences) + { + var reference = metadataReader.GetAssemblyReference(referenceHandle); + var name = metadataReader.GetString(reference.Name); + + if (name.Equals("System.Runtime", StringComparison.OrdinalIgnoreCase)) + { + /* + * If any validator rejects the plugin version, reject the plugin. + * Otherwise, accept the plugin. + */ + var pluginVersion = reference.Version; + var currentVersion = Environment.Version; + foreach(var validator in this.environmentVersionValidators) + { + if (!validator.Validate(currentVersion, pluginVersion)) + { + return false; + } + } + + return true; + } + } + + return false; + } + + private bool HasValidTypeDefinitions(IEnumerable typeDefinitions, MetadataReader metadataReader) + { + foreach(var validator in this.typeDefinitionsValidators) + { + if (!validator.Validate(typeDefinitions, metadataReader)) + { + return false; + } + } + + return true; + } + + private bool IsValidPlugin(MetadataReader metadataReader) + { + foreach(var validator in this.metadataValidators) + { + if (!validator.Validate(metadataReader)) + { + return false; + } + } + + return true; + } + + private static void ForceLoadDependenciesOfLoadedAssembly(Assembly assembly) + { + foreach (var type in assembly.GetTypes()) + { + // We'll force JIT compilation of all the methods, constructors and property setter/getter, which in turn forces dependencies to load + foreach (var method in type.GetMethods(BindingFlags.DeclaredOnly | + BindingFlags.Public | + BindingFlags.NonPublic | + BindingFlags.Instance | + BindingFlags.Static)) + { + RuntimeHelpers.PrepareMethod(method.MethodHandle); + } + + foreach(var constructor in type.GetConstructors()) + { + RuntimeHelpers.PrepareMethod(constructor.MethodHandle); + } + + foreach (var property in type.GetProperties(BindingFlags.Public | + BindingFlags.NonPublic | + BindingFlags.Instance | + BindingFlags.Static)) + { + MethodInfo? getMethod = property.GetGetMethod(nonPublic: true); + MethodInfo? setMethod = property.GetSetMethod(nonPublic: true); + + if (getMethod is not null) + { + RuntimeHelpers.PrepareMethod(getMethod.MethodHandle); + } + + if (setMethod is not null) + { + RuntimeHelpers.PrepareMethod(setMethod.MethodHandle); + } + } + } + } +} diff --git a/Plumsy/Plumsy.csproj b/Plumsy/Plumsy.csproj new file mode 100644 index 0000000..ba54aa1 --- /dev/null +++ b/Plumsy/Plumsy.csproj @@ -0,0 +1,33 @@ + + + + net6.0 + enable + enable + true + latest + Alexandru Macocian + + + Plugin management library. + 1.0 + 1.0 + https://github.com/AlexMacocian/Plum + 1.0 + true + true + LICENSE + + + + + + + + + True + + + + + diff --git a/Plumsy/Resolvers/IDependencyResolver.cs b/Plumsy/Resolvers/IDependencyResolver.cs new file mode 100644 index 0000000..7e366ac --- /dev/null +++ b/Plumsy/Resolvers/IDependencyResolver.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Plum.Net.Resolvers; + +public interface IDependencyResolver +{ + bool TryResolveDependency(Assembly? requestingAssembly, string dependencyName, out string? path); +} diff --git a/Plumsy/Validators/IEnvironmentVersionValidator.cs b/Plumsy/Validators/IEnvironmentVersionValidator.cs new file mode 100644 index 0000000..2d7dfcf --- /dev/null +++ b/Plumsy/Validators/IEnvironmentVersionValidator.cs @@ -0,0 +1,12 @@ +namespace Plum.Net.Validators; + +public interface IEnvironmentVersionValidator +{ + /// + /// Validate the version of .net of the plugin in comparison to the version of .net of the running assembly. + /// + /// .net version of the currently running assembly + /// .net version of the plugin + /// + bool Validate(Version currentVersion, Version pluginVersion); +} diff --git a/Plumsy/Validators/IMetadataValidator.cs b/Plumsy/Validators/IMetadataValidator.cs new file mode 100644 index 0000000..ae9ace6 --- /dev/null +++ b/Plumsy/Validators/IMetadataValidator.cs @@ -0,0 +1,11 @@ +using System.Reflection.Metadata; + +namespace Plum.Net.Validators; + +/// +/// A general validator that validates a plugin based on the entire metadata +/// +public interface IMetadataValidator +{ + bool Validate(MetadataReader metadataReader); +} diff --git a/Plumsy/Validators/ITypeDefinitionsValidator.cs b/Plumsy/Validators/ITypeDefinitionsValidator.cs new file mode 100644 index 0000000..38dcdad --- /dev/null +++ b/Plumsy/Validators/ITypeDefinitionsValidator.cs @@ -0,0 +1,14 @@ +using System.Reflection.Metadata; + +namespace Plum.Net.Validators; + +/// +/// A validator that validates a plugin based on the TypeDefinitions it contains. +/// +/// +/// Useful when you want to validate if the plugin contains a certain type that may implement a base type, in order to determine the entry point of the plugin. +/// +public interface ITypeDefinitionsValidator +{ + bool Validate(IEnumerable typeDefinitions, MetadataReader metadataReader); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..62e6e87 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Plumsy +## Plugin management library +Plumsy is a library for plugin management in C#. It is capable of inspecting and validating dlls, before loading them into the current AppDomain. + +Plumsy offers a set of extensions and callbacks to allow the caller to configure what plugins to load, how to handle plugin dependencies and runtime differences. + +## Examples + +### Create an instance of PluginManager +```C# +var pluginManager = new PluginManager(pathToPlugins); +```` + +### Retrieve a list of available plugins +```C# +var availablePlugins = pluginManager.GetAvailablePlugins(); +``` +Plumsy will call the validators for each of the dlls found in the `pathToPlugins` and validate the metadata of those dlls. If the dlls pass the validation, they are marked as available and returned by the above call. + +### Load plugins into the current AppDomain +```C# +var results = pluginManager.LoadPlugins(availablePlugins); +``` +Plumsy will attempt to load the provided list of plugin entries into the current AppDomain. `results` is a list of `PluginLoadOperation`, the type depending on the result of the load operation. +- `PluginLoadOperation.Success` contains a reference to the loaded assembly and implies that the plugin has been loaded successfully. +- `PluginLoadOperation.NullEntry` implies that the plugin entry was null. +- `PluginLoadOperation.FileNotFound` implies that the plugin path was not found. +- `PluginLoadOperation.ExceptionEncountered` implies that the plugin failed to load due to an exception. Check the inner `Exception` property for details. +- `PluginLoadOperation.UnexpectedErrorOccurred` implies that an unexpected error occurred. This happens when the `Assembly.Load` call succeeds but the returned assembly is `null`. + +### Setup validators +```C# +pluginManager + .WithEnvironmentVersionValidator(environmentVersionValidator) + .WithMetadataValidator(metadataValidator) + .WithTypeDefinitionsValidator(typeDefinitionsValidator); +``` +- `environmentVersionValidator` is of type `IEnvironmentVersionValidator`. This validator is used to validate the `Version` of the current runtime in comparison to the plugin target runtime. +- `metadataValidator` is of type `IMetadataValidator`. This validator receives a reference to the `MetadataReader` and can perform general validations over the plugin. +- `typeDefinitionsValidator` is of type `ITypeDefinitionsValidator`. This validator receives a reference to the `MetadataReader` as well as a list of `Type`s in order to perform validations over the plugin. This is useful when wanting to validate that the plugin contains a specific entry point class of a known type. + +### Callbacks +```C# +pluginManager + .WithAssemblyLoadedCallback(assemblyLoadedCallback); +``` +- `assemblyLoadedCallback` is of type `IAssemblyLoadedCallback`. This callback is called after an assembly has been loaded into the current AppDomain. + +### Dependency Resolving +```C# +pluginManager + .WithDependencyResolver(dependencyResolver); +``` +- `dependencyResolver` is of type `IDependencyResolver`. The dependency solver is called when the runtime is unable to find some dependencies of the currently loading plugin. *!! Due to the runtime lazily loading types, dependencies might only be resolved when calling specific methods !!* + +```C# +pluginManager + .WithForceLoadDependencies(true|false); +``` +- When forcing the Plumsy to load dependencies, Plumsy will attempt to manually cause JIT-ing of the methods in the loaded assembly. This would in turn cause the runtime to load its dependencies and would trigger `IDependencyResolver.TryResolveDependency` in cases where a dependency is not found. +- By default, Plumsy forces dependencies to load on assembly load. If you want instead to lazily load dependencies, call `pluginManager.WithForceLoadDependencies(false)`. \ No newline at end of file