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
}

View File

@@ -1,3 +1,5 @@
using System.Runtime.CompilerServices;
using StellaOps.Plugin.Versioning;
[assembly: InternalsVisibleTo("StellaOps.Plugin.Tests")]
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]

View File

@@ -0,0 +1,260 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Plugin.Security;
/// <summary>
/// Verifies plugin signatures using Cosign.
/// </summary>
/// <remarks>
/// Requires Cosign to be installed and available in PATH.
/// Signature files are expected to be adjacent to the assembly with .sig extension.
/// </remarks>
public sealed class CosignPluginVerifier : IPluginSignatureVerifier
{
private readonly CosignVerifierOptions _options;
private readonly ILogger<CosignPluginVerifier>? _logger;
public CosignPluginVerifier(CosignVerifierOptions options, ILogger<CosignPluginVerifier>? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
}
public async Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(assemblyPath))
{
throw new ArgumentException("Assembly path cannot be null or empty.", nameof(assemblyPath));
}
if (!File.Exists(assemblyPath))
{
return new SignatureVerificationResult(
IsValid: false,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: $"Assembly file not found: {assemblyPath}",
CertificateChain: null);
}
var signaturePath = GetSignaturePath(assemblyPath);
if (!File.Exists(signaturePath))
{
if (_options.AllowUnsigned)
{
_logger?.LogWarning("Plugin '{Assembly}' is unsigned but unsigned plugins are allowed.", Path.GetFileName(assemblyPath));
return new SignatureVerificationResult(
IsValid: true,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: null,
CertificateChain: null);
}
return new SignatureVerificationResult(
IsValid: false,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: $"Signature file not found: {signaturePath}",
CertificateChain: null);
}
return await VerifyWithCosignAsync(assemblyPath, signaturePath, cancellationToken).ConfigureAwait(false);
}
private async Task<SignatureVerificationResult> VerifyWithCosignAsync(
string assemblyPath,
string signaturePath,
CancellationToken cancellationToken)
{
var cosignPath = _options.CosignPath ?? "cosign";
var arguments = BuildCosignArguments(assemblyPath, signaturePath);
_logger?.LogDebug("Verifying plugin signature: {CosignPath} {Arguments}", cosignPath, arguments);
try
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = cosignPath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var output = await outputTask.ConfigureAwait(false);
var error = await errorTask.ConfigureAwait(false);
if (process.ExitCode == 0)
{
var (signerIdentity, timestamp) = ParseCosignOutput(output);
_logger?.LogInformation("Plugin '{Assembly}' signature verified. Signer: {Signer}", Path.GetFileName(assemblyPath), signerIdentity ?? "unknown");
return new SignatureVerificationResult(
IsValid: true,
SignerIdentity: signerIdentity,
SignatureTimestamp: timestamp,
FailureReason: null,
CertificateChain: output);
}
_logger?.LogError("Plugin '{Assembly}' signature verification failed: {Error}", Path.GetFileName(assemblyPath), error);
return new SignatureVerificationResult(
IsValid: false,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: string.IsNullOrWhiteSpace(error) ? "Signature verification failed" : error.Trim(),
CertificateChain: null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger?.LogError(ex, "Failed to execute Cosign for plugin '{Assembly}'.", Path.GetFileName(assemblyPath));
return new SignatureVerificationResult(
IsValid: false,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: $"Cosign execution failed: {ex.Message}",
CertificateChain: null);
}
}
private string BuildCosignArguments(string assemblyPath, string signaturePath)
{
var args = $"verify-blob --signature \"{signaturePath}\"";
if (!string.IsNullOrWhiteSpace(_options.PublicKeyPath))
{
args += $" --key \"{_options.PublicKeyPath}\"";
}
if (!string.IsNullOrWhiteSpace(_options.CertificatePath))
{
args += $" --certificate \"{_options.CertificatePath}\"";
}
if (!string.IsNullOrWhiteSpace(_options.CertificateIdentity))
{
args += $" --certificate-identity \"{_options.CertificateIdentity}\"";
}
if (!string.IsNullOrWhiteSpace(_options.CertificateOidcIssuer))
{
args += $" --certificate-oidc-issuer \"{_options.CertificateOidcIssuer}\"";
}
if (_options.UseRekorTransparencyLog)
{
args += " --rekor-url https://rekor.sigstore.dev";
}
else
{
args += " --insecure-ignore-tlog";
}
args += $" \"{assemblyPath}\"";
return args;
}
private static string GetSignaturePath(string assemblyPath)
{
return assemblyPath + ".sig";
}
private static (string? SignerIdentity, DateTimeOffset? Timestamp) ParseCosignOutput(string output)
{
string? signerIdentity = null;
DateTimeOffset? timestamp = null;
try
{
if (output.Contains("\""))
{
using var doc = JsonDocument.Parse(output);
if (doc.RootElement.TryGetProperty("optional", out var optional))
{
if (optional.TryGetProperty("Subject", out var subject))
{
signerIdentity = subject.GetString();
}
}
}
}
catch
{
// Output may not be JSON; extract identity from text
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.Contains("Subject:"))
{
signerIdentity = line.Split(':', 2)[1].Trim();
}
}
}
return (signerIdentity, timestamp);
}
}
/// <summary>
/// Configuration options for Cosign-based plugin verification.
/// </summary>
public sealed class CosignVerifierOptions
{
/// <summary>
/// Path to the Cosign executable. Defaults to "cosign" (must be in PATH).
/// </summary>
public string? CosignPath { get; set; }
/// <summary>
/// Path to the public key file for verification.
/// </summary>
public string? PublicKeyPath { get; set; }
/// <summary>
/// Path to the certificate file for verification.
/// </summary>
public string? CertificatePath { get; set; }
/// <summary>
/// Expected certificate identity (email or URI).
/// </summary>
public string? CertificateIdentity { get; set; }
/// <summary>
/// Expected OIDC issuer for the certificate.
/// </summary>
public string? CertificateOidcIssuer { get; set; }
/// <summary>
/// Whether to verify against the Rekor transparency log. Defaults to true.
/// </summary>
public bool UseRekorTransparencyLog { get; set; } = true;
/// <summary>
/// Whether to allow loading unsigned plugins. Defaults to false.
/// Should only be true in development/testing scenarios.
/// </summary>
public bool AllowUnsigned { get; set; }
}

