diff --git a/Directory.Version.props b/Directory.Version.props index 317e204..191820c 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,6 +1,6 @@ - 10.0.0 + 10.1.0 \ No newline at end of file diff --git a/README.md b/README.md index 8bee196..29a3008 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ It gives you: - Extension methods to register the file validator in the DI container -- Easy configuration via appsettings.json or fluent configuration in code +- Easy configuration via `appsettings.json` or fluent configuration in code > This package is the `Microsoft.Extensions.DependencyInjection` integration layer. > The core validation logic lives in [`ByteGuard.FileValidator`](https://github.com/ByteGuard-HQ/byteguard-file-validator-net). @@ -12,7 +12,7 @@ It gives you: ## Getting Started ### Installation -This package is published and installed via NuGet. +This package is published and installed via [NuGet](https://www.nuget.org/packages/ByteGuard.FileValidator.Extensions.DependencyInjection). Reference the package in your project: ```bash @@ -24,20 +24,31 @@ dotnet add package ByteGuard.FileValidator.Extensions.DependencyInjection ### Add to DI container In your `Program.cs` (or `Startup.cs` in older projects), register the validator: +**Using inline configuration** ```csharp -using ByteGuard.FileValidator; -using ByteGuard.FileValidator.Extensions.DependencyInjection; - -// Using inline configuration builder.Services.AddFileValidator(options => { options.AllowFileTypes(FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png); options.FileSizeLimit = ByteSize.MegaBytes(25); options.ThrowOnInvalidFiles(false); + + // If an antimalware package has been added + options.Scanner = ScannerRegistration.Create(opts => + { + // Refer to the individual scanner implementations for ScannerType value and possible options. + // ... + }) }); +``` -// Using configuration from appsettings.json -builder.Services.AddFileValidator(options => configuration.GetSection("FileValidatorConfiguration").Bind(options)); +**Using configuration from appsettings.json with default "FileValidator" section name** +```csharp +builder.Services.AddFileValidator(builder.Configuration); +``` + +**Using configuration from appsettings.json with custom section name** +```csharp +builder.Services.AddFileValidator(builder.Configuration, "MySection"); ``` ### Injection & Usage @@ -79,5 +90,28 @@ It's possible to configure the `FileValidator` through `appsettings.json`. } ``` +**With antimalware scanner** +It's possible to configure an antimalware scanner directly through `appsettings.json`. + +> ℹ️ _Refer to the individual scanner implementations for `ScannerType` value and possible options._ + +```json +{ + "FileValidatorConfiguration": { + "SupportedFileTypes": [ ".pdf", ".jpg", ".png" ], + "FileSizeLimit": 26214400, + "UnitFileSizeLimit": "25MB", + "ThrowExceptionOnInvalidFile": true, + "Scanner": { + "ScannerType": "...", + "Options": { + "OptionA": "...", + "OptionB": "..." + } + } + } +} +``` + ## License _ByteGuard.FileValidator.Extensions.DpendencyInjection is Copyright © ByteGuard Contributors - Provided under the MIT license._ \ No newline at end of file diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..8cc0687 Binary files /dev/null and b/assets/icon.png differ diff --git a/src/ByteGuard.FileValidator.Extensions.DependencyInjection/ByteGuard.FileValidator.Extensions.DependencyInjection.csproj b/src/ByteGuard.FileValidator.Extensions.DependencyInjection/ByteGuard.FileValidator.Extensions.DependencyInjection.csproj index 7ea2af2..7fa6b5c 100644 --- a/src/ByteGuard.FileValidator.Extensions.DependencyInjection/ByteGuard.FileValidator.Extensions.DependencyInjection.csproj +++ b/src/ByteGuard.FileValidator.Extensions.DependencyInjection/ByteGuard.FileValidator.Extensions.DependencyInjection.csproj @@ -9,18 +9,22 @@ git byteguard file-validator extensions dependency injection README.md + icon.png Copyright © ByteGuard Contributors MIT - + + + + diff --git a/src/ByteGuard.FileValidator.Extensions.DependencyInjection/Configuration/FileValidatorSettingsConfiguration.cs b/src/ByteGuard.FileValidator.Extensions.DependencyInjection/Configuration/FileValidatorSettingsConfiguration.cs index d368d9d..f16d7d7 100644 --- a/src/ByteGuard.FileValidator.Extensions.DependencyInjection/Configuration/FileValidatorSettingsConfiguration.cs +++ b/src/ByteGuard.FileValidator.Extensions.DependencyInjection/Configuration/FileValidatorSettingsConfiguration.cs @@ -44,4 +44,9 @@ public class FileValidatorSettingsConfiguration /// Whether to throw an exception if an unsupported/invalid file is encountered. Defaults to true. /// public bool ThrowExceptionOnInvalidFile { get; set; } = true; + + /// + /// Configuration for the antimalware scanner to use. + /// + public ScannerRegistration? Scanner { get; set; } } diff --git a/src/ByteGuard.FileValidator.Extensions.DependencyInjection/Configuration/ScannerRegistration.cs b/src/ByteGuard.FileValidator.Extensions.DependencyInjection/Configuration/ScannerRegistration.cs new file mode 100644 index 0000000..9585361 --- /dev/null +++ b/src/ByteGuard.FileValidator.Extensions.DependencyInjection/Configuration/ScannerRegistration.cs @@ -0,0 +1,84 @@ +using ByteGuard.FileValidator.Scanners; +using Microsoft.Extensions.Configuration; + +namespace ByteGuard.FileValidator.Extensions.DependencyInjection.Configuration; + +/// +/// Registration information for an antimalware scanner. +/// +public sealed class ScannerRegistration +{ + /// + /// Type of the scanner to register. Must be derived from IAntimalwareScanner. + /// + public string ScannerType { get; set; } = default!; + + private Type? _scannerType; + + /// + /// Gets or sets the of the scanner. + /// + public Type Type + { + get + { + if (_scannerType is not null) + return _scannerType; + + _scannerType = Type.GetType(ScannerType, throwOnError: false)!; + return _scannerType; + } + set + { + _scannerType = value; + ScannerType = value.AssemblyQualifiedName!; + } + } + + /// + /// Options for the scanner. + /// + public object? OptionsInstance { get; set; } + + /// + /// Raw configuration section for options (for appsettings registration). + /// + public IConfigurationSection? OptionsConfiguration { get; set; } + + /// + /// Creates a new instance for the specified scanner type and options. + /// + /// Scanner options. + /// Scanner implementation inheriting from IAntimalwareScanner. + /// Scanner options. + public static ScannerRegistration Create(Action options) + where TScanner : IAntimalwareScanner + where TOptions : class, new() + { + var opts = new TOptions(); + options?.Invoke(opts); + + return new ScannerRegistration + { + Type = typeof(TScanner), + OptionsInstance = opts + }; + } + + /// + /// Creates a new instance for the specified scanner type and options. + /// + /// Scanner options. + /// Scanner implementation inheriting from IAntimalwareScanner. + /// Scanner options. + public static ScannerRegistration Create(TOptions options) + where TScanner : IAntimalwareScanner + where TOptions : class, new() + { + return new ScannerRegistration + { + Type = typeof(TScanner), + OptionsInstance = options + }; + } +} diff --git a/src/ByteGuard.FileValidator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/ByteGuard.FileValidator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index b755b12..0fd9abb 100644 --- a/src/ByteGuard.FileValidator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/ByteGuard.FileValidator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using ByteGuard.FileValidator.Configuration; using ByteGuard.FileValidator.Extensions.DependencyInjection.Configuration; +using ByteGuard.FileValidator.Scanners; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -10,44 +12,189 @@ namespace ByteGuard.FileValidator.Extensions.DependencyInjection; /// public static class ServiceCollectionExtensions { + /// + /// The default configuration section name for File Validator settings. + /// + public const string DefaultSectionName = "FileValidator"; + + /// + /// Adds the File Validator services to the specified with configuration from the provided . + /// + /// + /// This method binds the configuration section named "FileValidator" by default. + /// + /// Service collection. + /// Configuration. + public static IServiceCollection AddFileValidator(this IServiceCollection services, IConfiguration configuration) + { + return services.AddFileValidator(configuration, DefaultSectionName); + } + + /// + /// Adds the File Validator services to the specified with configuration from the provided . + /// + /// Service collection. + /// Configuration. + /// Section name. + public static IServiceCollection AddFileValidator(this IServiceCollection services, IConfiguration configuration, string sectionName) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentException.ThrowIfNullOrEmpty(sectionName); + + var section = configuration.GetSection(sectionName); + if (section is null) + { + throw new InvalidOperationException($"Configuration section '{sectionName}' not found."); + } + + var settings = new FileValidatorSettingsConfiguration(); + section.Bind(settings); + + var scannerSection = section.GetSection("Scanner"); + if (scannerSection.Exists()) + { + var scannerType = scannerSection["ScannerType"]; + if (string.IsNullOrWhiteSpace(scannerType)) + { + throw new InvalidOperationException("ScannerType must be specified in the configuration section."); + } + + settings.Scanner = new ScannerRegistration(); + settings.Scanner.ScannerType = scannerType; + settings.Scanner.OptionsConfiguration = scannerSection.GetSection("Options"); + } + + ConfigureFromSettings(services, settings); + return services; + } + /// /// Adds the File Validator services to the specified with custom configuration options. /// /// Service collection. - /// Configuration options. + /// File validator configuration. public static IServiceCollection AddFileValidator(this IServiceCollection services, Action options) { - // Validate and setup configuration options. - services.AddSingleton, - FileValidatorConfigurationOptionsValidator>(); + ArgumentNullException.ThrowIfNull(options); + + var settings = new FileValidatorSettingsConfiguration(); + options(settings); - services.Configure(options); + ConfigureFromSettings(services, settings); + return services; + } + + /// + /// Configures services from settings. + /// + /// Service collection. + /// File valiator settings. + private static void ConfigureFromSettings(IServiceCollection services, FileValidatorSettingsConfiguration settings) + { + ArgumentNullException.ThrowIfNull(settings); services.AddOptions() - .Configure>((cfg, settings) => + .Configure(config => { // Convert from FileValidatorSettingsConfiguration to FileValidatorConfiguration. - cfg.SupportedFileTypes = settings.Value.SupportedFileTypes; - cfg.ThrowExceptionOnInvalidFile = settings.Value.ThrowExceptionOnInvalidFile; + config.SupportedFileTypes = settings.SupportedFileTypes; + config.ThrowExceptionOnInvalidFile = settings.ThrowExceptionOnInvalidFile; - if (settings.Value.FileSizeLimit != -1) + if (settings.FileSizeLimit != -1) { - cfg.FileSizeLimit = settings.Value.FileSizeLimit; + config.FileSizeLimit = settings.FileSizeLimit; } - else if (!string.IsNullOrWhiteSpace(settings.Value.UnitFileSizeLimit)) + else if (!string.IsNullOrWhiteSpace(settings.UnitFileSizeLimit)) { - cfg.FileSizeLimit = ByteSize.Parse(settings.Value.UnitFileSizeLimit); + config.FileSizeLimit = ByteSize.Parse(settings.UnitFileSizeLimit); } }) .ValidateOnStart(); + // Register antimalware scanner (if any). + RegisterConfiguredScanner(services, settings.Scanner); + // Register the FileValidator service. services.AddSingleton(serviceProvider => { var configuration = serviceProvider.GetRequiredService>().Value; + + // If an antimalware scanner is registered, resolve it and pass it to the FileValidator. + var antimalwareScanner = serviceProvider.GetService(); + if (antimalwareScanner is not null) + { + return new FileValidator(configuration, antimalwareScanner); + } + + // No antimalware scanner registered. return new FileValidator(configuration); }); + } - return services; + /// + /// Registers the configured antimalware scanner. + /// + /// Service collection. + /// Scanner registration. + private static void RegisterConfiguredScanner(IServiceCollection services, ScannerRegistration? scanner) + { + // If no scanner has been registerered. + if (scanner is null) return; + + var scannerType = scanner.Type; + if (scannerType is null) + { + throw new InvalidOperationException("The specified scanner type could not be resolved."); + } + + if (!typeof(IAntimalwareScanner).IsAssignableFrom(scanner.Type)) + { + throw new InvalidOperationException($"The specified scanner type '{scanner.Type.FullName}' does not implement the '{nameof(IAntimalwareScanner)}' interface."); + } + + var genericScannerInterface = scanner.Type + .GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAntimalwareScanner<>)); + + var optionsType = genericScannerInterface?.GetGenericArguments()[0]; + + // Register scanner as IAntimalwareScanner implementation. + services.AddSingleton(_ => + { + object? optionsInstance = scanner.OptionsInstance; + if (optionsInstance is null && scanner.OptionsConfiguration is not null) + { + if (optionsType is null) + { + throw new InvalidOperationException($"Scanner '{scannerType.FullName}' must implement IAntimalwareScanner to be configured from appsettings."); + } + + optionsInstance = Activator.CreateInstance(optionsType); + if (optionsInstance is null) + { + throw new InvalidOperationException($"Could not create options instance of type '{optionsType.FullName}'."); + } + + scanner.OptionsConfiguration.Bind(optionsInstance); + } + + object? impl; + + if (optionsInstance is not null) + { + impl = Activator.CreateInstance(scanner.Type, optionsInstance); + } + else + { + impl = Activator.CreateInstance(scanner.Type); + } + + if (impl is not IAntimalwareScanner typedScanner) + { + throw new InvalidOperationException($"Scanner type '{scanner.Type.FullName}' does not implement the '{nameof(IAntimalwareScanner)}' interface."); + } + + return typedScanner; + }); } } diff --git a/tests/FileValidator.Extensions.DependencyInjection.Tests.Unit/MockAntimalwareScanner.cs b/tests/FileValidator.Extensions.DependencyInjection.Tests.Unit/MockAntimalwareScanner.cs new file mode 100644 index 0000000..dba1301 --- /dev/null +++ b/tests/FileValidator.Extensions.DependencyInjection.Tests.Unit/MockAntimalwareScanner.cs @@ -0,0 +1,29 @@ +using ByteGuard.FileValidator.Scanners; + +namespace FileValidator.Extensions.DependencyInjection.Tests.Unit; + +public class MockAntimalwareScanner : IAntimalwareScanner +{ + public readonly MockAntimalwareScannerOptions Options; + + public MockAntimalwareScanner(MockAntimalwareScannerOptions options) + { + Options = options; + } + + public MockAntimalwareScanner() + { + Options = new MockAntimalwareScannerOptions(); + } + + public bool IsClean(Stream contentStream, string fileName) + { + return true; + } +} + +public class MockAntimalwareScannerOptions +{ + public string OptionA { get; set; } = string.Empty; + public int? OptionB { get; set; } +} diff --git a/tests/FileValidator.Extensions.DependencyInjection.Tests.Unit/ServiceCollectionExtensionsTests.cs b/tests/FileValidator.Extensions.DependencyInjection.Tests.Unit/ServiceCollectionExtensionsTests.cs index 092c16a..7544ca6 100644 --- a/tests/FileValidator.Extensions.DependencyInjection.Tests.Unit/ServiceCollectionExtensionsTests.cs +++ b/tests/FileValidator.Extensions.DependencyInjection.Tests.Unit/ServiceCollectionExtensionsTests.cs @@ -1,5 +1,8 @@ using ByteGuard.FileValidator.Configuration; using ByteGuard.FileValidator.Extensions.DependencyInjection.Configuration; +using ByteGuard.FileValidator.Scanners; +using FileValidator.Extensions.DependencyInjection.Tests.Unit; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -85,4 +88,205 @@ public void AddFileValidator_FileSizeLimitAndFriendlyFileSizeLimitBothSet_Should var options = sp.GetRequiredService>(); Assert.Equal(ByteSize.MegaBytes(10), options.Value.FileSizeLimit); } + + [Fact(DisplayName = "AddFileValidator should register the configured antimalware scanner")] + public void AddFileValidator_ConfiguredAntimalwareScanner_ShouldRegisterScanner() + { + // Arrange + var services = new ServiceCollection(); + Action configAction = options => + { + options.SupportedFileTypes = [".pdf"]; + options.FileSizeLimit = ByteSize.MegaBytes(15); + options.Scanner = ScannerRegistration.Create(opts => + { + opts.OptionA = "TestOption"; + opts.OptionB = 42; + }); + }; + + // Act + services.AddFileValidator(configAction); + + // Assert + var sp = services.BuildServiceProvider(); + var scanner = sp.GetRequiredService(); + Assert.IsType(scanner); + } + + [Fact(DisplayName = "AddFileValidator should register the configured antimalware scanner without options")] + public void AddFileValidator_ConfiguredAntimalwareScannerWithoutOptions_ShouldRegisterScanner() + { + // Arrange + var services = new ServiceCollection(); + Action configAction = options => + { + options.SupportedFileTypes = [".pdf"]; + options.FileSizeLimit = ByteSize.MegaBytes(15); + options.Scanner = ScannerRegistration.Create(_ => { }); + }; + + // Act + services.AddFileValidator(configAction); + + // Assert + var sp = services.BuildServiceProvider(); + var scanner = sp.GetRequiredService(); + Assert.IsType(scanner); + } + + [Fact(DisplayName = "AddFileValidator should not register an antimalware scanner when none is configured")] + public void AddFileValidator_NoConfiguredAntimalwareScanner_ShouldNotRegisterScanner() + { + // Arrange + var services = new ServiceCollection(); + Action configAction = options => + { + options.SupportedFileTypes = [".pdf"]; + options.FileSizeLimit = ByteSize.MegaBytes(15); + options.Scanner = null; // No scanner configured + }; + + // Act + services.AddFileValidator(configAction); + + // Assert + var sp = services.BuildServiceProvider(); + var scanner = sp.GetService(); + Assert.Null(scanner); + } + + [Fact(DisplayName = "AddFileValidator should throw exception when configured antimalware scanner type is invalid")] + public void AddFileValidator_ConfiguredAntimalwareScannerWithInvalidType_ShouldThrowException() + { + // Arrange + var services = new ServiceCollection(); + Action configAction = options => + { + options.SupportedFileTypes = [".pdf"]; + options.FileSizeLimit = ByteSize.MegaBytes(15); + options.Scanner = new ScannerRegistration + { + ScannerType = "Unknown.Namespace.ImplementationScanner, ImplementationScanner", // Invalid type, does not implement IAntimalwareScanner + OptionsInstance = null + }; + }; + + // Act & Assert + Assert.Throws(() => services.AddFileValidator(configAction)); + } + + [Fact(DisplayName = "AddFileValidator should inject the antimalware scanner into the FileValidator")] + public void AddFileValidator_ConfiguredAntimalwareScanner_ShouldInjectScannerIntoFileValidator() + { + // Arrange + var services = new ServiceCollection(); + Action configAction = options => + { + options.SupportedFileTypes = [".pdf"]; + options.FileSizeLimit = ByteSize.MegaBytes(15); + options.Scanner = ScannerRegistration.Create(opts => + { + opts.OptionA = "InjectedOption"; + opts.OptionB = 99; + }); + }; + + // Act + services.AddFileValidator(configAction); + + // Assert + var sp = services.BuildServiceProvider(); + var fileValidator = sp.GetRequiredService(); + Assert.NotNull(fileValidator); + + var scannerField = typeof(FileValidator).GetField("_antimalwareScanner", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(scannerField); + + var scannerInstance = scannerField.GetValue(fileValidator) as MockAntimalwareScanner; + Assert.NotNull(scannerInstance); + } + + [Fact(DisplayName = "AddFileValidator should create FileValidator without antimalware scanner when none is configured")] + public void AddFileValidator_NoConfiguredAntimalwareScanner_ShouldCreateFileValidatorWithoutScanner() + { + // Arrange + var services = new ServiceCollection(); + Action configAction = options => + { + options.SupportedFileTypes = [".pdf"]; + options.FileSizeLimit = ByteSize.MegaBytes(15); + options.Scanner = null; // No scanner configured + }; + + // Act + services.AddFileValidator(configAction); + + // Assert + var sp = services.BuildServiceProvider(); + var fileValidator = sp.GetRequiredService(); + Assert.NotNull(fileValidator); + + var scannerField = typeof(FileValidator).GetField("_antimalwareScanner", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(scannerField); + + var scannerInstance = scannerField.GetValue(fileValidator); + Assert.Null(scannerInstance); + } + + [Fact(DisplayName = "AddFileValidator should register the scanner when configuration has been provided through appsettings.json")] + public void AddFileValidator_ConfigurationFromAppSettings_ShouldRegisterScanner() + { + // Arrange + var services = new ServiceCollection(); + + var inMemorySettings = new Dictionary + { + {"FileValidator:SupportedFileTypes:0", ".pdf"}, + {"FileValidator:UnitFileSizeLimit", "15MB"}, + {"FileValidator:Scanner:ScannerType", "FileValidator.Extensions.DependencyInjection.Tests.Unit.MockAntimalwareScanner, FileValidator.Extensions.DependencyInjection.Tests.Unit"}, + {"FileValidator:Scanner:Options:OptionA", "ConfigOption"}, + {"FileValidator:Scanner:Options:OptionB", "123"} + }; + + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(inMemorySettings.AsEnumerable()) + .Build(); + + // Act + services.AddFileValidator(configuration); + + // Assert + var sp = services.BuildServiceProvider(); + var scanner = sp.GetRequiredService(); + Assert.IsType(scanner); + } + + [Fact(DisplayName = "AddFileValidator should register the scanner when configuration has been provided through appsettings.json")] + public void AddFileValidator_ConfigurationFromAppSettingsCustomSectionName_ShouldRegisterScanner() + { + // Arrange + var services = new ServiceCollection(); + + var inMemorySettings = new Dictionary + { + {"CustomName:SupportedFileTypes:0", ".pdf"}, + {"CustomName:UnitFileSizeLimit", "15MB"}, + {"CustomName:Scanner:ScannerType", "FileValidator.Extensions.DependencyInjection.Tests.Unit.MockAntimalwareScanner, FileValidator.Extensions.DependencyInjection.Tests.Unit"}, + {"CustomName:Scanner:Options:OptionA", "ConfigOption"}, + {"CustomName:Scanner:Options:OptionB", "123"} + }; + + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection(inMemorySettings.AsEnumerable()) + .Build(); + + // Act + services.AddFileValidator(configuration, "CustomName"); + + // Assert + var sp = services.BuildServiceProvider(); + var scanner = sp.GetRequiredService(); + Assert.IsType(scanner); + } }