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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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