up
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Plugin.BouncyCastle.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Plugin.Pkcs11Gost.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Plugin.PqSoft.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Plugin.SimRemote.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Plugin.SmRemote.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Plugin.SmSoft.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cryptography.Plugin.WineCsp.Tests")]
|
||||
[assembly: StellaPluginVersion("1.0.0", MinimumHostVersion = "1.0.0", MaximumHostVersion = "1.99.99")]
|
||||
@@ -8,7 +8,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.DependencyInjection.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring fail-fast options validation on application startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fail-fast validation ensures configuration errors are detected immediately at startup
|
||||
/// rather than at first use, following the principle of "fail early, fail loudly."
|
||||
/// </remarks>
|
||||
public static class FailFastOptionsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds options with a validator and configures fail-fast startup validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to configure.</typeparam>
|
||||
/// <typeparam name="TValidator">The validator type that implements <see cref="IValidateOptions{TOptions}"/>.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="sectionName">The configuration section name.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOptionsWithValidation<TOptions, TValidator>(
|
||||
this IServiceCollection services,
|
||||
string sectionName)
|
||||
where TOptions : class
|
||||
where TValidator : class, IValidateOptions<TOptions>
|
||||
{
|
||||
services.AddOptions<TOptions>()
|
||||
.BindConfiguration(sectionName)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IValidateOptions<TOptions>, TValidator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds options with a validator instance and configures fail-fast startup validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to configure.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="sectionName">The configuration section name.</param>
|
||||
/// <param name="validator">The validator instance.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOptionsWithValidation<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string sectionName,
|
||||
IValidateOptions<TOptions> validator)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddOptions<TOptions>()
|
||||
.BindConfiguration(sectionName)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton(validator);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds options with inline validation and configures fail-fast startup validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to configure.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="sectionName">The configuration section name.</param>
|
||||
/// <param name="validate">The validation function. Returns null/empty for success, or error messages for failure.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOptionsWithValidation<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string sectionName,
|
||||
Func<TOptions, IEnumerable<string>?> validate)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddOptions<TOptions>()
|
||||
.BindConfiguration(sectionName)
|
||||
.Validate(options =>
|
||||
{
|
||||
var errors = validate(options);
|
||||
return errors == null || !errors.Any();
|
||||
}, "Configuration validation failed. See logs for details.")
|
||||
.ValidateOnStart();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds options with data annotation validation and configures fail-fast startup validation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to configure. Must have DataAnnotations attributes.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="sectionName">The configuration section name.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOptionsWithDataAnnotations<TOptions>(
|
||||
this IServiceCollection services,
|
||||
string sectionName)
|
||||
where TOptions : class
|
||||
{
|
||||
services.AddOptions<TOptions>()
|
||||
.BindConfiguration(sectionName)
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an existing options configuration to validate on start.
|
||||
/// Use when options are already registered but need fail-fast validation added.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection ValidateOptionsOnStart<TOptions>(this IServiceCollection services)
|
||||
where TOptions : class
|
||||
{
|
||||
// Force options validation at startup by resolving IOptions<TOptions>
|
||||
services.AddHostedService<OptionsValidationHostedService<TOptions>>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.DependencyInjection.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// A hosted service that validates options at application startup.
|
||||
/// This ensures configuration errors are detected early rather than at first use.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to validate.</typeparam>
|
||||
internal sealed class OptionsValidationHostedService<TOptions> : IHostedService
|
||||
where TOptions : class
|
||||
{
|
||||
private readonly IOptions<TOptions> _options;
|
||||
|
||||
public OptionsValidationHostedService(IOptions<TOptions> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Accessing .Value triggers validation if IValidateOptions<TOptions> is registered
|
||||
_ = _options.Value;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.DependencyInjection.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for implementing options validators with a fluent error collection pattern.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to validate.</typeparam>
|
||||
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the configuration section prefix used in error messages.
|
||||
/// Override to customize the prefix (e.g., "MyModule:Settings").
|
||||
/// </summary>
|
||||
protected virtual string SectionPrefix => typeof(TOptions).Name.Replace("Options", string.Empty);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, TOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var context = new ValidationContext(SectionPrefix);
|
||||
ValidateOptions(options, context);
|
||||
|
||||
return context.HasErrors
|
||||
? ValidateOptionsResult.Fail(context.Errors)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override this method to implement validation logic.
|
||||
/// Use the context to add errors.
|
||||
/// </summary>
|
||||
/// <param name="options">The options to validate.</param>
|
||||
/// <param name="context">The validation context for collecting errors.</param>
|
||||
protected abstract void ValidateOptions(TOptions options, ValidationContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Provides a fluent interface for collecting validation errors.
|
||||
/// </summary>
|
||||
protected sealed class ValidationContext
|
||||
{
|
||||
private readonly List<string> _errors = new();
|
||||
private readonly string _sectionPrefix;
|
||||
|
||||
internal ValidationContext(string sectionPrefix)
|
||||
{
|
||||
_sectionPrefix = sectionPrefix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collected errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors => _errors;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any errors have been added.
|
||||
/// </summary>
|
||||
public bool HasErrors => _errors.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a validation error for a specific property.
|
||||
/// </summary>
|
||||
/// <param name="propertyName">The property name (e.g., "MaxRetries").</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext AddError(string propertyName, string message)
|
||||
{
|
||||
_errors.Add($"{_sectionPrefix}:{propertyName} {message}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a validation error for a nested property.
|
||||
/// </summary>
|
||||
/// <param name="parentProperty">The parent property name.</param>
|
||||
/// <param name="childProperty">The child property name.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext AddError(string parentProperty, string childProperty, string message)
|
||||
{
|
||||
_errors.Add($"{_sectionPrefix}:{parentProperty}:{childProperty} {message}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a general validation error.
|
||||
/// </summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext AddGeneralError(string message)
|
||||
{
|
||||
_errors.Add($"{_sectionPrefix}: {message}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conditionally adds an error if the condition is true.
|
||||
/// </summary>
|
||||
/// <param name="condition">The condition to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext AddErrorIf(bool condition, string propertyName, string message)
|
||||
{
|
||||
if (condition)
|
||||
{
|
||||
AddError(propertyName, message);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a string property is not null or whitespace.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext RequireNotEmpty(string? value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
AddError(propertyName, "is required and cannot be empty.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a numeric property is greater than zero.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext RequirePositive(int value, string propertyName)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
AddError(propertyName, "must be greater than zero.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a TimeSpan property is greater than zero.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext RequirePositive(TimeSpan value, string propertyName)
|
||||
{
|
||||
if (value <= TimeSpan.Zero)
|
||||
{
|
||||
AddError(propertyName, "must be greater than zero.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a value is within a specified range.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value type.</typeparam>
|
||||
/// <param name="value">The value to check.</param>
|
||||
/// <param name="propertyName">The property name.</param>
|
||||
/// <param name="min">The minimum allowed value (inclusive).</param>
|
||||
/// <param name="max">The maximum allowed value (inclusive).</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public ValidationContext RequireInRange<T>(T value, string propertyName, T min, T max)
|
||||
where T : IComparable<T>
|
||||
{
|
||||
if (value.CompareTo(min) < 0 || value.CompareTo(max) > 0)
|
||||
{
|
||||
AddError(propertyName, $"must be between {min} and {max}.");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Plugin.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Tests.Hosting;
|
||||
|
||||
public sealed class PluginHostOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
Assert.Null(options.BaseDirectory);
|
||||
Assert.Null(options.PluginsDirectory);
|
||||
Assert.Null(options.PrimaryPrefix);
|
||||
Assert.Empty(options.AdditionalPrefixes);
|
||||
Assert.Empty(options.PluginOrder);
|
||||
Assert.Empty(options.SearchPatterns);
|
||||
Assert.True(options.EnsureDirectoryExists);
|
||||
Assert.True(options.RecursiveSearch);
|
||||
Assert.Null(options.HostVersion);
|
||||
Assert.True(options.EnforceVersionCompatibility);
|
||||
Assert.True(options.RequireVersionAttribute);
|
||||
Assert.True(options.StrictMajorVersionCheck);
|
||||
Assert.Null(options.SignatureVerifier);
|
||||
Assert.False(options.EnforceSignatureVerification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostVersion_CanBeSet()
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
HostVersion = new Version(2, 1, 0)
|
||||
};
|
||||
|
||||
Assert.Equal(new Version(2, 1, 0), options.HostVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureVerifier_CanBeSet()
|
||||
{
|
||||
var verifier = NullPluginVerifier.Instance;
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
SignatureVerifier = verifier
|
||||
};
|
||||
|
||||
Assert.Same(verifier, options.SignatureVerifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforceVersionCompatibility_CanBeDisabled()
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
EnforceVersionCompatibility = false
|
||||
};
|
||||
|
||||
Assert.False(options.EnforceVersionCompatibility);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnforceSignatureVerification_CanBeEnabled()
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
EnforceSignatureVerification = true
|
||||
};
|
||||
|
||||
Assert.True(options.EnforceSignatureVerification);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdditionalPrefixes_CanBePopulated()
|
||||
{
|
||||
var options = new PluginHostOptions();
|
||||
options.AdditionalPrefixes.Add("MyCompany");
|
||||
options.AdditionalPrefixes.Add("ThirdParty");
|
||||
|
||||
Assert.Equal(2, options.AdditionalPrefixes.Count);
|
||||
Assert.Contains("MyCompany", options.AdditionalPrefixes);
|
||||
Assert.Contains("ThirdParty", options.AdditionalPrefixes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequireVersionAttribute_CanBeDisabled()
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
RequireVersionAttribute = false
|
||||
};
|
||||
|
||||
Assert.False(options.RequireVersionAttribute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrictMajorVersionCheck_CanBeDisabled()
|
||||
{
|
||||
var options = new PluginHostOptions
|
||||
{
|
||||
StrictMajorVersionCheck = false
|
||||
};
|
||||
|
||||
Assert.False(options.StrictMajorVersionCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionConfiguration_HasSecureDefaults()
|
||||
{
|
||||
// Verify that out-of-the-box defaults are secure for production
|
||||
var options = new PluginHostOptions();
|
||||
|
||||
// All enforcement options should default to strict/true
|
||||
Assert.True(options.EnforceVersionCompatibility, "Version compatibility should be enforced by default");
|
||||
Assert.True(options.RequireVersionAttribute, "Version attribute should be required by default");
|
||||
Assert.True(options.StrictMajorVersionCheck, "Strict major version check should be enabled by default");
|
||||
|
||||
// Signature verification is opt-in since it requires infrastructure
|
||||
Assert.False(options.EnforceSignatureVerification, "Signature verification is opt-in");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Tests.Hosting;
|
||||
|
||||
public sealed class PluginLoadFailureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Record_StoresValues()
|
||||
{
|
||||
var failure = new PluginLoadFailure(
|
||||
AssemblyPath: "/path/to/plugin.dll",
|
||||
Reason: PluginLoadFailureReason.SignatureInvalid,
|
||||
Message: "Signature verification failed");
|
||||
|
||||
Assert.Equal("/path/to/plugin.dll", failure.AssemblyPath);
|
||||
Assert.Equal(PluginLoadFailureReason.SignatureInvalid, failure.Reason);
|
||||
Assert.Equal("Signature verification failed", failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginLoadFailureReason_HasExpectedValues()
|
||||
{
|
||||
Assert.Equal(0, (int)PluginLoadFailureReason.LoadError);
|
||||
Assert.Equal(1, (int)PluginLoadFailureReason.SignatureInvalid);
|
||||
Assert.Equal(2, (int)PluginLoadFailureReason.IncompatibleVersion);
|
||||
Assert.Equal(3, (int)PluginLoadFailureReason.MissingVersionAttribute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingVersionAttribute_CanBeUsedInFailure()
|
||||
{
|
||||
var failure = new PluginLoadFailure(
|
||||
AssemblyPath: "/path/to/unversioned-plugin.dll",
|
||||
Reason: PluginLoadFailureReason.MissingVersionAttribute,
|
||||
Message: "Plugin does not declare a [StellaPluginVersion] attribute");
|
||||
|
||||
Assert.Equal("/path/to/unversioned-plugin.dll", failure.AssemblyPath);
|
||||
Assert.Equal(PluginLoadFailureReason.MissingVersionAttribute, failure.Reason);
|
||||
Assert.Contains("StellaPluginVersion", failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_SupportsEquality()
|
||||
{
|
||||
var failure1 = new PluginLoadFailure("/path/plugin.dll", PluginLoadFailureReason.LoadError, "Error");
|
||||
var failure2 = new PluginLoadFailure("/path/plugin.dll", PluginLoadFailureReason.LoadError, "Error");
|
||||
|
||||
Assert.Equal(failure1, failure2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_SupportsInequality()
|
||||
{
|
||||
var failure1 = new PluginLoadFailure("/path/plugin.dll", PluginLoadFailureReason.LoadError, "Error");
|
||||
var failure2 = new PluginLoadFailure("/path/other.dll", PluginLoadFailureReason.LoadError, "Error");
|
||||
|
||||
Assert.NotEqual(failure1, failure2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Plugin.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Tests.Security;
|
||||
|
||||
public sealed class CosignVerifierOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
var options = new CosignVerifierOptions();
|
||||
|
||||
Assert.Null(options.CosignPath);
|
||||
Assert.Null(options.PublicKeyPath);
|
||||
Assert.Null(options.CertificatePath);
|
||||
Assert.Null(options.CertificateIdentity);
|
||||
Assert.Null(options.CertificateOidcIssuer);
|
||||
Assert.True(options.UseRekorTransparencyLog);
|
||||
Assert.False(options.AllowUnsigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_CanBeSet()
|
||||
{
|
||||
var options = new CosignVerifierOptions
|
||||
{
|
||||
CosignPath = "/usr/local/bin/cosign",
|
||||
PublicKeyPath = "/keys/cosign.pub",
|
||||
CertificatePath = "/certs/cert.pem",
|
||||
CertificateIdentity = "test@example.com",
|
||||
CertificateOidcIssuer = "https://accounts.google.com",
|
||||
UseRekorTransparencyLog = false,
|
||||
AllowUnsigned = true
|
||||
};
|
||||
|
||||
Assert.Equal("/usr/local/bin/cosign", options.CosignPath);
|
||||
Assert.Equal("/keys/cosign.pub", options.PublicKeyPath);
|
||||
Assert.Equal("/certs/cert.pem", options.CertificatePath);
|
||||
Assert.Equal("test@example.com", options.CertificateIdentity);
|
||||
Assert.Equal("https://accounts.google.com", options.CertificateOidcIssuer);
|
||||
Assert.False(options.UseRekorTransparencyLog);
|
||||
Assert.True(options.AllowUnsigned);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Plugin.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Tests.Security;
|
||||
|
||||
public sealed class NullPluginVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AlwaysReturnsValid()
|
||||
{
|
||||
var verifier = NullPluginVerifier.Instance;
|
||||
|
||||
var result = await verifier.VerifyAsync("/path/to/any/assembly.dll");
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.SignerIdentity);
|
||||
Assert.Null(result.SignatureTimestamp);
|
||||
Assert.Null(result.FailureReason);
|
||||
Assert.Null(result.CertificateChain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instance_ReturnsSameInstance()
|
||||
{
|
||||
var instance1 = NullPluginVerifier.Instance;
|
||||
var instance2 = NullPluginVerifier.Instance;
|
||||
|
||||
Assert.Same(instance1, instance2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_HandlesNullPath()
|
||||
{
|
||||
var verifier = NullPluginVerifier.Instance;
|
||||
|
||||
// NullPluginVerifier doesn't validate the path, just returns success
|
||||
var result = await verifier.VerifyAsync(null!);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Reflection.Emit;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Tests.Versioning;
|
||||
|
||||
public sealed class PluginCompatibilityCheckerTests
|
||||
{
|
||||
[Fact]
|
||||
public void CheckCompatibility_LenientMode_ReturnsCompatibleForAssemblyWithoutAttribute()
|
||||
{
|
||||
var assembly = typeof(PluginCompatibilityCheckerTests).Assembly;
|
||||
var hostVersion = new Version(1, 0, 0);
|
||||
|
||||
// Default overload uses lenient options
|
||||
var result = PluginCompatibilityChecker.CheckCompatibility(assembly, hostVersion);
|
||||
|
||||
Assert.True(result.IsCompatible);
|
||||
Assert.False(result.HasVersionAttribute);
|
||||
Assert.Null(result.PluginVersion);
|
||||
Assert.Null(result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_StrictMode_RejectsAssemblyWithoutAttribute()
|
||||
{
|
||||
var assembly = typeof(PluginCompatibilityCheckerTests).Assembly;
|
||||
var hostVersion = new Version(1, 0, 0);
|
||||
var options = CompatibilityCheckOptions.Default; // Requires version attribute
|
||||
|
||||
var result = PluginCompatibilityChecker.CheckCompatibility(assembly, hostVersion, options);
|
||||
|
||||
Assert.False(result.IsCompatible);
|
||||
Assert.False(result.HasVersionAttribute);
|
||||
Assert.Null(result.PluginVersion);
|
||||
Assert.NotNull(result.FailureReason);
|
||||
Assert.Contains("[StellaPluginVersion]", result.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_ThrowsOnNullAssembly()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
PluginCompatibilityChecker.CheckCompatibility(null!, new Version(1, 0, 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckCompatibility_ThrowsOnNullHostVersion()
|
||||
{
|
||||
var assembly = typeof(PluginCompatibilityCheckerTests).Assembly;
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
PluginCompatibilityChecker.CheckCompatibility(assembly, null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.0.0", "1.0.0", true)] // host == min → compatible
|
||||
[InlineData("2.0.0", "1.0.0", true)] // host > min → compatible
|
||||
[InlineData("1.0.0", "2.0.0", false)] // host < min → NOT compatible
|
||||
public void CheckCompatibility_ValidatesMinimumHostVersion(
|
||||
string hostVersionStr,
|
||||
string minHostVersionStr,
|
||||
bool expectedCompatible)
|
||||
{
|
||||
// This test validates the logic conceptually
|
||||
// In a real scenario, we'd need a dynamically created assembly with the attribute
|
||||
var hostVersion = Version.Parse(hostVersionStr);
|
||||
var minHostVersion = Version.Parse(minHostVersionStr);
|
||||
|
||||
// Direct validation of version comparison logic
|
||||
var isCompatible = hostVersion >= minHostVersion;
|
||||
|
||||
Assert.Equal(expectedCompatible, isCompatible);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3.0.0", "2.0.0", false)]
|
||||
[InlineData("2.0.0", "2.0.0", true)]
|
||||
[InlineData("1.0.0", "2.0.0", true)]
|
||||
public void CheckCompatibility_ValidatesMaximumHostVersion(
|
||||
string hostVersionStr,
|
||||
string maxHostVersionStr,
|
||||
bool expectedCompatible)
|
||||
{
|
||||
var hostVersion = Version.Parse(hostVersionStr);
|
||||
var maxHostVersion = Version.Parse(maxHostVersionStr);
|
||||
|
||||
var isCompatible = hostVersion <= maxHostVersion;
|
||||
|
||||
Assert.Equal(expectedCompatible, isCompatible);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2.0.0", "1.0.0", false)] // host major 2 > min major 1 → NOT compatible (strict)
|
||||
[InlineData("1.5.0", "1.0.0", true)] // host major 1 == min major 1 → compatible
|
||||
[InlineData("1.0.0", "1.0.0", true)] // host == min → compatible
|
||||
public void StrictMajorVersionCheck_RejectsCrossMajorVersionWhenNoMaxSpecified(
|
||||
string hostVersionStr,
|
||||
string minHostVersionStr,
|
||||
bool expectedCompatible)
|
||||
{
|
||||
var hostVersion = Version.Parse(hostVersionStr);
|
||||
var minHostVersion = Version.Parse(minHostVersionStr);
|
||||
|
||||
// With strict major version check, if plugin declares min=1.0.0 but no max,
|
||||
// and host is 2.x, it should be rejected
|
||||
var hostMajorExceedsMin = hostVersion.Major > minHostVersion.Major;
|
||||
var isCompatible = !hostMajorExceedsMin && hostVersion >= minHostVersion;
|
||||
|
||||
Assert.Equal(expectedCompatible, isCompatible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompatibilityCheckOptions_DefaultRequiresVersionAttribute()
|
||||
{
|
||||
var options = CompatibilityCheckOptions.Default;
|
||||
|
||||
Assert.True(options.RequireVersionAttribute);
|
||||
Assert.True(options.StrictMajorVersionCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompatibilityCheckOptions_LenientDoesNotRequireVersionAttribute()
|
||||
{
|
||||
var options = CompatibilityCheckOptions.Lenient;
|
||||
|
||||
Assert.False(options.RequireVersionAttribute);
|
||||
Assert.False(options.StrictMajorVersionCheck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompatibilityCheckOptions_CanBeCustomized()
|
||||
{
|
||||
var options = new CompatibilityCheckOptions
|
||||
{
|
||||
RequireVersionAttribute = false,
|
||||
StrictMajorVersionCheck = true
|
||||
};
|
||||
|
||||
Assert.False(options.RequireVersionAttribute);
|
||||
Assert.True(options.StrictMajorVersionCheck);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using StellaOps.Plugin.Versioning;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Plugin.Tests.Versioning;
|
||||
|
||||
public sealed class StellaPluginVersionAttributeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ParsesValidVersion()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.2.3");
|
||||
|
||||
Assert.Equal(new Version(1, 2, 3), attr.PluginVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ParsesTwoPartVersion()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.0");
|
||||
|
||||
Assert.Equal(new Version(1, 0), attr.PluginVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ParsesFourPartVersion()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.2.3.4");
|
||||
|
||||
Assert.Equal(new Version(1, 2, 3, 4), attr.PluginVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnInvalidVersion()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new StellaPluginVersionAttribute("invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnEmptyVersion()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new StellaPluginVersionAttribute(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnNullVersion()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new StellaPluginVersionAttribute(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMinimumHostVersion_ReturnsNullWhenNotSet()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.0.0");
|
||||
|
||||
Assert.Null(attr.GetMinimumHostVersion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMinimumHostVersion_ParsesValidVersion()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.0.0")
|
||||
{
|
||||
MinimumHostVersion = "2.0.0"
|
||||
};
|
||||
|
||||
Assert.Equal(new Version(2, 0, 0), attr.GetMinimumHostVersion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMaximumHostVersion_ReturnsNullWhenNotSet()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.0.0");
|
||||
|
||||
Assert.Null(attr.GetMaximumHostVersion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMaximumHostVersion_ParsesValidVersion()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.0.0")
|
||||
{
|
||||
MaximumHostVersion = "3.0.0"
|
||||
};
|
||||
|
||||
Assert.Equal(new Version(3, 0, 0), attr.GetMaximumHostVersion());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresSignature_DefaultsToTrue()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.0.0");
|
||||
|
||||
Assert.True(attr.RequiresSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresSignature_CanBeSetToFalse()
|
||||
{
|
||||
var attr = new StellaPluginVersionAttribute("1.0.0")
|
||||
{
|
||||
RequiresSignature = false
|
||||
};
|
||||
|
||||
Assert.False(attr.RequiresSignature);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user