up
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Security;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
@@ -12,7 +16,21 @@ public static class PluginHost
|
||||
private static readonly object Sync = new();
|
||||
private static readonly Dictionary<string, PluginAssembly> LoadedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Loads plugins synchronously. For signature verification support, use <see cref="LoadPluginsAsync"/>.
|
||||
/// </summary>
|
||||
public static PluginHostResult LoadPlugins(PluginHostOptions options, ILogger? logger = null)
|
||||
{
|
||||
return LoadPluginsAsync(options, logger, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads plugins asynchronously with optional signature verification and version compatibility checking.
|
||||
/// </summary>
|
||||
public static async Task<PluginHostResult> LoadPluginsAsync(
|
||||
PluginHostOptions options,
|
||||
ILogger? logger = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options == null)
|
||||
{
|
||||
@@ -30,7 +48,7 @@ public static class PluginHost
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
logger?.LogWarning("Plugin directory '{PluginDirectory}' does not exist; no plugins will be loaded.", pluginDirectory);
|
||||
return new PluginHostResult(pluginDirectory, Array.Empty<string>(), Array.Empty<PluginAssembly>(), Array.Empty<string>());
|
||||
return new PluginHostResult(pluginDirectory, Array.Empty<string>(), Array.Empty<PluginAssembly>(), Array.Empty<string>(), Array.Empty<PluginLoadFailure>());
|
||||
}
|
||||
|
||||
var searchPatterns = BuildSearchPatterns(options, pluginDirectory);
|
||||
@@ -38,35 +56,138 @@ public static class PluginHost
|
||||
var orderedFiles = ApplyExplicitOrdering(discovered, options.PluginOrder, out var missingOrderedNames);
|
||||
|
||||
var loaded = new List<PluginAssembly>(orderedFiles.Count);
|
||||
var failures = new List<PluginLoadFailure>();
|
||||
var verifier = options.SignatureVerifier ?? NullPluginVerifier.Instance;
|
||||
|
||||
lock (Sync)
|
||||
foreach (var file in orderedFiles)
|
||||
{
|
||||
foreach (var file in orderedFiles)
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Check cache first (thread-safe)
|
||||
lock (Sync)
|
||||
{
|
||||
if (LoadedPlugins.TryGetValue(file, out var existing))
|
||||
{
|
||||
loaded.Add(existing);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
// Verify signature
|
||||
if (options.EnforceSignatureVerification || options.SignatureVerifier != null)
|
||||
{
|
||||
var signatureResult = await verifier.VerifyAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
if (!signatureResult.IsValid)
|
||||
{
|
||||
if (options.EnforceSignatureVerification)
|
||||
{
|
||||
logger?.LogError("Plugin '{Plugin}' failed signature verification: {Reason}", Path.GetFileName(file), signatureResult.FailureReason);
|
||||
failures.Add(new PluginLoadFailure(file, PluginLoadFailureReason.SignatureInvalid, signatureResult.FailureReason ?? "Signature verification failed"));
|
||||
continue;
|
||||
}
|
||||
|
||||
logger?.LogWarning("Plugin '{Plugin}' has invalid signature but enforcement is disabled: {Reason}", Path.GetFileName(file), signatureResult.FailureReason);
|
||||
}
|
||||
else if (signatureResult.SignerIdentity != null)
|
||||
{
|
||||
logger?.LogDebug("Plugin '{Plugin}' signed by: {Signer}", Path.GetFileName(file), signatureResult.SignerIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
// Load assembly
|
||||
PluginAssembly? descriptor = null;
|
||||
try
|
||||
{
|
||||
var loadContext = new PluginLoadContext(file);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(file);
|
||||
|
||||
// Check version compatibility
|
||||
if (options.HostVersion != null)
|
||||
{
|
||||
var checkOptions = new CompatibilityCheckOptions
|
||||
{
|
||||
RequireVersionAttribute = options.RequireVersionAttribute,
|
||||
StrictMajorVersionCheck = options.StrictMajorVersionCheck
|
||||
};
|
||||
|
||||
var compatResult = PluginCompatibilityChecker.CheckCompatibility(assembly, options.HostVersion, checkOptions);
|
||||
|
||||
// Handle missing version attribute (separate failure reason)
|
||||
if (!compatResult.HasVersionAttribute && options.RequireVersionAttribute)
|
||||
{
|
||||
if (options.EnforceVersionCompatibility)
|
||||
{
|
||||
logger?.LogError("Plugin '{Plugin}' rejected: {Reason}", Path.GetFileName(file), compatResult.FailureReason);
|
||||
failures.Add(new PluginLoadFailure(file, PluginLoadFailureReason.MissingVersionAttribute, compatResult.FailureReason ?? "Missing version attribute"));
|
||||
continue;
|
||||
}
|
||||
|
||||
logger?.LogWarning("Plugin '{Plugin}' is missing version attribute: {Reason}", Path.GetFileName(file), compatResult.FailureReason);
|
||||
}
|
||||
else if (!compatResult.IsCompatible)
|
||||
{
|
||||
if (options.EnforceVersionCompatibility)
|
||||
{
|
||||
logger?.LogError("Plugin '{Plugin}' is incompatible with host version {HostVersion}: {Reason}", Path.GetFileName(file), options.HostVersion, compatResult.FailureReason);
|
||||
failures.Add(new PluginLoadFailure(file, PluginLoadFailureReason.IncompatibleVersion, compatResult.FailureReason ?? "Version incompatible"));
|
||||
continue;
|
||||
}
|
||||
|
||||
logger?.LogWarning("Plugin '{Plugin}' may be incompatible with host version {HostVersion}: {Reason}", Path.GetFileName(file), options.HostVersion, compatResult.FailureReason);
|
||||
}
|
||||
else if (compatResult.HasVersionAttribute)
|
||||
{
|
||||
logger?.LogDebug("Plugin '{Plugin}' version {PluginVersion} is compatible with host version {HostVersion}", Path.GetFileName(file), compatResult.PluginVersion, options.HostVersion);
|
||||
}
|
||||
}
|
||||
else if (options.RequireVersionAttribute)
|
||||
{
|
||||
// No host version set but we still require the attribute - warn
|
||||
var checkOptions = new CompatibilityCheckOptions
|
||||
{
|
||||
RequireVersionAttribute = true,
|
||||
StrictMajorVersionCheck = false
|
||||
};
|
||||
|
||||
var compatResult = PluginCompatibilityChecker.CheckCompatibility(assembly, new Version(0, 0, 0), checkOptions);
|
||||
if (!compatResult.HasVersionAttribute)
|
||||
{
|
||||
logger?.LogWarning("Plugin '{Plugin}' is missing [StellaPluginVersion] attribute. Configure HostVersion to enforce version checking.", Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
|
||||
descriptor = new PluginAssembly(file, assembly, loadContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file);
|
||||
failures.Add(new PluginLoadFailure(file, PluginLoadFailureReason.LoadError, ex.Message));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add to cache (thread-safe)
|
||||
lock (Sync)
|
||||
{
|
||||
if (!LoadedPlugins.TryGetValue(file, out var existing))
|
||||
{
|
||||
var loadContext = new PluginLoadContext(file);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(file);
|
||||
var descriptor = new PluginAssembly(file, assembly, loadContext);
|
||||
LoadedPlugins[file] = descriptor;
|
||||
loaded.Add(descriptor);
|
||||
logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", assembly.FullName, file);
|
||||
logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", descriptor.Assembly.FullName, file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file);
|
||||
loaded.Add(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var missingOrdered = new ReadOnlyCollection<string>(missingOrderedNames);
|
||||
return new PluginHostResult(pluginDirectory, searchPatterns, new ReadOnlyCollection<PluginAssembly>(loaded), missingOrdered);
|
||||
return new PluginHostResult(
|
||||
pluginDirectory,
|
||||
searchPatterns,
|
||||
new ReadOnlyCollection<PluginAssembly>(loaded),
|
||||
missingOrdered,
|
||||
new ReadOnlyCollection<PluginLoadFailure>(failures));
|
||||
}
|
||||
|
||||
private static string ResolvePluginDirectory(PluginHostOptions options, string baseDirectory)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StellaOps.Plugin.Security;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
public sealed class PluginHostOptions
|
||||
{
|
||||
private readonly List<string> additionalPrefixes = new();
|
||||
private readonly List<string> pluginOrder = new();
|
||||
private readonly List<string> searchPatterns = new();
|
||||
private readonly List<string> _additionalPrefixes = new();
|
||||
private readonly List<string> _pluginOrder = new();
|
||||
private readonly List<string> _searchPatterns = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional base directory used for resolving relative plugin paths. Defaults to <see cref="AppContext.BaseDirectory" />.
|
||||
@@ -29,18 +30,18 @@ public sealed class PluginHostOptions
|
||||
/// <summary>
|
||||
/// Additional prefixes that should be considered when building search patterns.
|
||||
/// </summary>
|
||||
public IList<string> AdditionalPrefixes => additionalPrefixes;
|
||||
public IList<string> AdditionalPrefixes => _additionalPrefixes;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit plugin ordering expressed as assembly names without extension.
|
||||
/// Entries that are not discovered will be reported in <see cref="PluginHostResult.MissingOrderedPlugins" />.
|
||||
/// </summary>
|
||||
public IList<string> PluginOrder => pluginOrder;
|
||||
public IList<string> PluginOrder => _pluginOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit search patterns. When empty, they are derived from prefix settings.
|
||||
/// </summary>
|
||||
public IList<string> SearchPatterns => searchPatterns;
|
||||
public IList<string> SearchPatterns => _searchPatterns;
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) the plugin directory will be created if it does not exist.
|
||||
@@ -52,6 +53,46 @@ public sealed class PluginHostOptions
|
||||
/// </summary>
|
||||
public bool RecursiveSearch { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// The host application version used for plugin compatibility checking.
|
||||
/// When set, plugins with <see cref="Versioning.StellaPluginVersionAttribute"/> will be validated.
|
||||
/// </summary>
|
||||
public Version? HostVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enforce version compatibility checking. Defaults to true.
|
||||
/// When false, incompatible plugins will be loaded with a warning.
|
||||
/// </summary>
|
||||
public bool EnforceVersionCompatibility { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether plugins must declare a <see cref="Versioning.StellaPluginVersionAttribute"/>. Defaults to true.
|
||||
/// When true, plugins without the version attribute will be rejected.
|
||||
/// This is recommended for production deployments to ensure all plugins are properly versioned.
|
||||
/// </summary>
|
||||
public bool RequireVersionAttribute { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enforce strict major version checking. Defaults to true.
|
||||
/// When true and a plugin does not specify MaximumHostVersion, the loader assumes
|
||||
/// the plugin only supports host versions with the same major version as MinimumHostVersion.
|
||||
/// This prevents loading plugins designed for host 1.x on host 2.x without explicit compatibility declaration.
|
||||
/// </summary>
|
||||
public bool StrictMajorVersionCheck { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// The signature verifier to use for plugin validation.
|
||||
/// Defaults to <see cref="NullPluginVerifier"/> (no verification).
|
||||
/// Set to <see cref="CosignPluginVerifier"/> for production use.
|
||||
/// </summary>
|
||||
public IPluginSignatureVerifier? SignatureVerifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enforce signature verification. Defaults to false.
|
||||
/// When true and <see cref="SignatureVerifier"/> is set, plugins without valid signatures will be rejected.
|
||||
/// </summary>
|
||||
public bool EnforceSignatureVerification { get; set; }
|
||||
|
||||
internal string ResolveBaseDirectory()
|
||||
=> string.IsNullOrWhiteSpace(BaseDirectory)
|
||||
? AppContext.BaseDirectory
|
||||
|
||||
@@ -2,25 +2,52 @@ using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Contains the results of a plugin loading operation.
|
||||
/// </summary>
|
||||
public sealed class PluginHostResult
|
||||
{
|
||||
internal PluginHostResult(
|
||||
string pluginDirectory,
|
||||
IReadOnlyList<string> searchPatterns,
|
||||
IReadOnlyList<PluginAssembly> plugins,
|
||||
IReadOnlyList<string> missingOrderedPlugins)
|
||||
IReadOnlyList<string> missingOrderedPlugins,
|
||||
IReadOnlyList<PluginLoadFailure>? failures = null)
|
||||
{
|
||||
PluginDirectory = pluginDirectory;
|
||||
SearchPatterns = searchPatterns;
|
||||
Plugins = plugins;
|
||||
MissingOrderedPlugins = missingOrderedPlugins;
|
||||
Failures = failures ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The directory that was searched for plugins.
|
||||
/// </summary>
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The search patterns used to discover plugins.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SearchPatterns { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Successfully loaded plugin assemblies.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PluginAssembly> Plugins { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugin names that were specified in the explicit ordering but were not found.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> MissingOrderedPlugins { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Plugins that failed to load due to signature, version, or other errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PluginLoadFailure> Failures { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any plugins failed to load.
|
||||
/// </summary>
|
||||
public bool HasFailures => Failures.Count > 0;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace StellaOps.Plugin.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a failure that occurred while attempting to load a plugin.
|
||||
/// </summary>
|
||||
/// <param name="AssemblyPath">The path to the plugin assembly that failed to load.</param>
|
||||
/// <param name="Reason">The category of failure.</param>
|
||||
/// <param name="Message">A detailed message describing the failure.</param>
|
||||
public sealed record PluginLoadFailure(
|
||||
string AssemblyPath,
|
||||
PluginLoadFailureReason Reason,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Categorizes the reason a plugin failed to load.
|
||||
/// </summary>
|
||||
public enum PluginLoadFailureReason
|
||||
{
|
||||
/// <summary>
|
||||
/// The plugin assembly could not be loaded due to an error.
|
||||
/// </summary>
|
||||
LoadError,
|
||||
|
||||
/// <summary>
|
||||
/// The plugin's signature verification failed.
|
||||
/// </summary>
|
||||
SignatureInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// The plugin is not compatible with the host version.
|
||||
/// </summary>
|
||||
IncompatibleVersion,
|
||||
|
||||
/// <summary>
|
||||
/// The plugin does not have a required StellaPluginVersion attribute.
|
||||
/// </summary>
|
||||
MissingVersionAttribute
|
||||
}
|
||||
Reference in New Issue
Block a user