View File

@@ -0,0 +1,33 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Plugin.Security;
/// <summary>
/// Provides plugin signature verification to ensure integrity and authenticity.
/// </summary>
public interface IPluginSignatureVerifier
{
/// <summary>
/// Verifies the signature of a plugin assembly file.
/// </summary>
/// <param name="assemblyPath">The full path to the plugin assembly.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating verification status.</returns>
Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default);
}
/// <summary>
/// Represents the result of a signature verification operation.
/// </summary>
/// <param name="IsValid">True if the signature is valid.</param>
/// <param name="SignerIdentity">The identity of the signer, if available.</param>
/// <param name="SignatureTimestamp">When the artifact was signed, if available.</param>
/// <param name="FailureReason">Description of verification failure, if applicable.</param>
/// <param name="CertificateChain">Certificate chain information, if available.</param>
public sealed record SignatureVerificationResult(
bool IsValid,
string? SignerIdentity,
System.DateTimeOffset? SignatureTimestamp,
string? FailureReason,
string? CertificateChain);

View File

@@ -0,0 +1,29 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Plugin.Security;
/// <summary>
/// A no-op plugin verifier that always returns valid.
/// Use only in development/testing scenarios where signature verification is not required.
/// </summary>
public sealed class NullPluginVerifier : IPluginSignatureVerifier
{
/// <summary>
/// Gets the singleton instance.
/// </summary>
public static NullPluginVerifier Instance { get; } = new();
private NullPluginVerifier() { }
/// <inheritdoc />
public Task<SignatureVerificationResult> VerifyAsync(string assemblyPath, CancellationToken cancellationToken = default)
{
return Task.FromResult(new SignatureVerificationResult(
IsValid: true,
SignerIdentity: null,
SignatureTimestamp: null,
FailureReason: null,
CertificateChain: null));
}
}

View File

