This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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
}