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