@@ -0,0 +1,209 @@
using System;
using System.Reflection;
namespace StellaOps.Plugin.Versioning;
/// <summary>
/// Provides plugin version compatibility checking against the host application.
/// </summary>
public static class PluginCompatibilityChecker
{
/// <summary>
/// Checks if a plugin assembly is compatible with the specified host version.
/// Uses lenient defaults (does not require version attribute, no strict major version check).
/// For production use, prefer the overload that accepts <see cref="CompatibilityCheckOptions"/>.
/// </summary>
/// <param name="pluginAssembly">The plugin assembly to check.</param>
/// <param name="hostVersion">The host application version.</param>
/// <returns>A result indicating compatibility status.</returns>
public static PluginCompatibilityResult CheckCompatibility(Assembly pluginAssembly, Version hostVersion)
{
return CheckCompatibility(pluginAssembly, hostVersion, CompatibilityCheckOptions.Lenient);
}
/// <summary>
/// Checks if a plugin assembly is compatible with the specified host version using the provided options.
/// </summary>
/// <param name="pluginAssembly">The plugin assembly to check.</param>
/// <param name="hostVersion">The host application version.</param>
/// <param name="options">Options controlling compatibility checking behavior.</param>
/// <returns>A result indicating compatibility status.</returns>
public static PluginCompatibilityResult CheckCompatibility(
Assembly pluginAssembly,
Version hostVersion,
CompatibilityCheckOptions options)
{
if (pluginAssembly == null)
{
throw new ArgumentNullException(nameof(pluginAssembly));
}
if (hostVersion == null)
{
throw new ArgumentNullException(nameof(hostVersion));
}
options ??= CompatibilityCheckOptions.Default;
var attribute = pluginAssembly.GetCustomAttribute<StellaPluginVersionAttribute>();
if (attribute == null)
{
// No version attribute - fail if required
if (options.RequireVersionAttribute)
{
return new PluginCompatibilityResult(
IsCompatible: false,
PluginVersion: null,
MinimumHostVersion: null,
MaximumHostVersion: null,
RequiresSignature: true,
FailureReason: "Plugin does not declare a [StellaPluginVersion] attribute. All plugins must specify version compatibility.",
HasVersionAttribute: false);
}
return new PluginCompatibilityResult(
IsCompatible: true,
PluginVersion: null,
MinimumHostVersion: null,
MaximumHostVersion: null,
RequiresSignature: true,
FailureReason: null,
HasVersionAttribute: false);
}
var minVersion = attribute.GetMinimumHostVersion();
var maxVersion = attribute.GetMaximumHostVersion();
// Check minimum version
if (minVersion != null && hostVersion < minVersion)
{
return new PluginCompatibilityResult(
IsCompatible: false,
PluginVersion: attribute.PluginVersion,
MinimumHostVersion: minVersion,
MaximumHostVersion: maxVersion,
RequiresSignature: attribute.RequiresSignature,
FailureReason: $"Host version {hostVersion} is below minimum required version {minVersion}.",
HasVersionAttribute: true);
}
// Check maximum version (explicit)
if (maxVersion != null && hostVersion > maxVersion)
{
return new PluginCompatibilityResult(
IsCompatible: false,
PluginVersion: attribute.PluginVersion,
MinimumHostVersion: minVersion,
MaximumHostVersion: maxVersion,
RequiresSignature: attribute.RequiresSignature,
FailureReason: $"Host version {hostVersion} exceeds maximum supported version {maxVersion}.",
HasVersionAttribute: true);
}
// Strict major version check: when MaximumHostVersion is not specified,
// assume the plugin only supports the same major version range as its declared MinimumHostVersion
// This prevents loading a plugin designed for host 1.x on host 2.x without explicit declaration.
if (options.StrictMajorVersionCheck && maxVersion == null && minVersion != null)
{
var referenceMajor = minVersion.Major;
if (hostVersion.Major > referenceMajor)
{
return new PluginCompatibilityResult(
IsCompatible: false,
PluginVersion: attribute.PluginVersion,
MinimumHostVersion: minVersion,
MaximumHostVersion: maxVersion,
RequiresSignature: attribute.RequiresSignature,
FailureReason: $"Host major version {hostVersion.Major} exceeds plugin's declared compatibility range (major version {referenceMajor}). " +
$"Plugin must explicitly declare MaximumHostVersion to support host {hostVersion}.",
HasVersionAttribute: true);
}
}
// Strict major version check when NO MinimumHostVersion is specified but we have a plugin version
// Use the plugin's own major version as the reference
if (options.StrictMajorVersionCheck && maxVersion == null && minVersion == null)
{
var pluginMajor = attribute.PluginVersion.Major;
if (hostVersion.Major > pluginMajor)
{
return new PluginCompatibilityResult(
IsCompatible: false,
PluginVersion: attribute.PluginVersion,
MinimumHostVersion: minVersion,
MaximumHostVersion: maxVersion,
RequiresSignature: attribute.RequiresSignature,
FailureReason: $"Host major version {hostVersion.Major} exceeds plugin version {attribute.PluginVersion} with no explicit compatibility declaration. " +
$"Plugin must declare MinimumHostVersion and/or MaximumHostVersion to support host {hostVersion}.",
HasVersionAttribute: true);
}
}
return new PluginCompatibilityResult(
IsCompatible: true,
PluginVersion: attribute.PluginVersion,
MinimumHostVersion: minVersion,
MaximumHostVersion: maxVersion,
RequiresSignature: attribute.RequiresSignature,
FailureReason: null,
HasVersionAttribute: true);
}
}
/// <summary>
/// Options for controlling plugin compatibility checking behavior.
/// </summary>
public sealed class CompatibilityCheckOptions
{
/// <summary>
/// Default options for production use. Requires version attribute and enforces strict major version checking.
/// </summary>
public static CompatibilityCheckOptions Default { get; } = new()
{
RequireVersionAttribute = true,
StrictMajorVersionCheck = true
};
/// <summary>
/// Lenient options for backward compatibility. Does not require version attribute, no strict major version check.
/// </summary>
public static CompatibilityCheckOptions Lenient { get; } = new()
{
RequireVersionAttribute = false,
StrictMajorVersionCheck = false
};
/// <summary>
/// Whether plugins must have a <see cref="StellaPluginVersionAttribute"/>.
/// When true, plugins without the attribute will be rejected.
/// Default: true.
/// </summary>
public bool RequireVersionAttribute { get; init; } = true;
/// <summary>
/// Whether to enforce strict major version boundary checking.
/// When true and MaximumHostVersion is not specified, the checker assumes
/// the plugin only supports host versions with the same major version.
/// Default: true.
/// </summary>
public bool StrictMajorVersionCheck { get; init; } = true;
}
/// <summary>
/// Represents the result of a plugin compatibility check.
/// </summary>
/// <param name="IsCompatible">True if the plugin is compatible with the host.</param>
/// <param name="PluginVersion">The plugin's declared version, if available.</param>
/// <param name="MinimumHostVersion">The minimum host version required, if specified.</param>
/// <param name="MaximumHostVersion">The maximum host version supported, if specified.</param>
/// <param name="RequiresSignature">Whether the plugin requires signature verification.</param>
/// <param name="FailureReason">Description of why compatibility check failed, if applicable.</param>
/// <param name="HasVersionAttribute">True if the plugin has a StellaPluginVersion attribute.</param>
public sealed record PluginCompatibilityResult(
bool IsCompatible,
Version? PluginVersion,
Version? MinimumHostVersion,
Version? MaximumHostVersion,
bool RequiresSignature,
string? FailureReason,
bool HasVersionAttribute);

