diff --git a/AGENTS.md b/AGENTS.md
index c881496..d4cbc87 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,3 +2,5 @@
Stricly follow the [Human Guidelines](CONTRIBUTING.md)
+## Additional rules
+- Do not downgrade the .NET version if the current one in the developer machine is lower than the one required by global.json.
diff --git a/FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs b/FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs
index f6d43c0..8e2dd56 100644
--- a/FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs
+++ b/FmiSrl.FtpServer.Server/Abstractions/FtpCommandContext.cs
@@ -22,6 +22,11 @@ public record FtpCommandContext(
ILogger Logger
)
{
+ ///
+ /// Gets or sets the FTP response for this command.
+ ///
+ public FtpResponse? Response { get; set; }
+
///
/// Gets the authentication context for the current session.
///
diff --git a/FmiSrl.FtpServer.Server/Abstractions/FtpResponse.cs b/FmiSrl.FtpServer.Server/Abstractions/FtpResponse.cs
new file mode 100644
index 0000000..3972d78
--- /dev/null
+++ b/FmiSrl.FtpServer.Server/Abstractions/FtpResponse.cs
@@ -0,0 +1,31 @@
+namespace FmiSrl.FtpServer.Server.Abstractions;
+
+///
+/// Represents an FTP response.
+///
+public class FtpResponse
+{
+ ///
+ /// Gets or sets the FTP response code.
+ ///
+ public int Code { get; set; }
+
+ ///
+ /// Gets or sets the response message.
+ ///
+ public string Message { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The FTP response code.
+ /// The response message.
+ public FtpResponse(int code, string message)
+ {
+ Code = code;
+ Message = message;
+ }
+
+ ///
+ public override string ToString() => $"{Code} {Message}";
+}
diff --git a/FmiSrl.FtpServer.Server/Abstractions/IFtpCommandMiddleware.cs b/FmiSrl.FtpServer.Server/Abstractions/IFtpCommandMiddleware.cs
new file mode 100644
index 0000000..8cef08d
--- /dev/null
+++ b/FmiSrl.FtpServer.Server/Abstractions/IFtpCommandMiddleware.cs
@@ -0,0 +1,15 @@
+namespace FmiSrl.FtpServer.Server.Abstractions;
+
+///
+/// Defines the behavior for an FTP command middleware.
+///
+public interface IFtpCommandMiddleware
+{
+ ///
+ /// Invokes the middleware asynchronously.
+ ///
+ /// The for the current command.
+ /// A delegate representing the next step in the pipeline.
+ /// A task that represents the asynchronous operation.
+ Task InvokeAsync(FtpCommandContext context, Func next);
+}
diff --git a/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerBuilder.cs b/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerBuilder.cs
index 4f78094..dc345ce 100644
--- a/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerBuilder.cs
+++ b/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerBuilder.cs
@@ -1,3 +1,4 @@
+using FmiSrl.FtpServer.Server.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace FmiSrl.FtpServer.Server.DependencyInjection;
@@ -5,4 +6,10 @@ namespace FmiSrl.FtpServer.Server.DependencyInjection;
internal sealed class FtpServerBuilder(IServiceCollection services) : IFtpServerBuilder
{
public IServiceCollection Services { get; } = services;
+
+ public IFtpServerBuilder AddMiddleware() where TMiddleware : class, IFtpCommandMiddleware
+ {
+ Services.AddTransient();
+ return this;
+ }
}
diff --git a/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerServiceCollectionExtensions.cs b/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerServiceCollectionExtensions.cs
index 9980939..e343812 100644
--- a/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerServiceCollectionExtensions.cs
+++ b/FmiSrl.FtpServer.Server/DependencyInjection/FtpServerServiceCollectionExtensions.cs
@@ -1,3 +1,4 @@
+using FmiSrl.FtpServer.Server.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -32,6 +33,9 @@ public static IFtpServerBuilder AddFtpServer(
services.TryAddSingleton();
- return new FtpServerBuilder(services);
+ var builder = new FtpServerBuilder(services);
+ builder.AddMiddleware();
+
+ return builder;
}
}
diff --git a/FmiSrl.FtpServer.Server/DependencyInjection/IFtpServerBuilder.cs b/FmiSrl.FtpServer.Server/DependencyInjection/IFtpServerBuilder.cs
index c4c3e55..a85fdfb 100644
--- a/FmiSrl.FtpServer.Server/DependencyInjection/IFtpServerBuilder.cs
+++ b/FmiSrl.FtpServer.Server/DependencyInjection/IFtpServerBuilder.cs
@@ -1,3 +1,4 @@
+using FmiSrl.FtpServer.Server.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace FmiSrl.FtpServer.Server.DependencyInjection;
@@ -12,4 +13,11 @@ public interface IFtpServerBuilder
///
/// The used by this builder.
IServiceCollection Services { get; }
+
+ ///
+ /// Adds a middleware to the FTP command pipeline.
+ ///
+ /// The type of the middleware to add.
+ /// The instance.
+ IFtpServerBuilder AddMiddleware() where TMiddleware : class, IFtpCommandMiddleware;
}
diff --git a/FmiSrl.FtpServer.Server/FtpServer.cs b/FmiSrl.FtpServer.Server/FtpServer.cs
index 798c680..2dc5e3e 100644
--- a/FmiSrl.FtpServer.Server/FtpServer.cs
+++ b/FmiSrl.FtpServer.Server/FtpServer.cs
@@ -22,6 +22,7 @@ namespace FmiSrl.FtpServer.Server;
public class FtpServer(
IFileSystemProvider fileSystemProvider,
IAuthenticationProvider authenticationProvider,
+ IEnumerable middlewares,
IOptions configurationOptions,
ILogger? logger = null
)
@@ -35,7 +36,7 @@ public class FtpServer(
authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider));
private readonly ILogger _logger = logger ?? NullLogger.Instance;
- private readonly FtpCommandHandler _commandHandler = new();
+ private readonly FtpCommandHandler _commandHandler = new(middlewares);
private readonly Dictionary _sessions = [];
private NetServer? _netServer;
diff --git a/FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs b/FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs
index 5adc88a..52467e4 100644
--- a/FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs
+++ b/FmiSrl.FtpServer.Server/Infrastructure/FtpCommandHandler.cs
@@ -1,3 +1,4 @@
+using System.Net;
using FmiSrl.FtpServer.Server.Abstractions;
namespace FmiSrl.FtpServer.Server.Infrastructure;
@@ -5,9 +6,10 @@ namespace FmiSrl.FtpServer.Server.Infrastructure;
///
/// Handles the registration and execution of FTP commands.
///
-public class FtpCommandHandler
+public class FtpCommandHandler(IEnumerable middlewares)
{
private readonly Dictionary _commands = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _middlewares = middlewares.ToList();
///
/// Registers an FTP command.
@@ -28,18 +30,82 @@ public void RegisterCommand(IFtpCommand command)
/// A task that represents the asynchronous operation.
public async Task HandleCommandAsync(FtpCommandContext context)
{
- if (!_commands.TryGetValue(context.Verb, out var command))
+ var originalSession = context.Session;
+ var capturingSession = new ResponseCapturingSession(originalSession, context);
+
+ // Temporarily replace the session in the context to capture responses
+ var contextWithCapturingSession = context with { Session = capturingSession };
+
+ var next = async () =>
+ {
+ if (!_commands.TryGetValue(context.Verb, out var command))
+ {
+ context.Response = new FtpResponse(502, "Command not implemented.");
+ return;
+ }
+
+ if (command.RequiresAuthentication && !context.Session.IsAuthenticated)
+ {
+ context.Response = new FtpResponse(530, "Not logged in.");
+ return;
+ }
+
+ await command.ExecuteAsync(contextWithCapturingSession);
+ };
+
+ // Build the pipeline
+ var pipeline = next;
+ for (var i = _middlewares.Count - 1; i >= 0; i--)
+ {
+ var middleware = _middlewares[i];
+ var currentNext = pipeline;
+ pipeline = () => middleware.InvokeAsync(context, currentNext);
+ }
+
+ await pipeline();
+
+ // If a response was captured or set, send it via the original session
+ if (context.Response != null)
+ {
+ await originalSession.SendResponseAsync(context.Response.Code, context.Response.Message);
+ }
+ }
+
+ private sealed class ResponseCapturingSession(IFtpSession inner, FtpCommandContext context) : IFtpSession
+ {
+ public string Id => inner.Id;
+ public bool IsAuthenticated { get => inner.IsAuthenticated; set => inner.IsAuthenticated = value; }
+ public string? Username { get => inner.Username; set => inner.Username = value; }
+ public string CurrentDirectory { get => inner.CurrentDirectory; set => inner.CurrentDirectory = value; }
+ public EndPoint RemoteEndPoint => inner.RemoteEndPoint;
+ public IFtpDataConnection? DataConnection { get => inner.DataConnection; set => inner.DataConnection = value; }
+ public IDictionary State => inner.State;
+
+ public Task SendResponseAsync(int code, string message)
{
- await context.Session.SendResponseAsync(502, "Command not implemented.");
- return;
+ if (code is >= 100 and < 200)
+ {
+ return inner.SendResponseAsync(code, message);
+ }
+ context.Response = new FtpResponse(code, message);
+ return Task.CompletedTask;
}
- if (command.RequiresAuthentication && !context.Session.IsAuthenticated)
+ public Task SendResponseAsync(string rawResponse)
{
- await context.Session.SendResponseAsync(530, "Not logged in.");
- return;
+ // Try to parse raw response if it matches "XYZ Message"
+ if (rawResponse.Length >= 4 && int.TryParse(rawResponse.AsSpan(0, 3), out var code) && rawResponse[3] == ' ')
+ {
+ if (code is >= 100 and < 200)
+ {
+ return inner.SendResponseAsync(rawResponse);
+ }
+ context.Response = new FtpResponse(code, rawResponse[4..].TrimEnd('\r', '\n'));
+ return Task.CompletedTask;
+ }
+ return inner.SendResponseAsync(rawResponse);
}
- await command.ExecuteAsync(context);
+ public Task LockSessionAsync() => inner.LockSessionAsync();
}
}
diff --git a/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnostics.cs b/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnostics.cs
new file mode 100644
index 0000000..0481d33
--- /dev/null
+++ b/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnostics.cs
@@ -0,0 +1,35 @@
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace FmiSrl.FtpServer.Server.Infrastructure;
+
+///
+/// Provides diagnostic tools for tracing and metrics.
+///
+public static class FtpDiagnostics
+{
+ ///
+ /// The prefix used for all traces and metrics.
+ ///
+ public const string ServiceName = "FmiSrl.FtpServer";
+
+ ///
+ /// The for tracing.
+ ///
+ public static readonly ActivitySource ActivitySource = new(ServiceName);
+
+ ///
+ /// The for metrics.
+ ///
+ public static readonly Meter Meter = new(ServiceName);
+
+ ///
+ /// Counter for commands executed.
+ ///
+ public static readonly Counter CommandsExecutedCounter = Meter.CreateCounter("ftp.commands.executed", description: "Total number of FTP commands executed");
+
+ ///
+ /// Histogram for command execution duration.
+ ///
+ public static readonly Histogram CommandDurationHistogram = Meter.CreateHistogram("ftp.commands.duration", unit: "ms", description: "Duration of FTP command execution");
+}
diff --git a/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnosticsMiddleware.cs b/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnosticsMiddleware.cs
new file mode 100644
index 0000000..e33ff26
--- /dev/null
+++ b/FmiSrl.FtpServer.Server/Infrastructure/FtpDiagnosticsMiddleware.cs
@@ -0,0 +1,47 @@
+using System.Diagnostics;
+using FmiSrl.FtpServer.Server.Abstractions;
+
+namespace FmiSrl.FtpServer.Server.Infrastructure;
+
+///
+/// A middleware that provides diagnostics for FTP commands.
+///
+public class FtpDiagnosticsMiddleware : IFtpCommandMiddleware
+{
+ ///
+ public async Task InvokeAsync(FtpCommandContext context, Func next)
+ {
+ using var activity = FtpDiagnostics.ActivitySource.StartActivity($"FTP {context.Verb}");
+ activity?.SetTag("ftp.verb", context.Verb);
+ activity?.SetTag("ftp.args", context.Arguments);
+ activity?.SetTag("ftp.session_id", context.Session.Id);
+
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ await next();
+
+ if (context.Response != null)
+ {
+ activity?.SetTag("ftp.response_code", context.Response.Code);
+ }
+ }
+ catch (Exception ex)
+ {
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ throw;
+ }
+ finally
+ {
+ sw.Stop();
+ var responseCode = context.Response?.Code ?? 0;
+
+ FtpDiagnostics.CommandsExecutedCounter.Add(1,
+ new KeyValuePair("ftp.verb", context.Verb),
+ new KeyValuePair("ftp.response_code", responseCode));
+
+ FtpDiagnostics.CommandDurationHistogram.Record(sw.Elapsed.TotalMilliseconds,
+ new KeyValuePair("ftp.verb", context.Verb));
+ }
+ }
+}
diff --git a/FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs b/FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs
index 412e442..f53aa35 100644
--- a/FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs
+++ b/FmiSrl.FtpServer.Tests.Integration/FtpServerTests.cs
@@ -1,6 +1,7 @@
using System.Net;
using FluentFTP;
using FmiSrl.FtpServer.Server;
+using FmiSrl.FtpServer.Server.Abstractions;
using FmiSrl.FtpServer.Server.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -42,7 +43,7 @@ public FtpServerTests()
ServerName = "TestServer"
});
- _server = new FmiSrl.FtpServer.Server.FtpServer(fileSystem, authenticator, serverOptions, NullLogger.Instance);
+ _server = new FmiSrl.FtpServer.Server.FtpServer(fileSystem, authenticator, Enumerable.Empty(), serverOptions, NullLogger.Instance);
}
public async ValueTask DisposeAsync()
diff --git a/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpCommandHandlerTests.cs b/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpCommandHandlerTests.cs
index 21c2b46..02f9ba5 100644
--- a/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpCommandHandlerTests.cs
+++ b/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpCommandHandlerTests.cs
@@ -7,7 +7,7 @@ namespace FmiSrl.FtpServer.Tests.Unit.Infrastructure;
public class FtpCommandHandlerTests
{
- private readonly FtpCommandHandler _sut = new();
+ private readonly FtpCommandHandler _sut = new(Enumerable.Empty());
private readonly IFtpSession _session = Substitute.For();
private readonly IFileSystemProvider _fileSystem = Substitute.For();
private readonly IAuthenticationProvider _authenticator = Substitute.For();
@@ -85,6 +85,6 @@ public async Task When_command_is_registered_and_auth_ok_should_execute_command(
await _sut.HandleCommandAsync(context);
// Assert
- await command.Received().ExecuteAsync(context);
+ await command.Received().ExecuteAsync(Arg.Any());
}
}
diff --git a/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpMiddlewareTests.cs b/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpMiddlewareTests.cs
new file mode 100644
index 0000000..77364ab
--- /dev/null
+++ b/FmiSrl.FtpServer.Tests.Unit/Infrastructure/FtpMiddlewareTests.cs
@@ -0,0 +1,61 @@
+using FmiSrl.FtpServer.Server.Abstractions;
+using FmiSrl.FtpServer.Server.Infrastructure;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using Xunit;
+
+namespace FmiSrl.FtpServer.Tests.Unit.Infrastructure;
+
+public class FtpMiddlewareTests
+{
+ private readonly IFtpSession _session = Substitute.For();
+ private readonly IFileSystemProvider _fileSystem = Substitute.For();
+ private readonly IAuthenticationProvider _authenticator = Substitute.For();
+
+ [Fact]
+ public async Task Middleware_should_be_able_to_manipulate_response()
+ {
+ // Arrange
+ var middleware = new TestManipulationMiddleware();
+ var sut = new FtpCommandHandler(new[] { middleware });
+
+ var command = Substitute.For();
+ command.Verbs.Returns(["TEST"]);
+ command.ExecuteAsync(Arg.Any()).Returns(async call =>
+ {
+ var ctx = call.Arg();
+ await ctx.Session.SendResponseAsync(200, "Original Message");
+ });
+ sut.RegisterCommand(command);
+
+ var context = new FtpCommandContext(
+ _session,
+ "TEST",
+ "",
+ _fileSystem,
+ _authenticator,
+ new(),
+ NullLogger.Instance
+ );
+
+ // Act
+ await sut.HandleCommandAsync(context);
+
+ // Assert
+ await _session.Received().SendResponseAsync(201, "Manipulated Message");
+ }
+
+ private class TestManipulationMiddleware : IFtpCommandMiddleware
+ {
+ public async Task InvokeAsync(FtpCommandContext context, Func next)
+ {
+ await next();
+
+ if (context.Response != null)
+ {
+ context.Response.Code = 201;
+ context.Response.Message = "Manipulated Message";
+ }
+ }
+ }
+}
diff --git a/global.json b/global.json
index 95365e3..43a2c0d 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.203",
+ "version": "10.0.101",
"rollForward": "latestMajor",
"allowPrerelease": false
}