View File

@@ -0,0 +1,76 @@
using System;
namespace StellaOps.Plugin.Versioning;
/// <summary>
/// Declares the minimum host version required by this plugin assembly.
/// The plugin host uses this attribute to verify compatibility before loading.
/// </summary>
/// <remarks>
/// Apply this attribute at the assembly level in your plugin project:
/// <code>
/// [assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0")]
/// </code>
/// </remarks>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)]
public sealed class StellaPluginVersionAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="StellaPluginVersionAttribute"/> class.
/// </summary>
/// <param name="pluginVersion">The semantic version of this plugin (e.g., "1.2.3").</param>
public StellaPluginVersionAttribute(string pluginVersion)
{
PluginVersion = ParseVersion(pluginVersion, nameof(pluginVersion));
}
/// <summary>
/// Gets the semantic version of this plugin.
/// </summary>
public Version PluginVersion { get; }
/// <summary>
/// Gets or sets the minimum host version required for this plugin to function.
/// When specified, the plugin host will reject loading if the host version is below this value.
/// </summary>
public string? MinimumHostVersion { get; set; }
/// <summary>
/// Gets or sets the maximum host version supported by this plugin.
/// When specified, the plugin host will reject loading if the host version exceeds this value.
/// </summary>
public string? MaximumHostVersion { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this plugin requires signature verification.
/// Defaults to true. Set to false only for development/testing scenarios.
/// </summary>
public bool RequiresSignature { get; set; } = true;
/// <summary>
/// Gets the parsed minimum host version, or null if not specified.
/// </summary>
public Version? GetMinimumHostVersion() =>
string.IsNullOrWhiteSpace(MinimumHostVersion) ? null : ParseVersion(MinimumHostVersion, nameof(MinimumHostVersion));
/// <summary>
/// Gets the parsed maximum host version, or null if not specified.
/// </summary>
public Version? GetMaximumHostVersion() =>
string.IsNullOrWhiteSpace(MaximumHostVersion) ? null : ParseVersion(MaximumHostVersion, nameof(MaximumHostVersion));
private static Version ParseVersion(string version, string parameterName)
{
if (string.IsNullOrWhiteSpace(version))
{
throw new ArgumentException("Version string cannot be null or empty.", parameterName);
}
if (!Version.TryParse(version, out var parsed))
{
throw new ArgumentException($"Invalid version format: '{version}'. Expected format: Major.Minor[.Build[.Revision]]", parameterName);
}
return parsed;
}
}