From bf0644962e9b8f052aebc27ba20332ac55e8fd3e Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 7 Jul 2026 12:01:29 +0300 Subject: [PATCH 1/2] feat: add native Rebex FTP upload handler and receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the serverless FTP handler and receiver into in-process native adapters (NativeRebexFtpUploadHandler, NativeRebexFtpReceiver), registered only when a Rebex license key is configured. - SshKeyNormalizer: shared, robust private-key normalization used by both adapters (rebuilds flattened single-line PEM keys, leaves well-formed keys untouched) - FtpProtocol.EnsurePasswordProvided: shared password guard — required for ftp/sftp, optional passphrase for sftpssh - Upload handler gains DataEncoding (utf8/base64) to support binary payloads, mirroring the receiver's ResponseEncoding - Receiver drops the broken RenameDuplicateFiles option --- .gitignore | 2 +- SW.Bitween.NativeAdapters/FtpProtocol.cs | 18 +++ .../NativeRebexFtpReceiver.cs | 110 ++++++++++++++++++ .../RebexFtpReceiver/RebexFtpReceiverInput.cs | 37 ++++++ .../NativeRebexFtpUploadHandler.cs | 89 ++++++++++++++ .../RebexFtpUploadHandlerInput.cs | 31 +++++ .../SW.Bitween.NativeAdapters.csproj | 6 +- .../ServiceCollectionExtensions.cs | 8 ++ SW.Bitween.NativeAdapters/SshKeyNormalizer.cs | 49 ++++++++ SW.Bitween.UnitTests/SshKeyNormalizerTests.cs | 69 +++++++++++ 10 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 SW.Bitween.NativeAdapters/FtpProtocol.cs create mode 100644 SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs create mode 100644 SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs create mode 100644 SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs create mode 100644 SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs create mode 100644 SW.Bitween.NativeAdapters/SshKeyNormalizer.cs create mode 100644 SW.Bitween.UnitTests/SshKeyNormalizerTests.cs diff --git a/.gitignore b/.gitignore index 0c1a286e..ee53b660 100644 --- a/.gitignore +++ b/.gitignore @@ -358,7 +358,7 @@ charts/default/charts/swlib-2.0.11.tgz .idea /SW.Bitween.Web/appsettings.Development.json -/SW.Bitween.Web/appsettings.DevRabbitMq.json +/SW.Bitween.Web/appsettings.Local.json /SW.Bitween.Web/adapters SW.Bitween.NativeAdapters/JsonFieldMapper/Bitween-api.code-workspace .DS_Store diff --git a/SW.Bitween.NativeAdapters/FtpProtocol.cs b/SW.Bitween.NativeAdapters/FtpProtocol.cs new file mode 100644 index 00000000..961896c6 --- /dev/null +++ b/SW.Bitween.NativeAdapters/FtpProtocol.cs @@ -0,0 +1,18 @@ +namespace SW.Bitween.NativeAdapters; + +/// +/// Shared FTP protocol rules used by the native FTP adapters, so the handler and receiver +/// can never disagree on them. +/// +public static class FtpProtocol +{ + /// + /// Password-authenticated protocols (ftp, sftp) require a password. sftpssh authenticates + /// with a private key, so its "password" is an optional key passphrase and is not enforced. + /// + public static void EnsurePasswordProvided(string protocol, string? password) + { + if (protocol.ToLower() is "ftp" or "sftp" && string.IsNullOrEmpty(password)) + throw new ArgumentException($"Password is required for the '{protocol}' protocol."); + } +} diff --git a/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs b/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs new file mode 100644 index 00000000..b4d327a0 --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs @@ -0,0 +1,110 @@ +using System.Text; +using Rebex.Net; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.RebexFtpReceiver; + +public class NativeRebexFtpReceiver : INativeInfolinkReceiver +{ + private readonly string? _licenseKey; + private RebexFtpReceiverInput _options = new(); + private IFtp _ftpOrSftp = null!; + + public NativeRebexFtpReceiver(string? licenseKey = null) + { + _licenseKey = licenseKey; + } + + public async Task Initialize() + { + Rebex.Licensing.Key = _licenseKey; + FtpProtocol.EnsurePasswordProvided(_options.Protocol, _options.Password); + + switch (_options.Protocol.ToLower()) + { + case "sftpssh": + var sftpssh = new Sftp(); + await sftpssh.ConnectAsync(_options.Host, _options.Port ?? 22); + + var keyBytes = Encoding.UTF8.GetBytes(SshKeyNormalizer.Normalize(_options.PrivateKey)); + var privateKey = new SshPrivateKey(keyBytes, _options.Password); + await sftpssh.LoginAsync(_options.Username, privateKey); + + _ftpOrSftp = sftpssh; + break; + + case "sftp": + var sftp = new Sftp(); + await sftp.ConnectAsync(_options.Host, _options.Port ?? 22); + _ftpOrSftp = sftp; + await _ftpOrSftp.LoginAsync(_options.Username, _options.Password); + break; + + case "ftp": + var ftp = new Rebex.Net.Ftp(); + await ftp.ConnectAsync(_options.Host, _options.Port ?? 21); + _ftpOrSftp = ftp; + await _ftpOrSftp.LoginAsync(_options.Username, _options.Password); + break; + + default: + throw new ArgumentException($"Unknown protocol '{_options.Protocol}'"); + } + + if (!string.IsNullOrEmpty(_options.TargetPath)) + await _ftpOrSftp.ChangeDirectoryAsync(_options.TargetPath); + } + + public async Task Finalize() + { + await _ftpOrSftp.DisconnectAsync(); + _ftpOrSftp.Dispose(); + } + + public async Task> ListFiles() + { + var files = await _ftpOrSftp.GetListAsync(); + + return files + .Where(i => i.IsFile) + .Take(_options.BatchSize) + .Select(i => i.Name) + .ToList(); + } + + public async Task GetFile(string fileId) + { + await using var stream = new MemoryStream(); + await _ftpOrSftp.GetFileAsync(fileId, stream); + + var data = stream.ToArray(); + + return _options.ResponseEncoding.ToLower() switch + { + "base64" => new XchangeFile(Convert.ToBase64String(data), fileId), + "utf8" => new XchangeFile(Encoding.UTF8.GetString(data), fileId), + _ => throw new ArgumentException( + $"Unknown {nameof(RebexFtpReceiverInput.ResponseEncoding)} '{_options.ResponseEncoding}'") + }; + } + + public async Task DeleteFile(string fileId) + { + if (_options.CheckFileExistence && !await _ftpOrSftp.FileExistsAsync(fileId)) + return; + + if (string.IsNullOrWhiteSpace(_options.DeleteMovesFileTo)) + await _ftpOrSftp.DeleteFileAsync(fileId); + else + await _ftpOrSftp.RenameAsync(fileId, _options.DeleteMovesFileTo + "/" + fileId); + } + + public string Name => "NativeRebexFtpReceiver"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(RebexFtpReceiverInput); +} diff --git a/SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs b/SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs new file mode 100644 index 00000000..41ee04b8 --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs @@ -0,0 +1,37 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.RebexFtpReceiver; + +public class RebexFtpReceiverInput +{ + [Required] + public string Host { get; set; } = string.Empty; + + public int? Port { get; set; } + + [Required] + public string Username { get; set; } = string.Empty; + + [Secure] + public string? Password { get; set; } + + public string? TargetPath { get; set; } + + [DefaultValue(50)] + public int BatchSize { get; set; } = 50; + + [DefaultValue("utf8")] + public string ResponseEncoding { get; set; } = "utf8"; + + public string? DeleteMovesFileTo { get; set; } + + [DefaultValue("sftp")] + public string Protocol { get; set; } = "sftp"; + + [DefaultValue(true)] + public bool CheckFileExistence { get; set; } = true; + + [Secure] + public string? PrivateKey { get; set; } +} diff --git a/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs new file mode 100644 index 00000000..8e5113c7 --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs @@ -0,0 +1,89 @@ +using System.Text; +using Rebex.Net; +using SW.PrimitiveTypes; + +namespace SW.Bitween.NativeAdapters.RebexFtpUploadHandler; + +public class NativeRebexFtpUploadHandler : INativeInfolinkHandler +{ + private readonly string? _licenseKey; + private RebexFtpUploadHandlerInput _options = new(); + + public NativeRebexFtpUploadHandler(string? licenseKey = null) + { + _licenseKey = licenseKey; + } + + public async Task Handle(XchangeFile xchangeFile) + { + Rebex.Licensing.Key = _licenseKey; + FtpProtocol.EnsurePasswordProvided(_options.Protocol, _options.Password); + + IFtp ftpOrSftp; + switch (_options.Protocol.ToLower()) + { + case "sftpssh": + var sftpssh = new Sftp(); + await sftpssh.ConnectAsync(_options.Host, _options.Port ?? 22); + + var keyBytes = Encoding.UTF8.GetBytes(SshKeyNormalizer.Normalize(_options.PrivateKey)); + var sshPrivateKey = new SshPrivateKey(keyBytes, _options.Password); + await sftpssh.LoginAsync(_options.Username, sshPrivateKey); + + ftpOrSftp = sftpssh; + break; + + case "sftp": + var sftp = new Sftp(); + await sftp.ConnectAsync(_options.Host, _options.Port ?? 22); + ftpOrSftp = sftp; + await ftpOrSftp.LoginAsync(_options.Username, _options.Password); + break; + + case "ftp": + var ftp = new Rebex.Net.Ftp(); + await ftp.ConnectAsync(_options.Host, _options.Port ?? 21); + ftpOrSftp = ftp; + await ftpOrSftp.LoginAsync(_options.Username, _options.Password); + break; + + default: + throw new ArgumentException($"Unknown protocol '{_options.Protocol}'"); + } + + var bytes = _options.DataEncoding.ToLower() switch + { + "base64" => Convert.FromBase64String(xchangeFile.Data), + "utf8" => Encoding.UTF8.GetBytes(xchangeFile.Data), + _ => throw new ArgumentException( + $"Unknown {nameof(RebexFtpUploadHandlerInput.DataEncoding)} '{_options.DataEncoding}'") + }; + + await using var stream = new MemoryStream(bytes); + + var filename = xchangeFile.Filename; + if (string.IsNullOrWhiteSpace(filename)) + { + var currentDate = DateTime.UtcNow; + filename = + $"{currentDate.Year:0000}{currentDate.Month:00}{currentDate.Day:00}{currentDate.Hour:00}{currentDate.Minute:00}{currentDate.Second:00}{currentDate.Millisecond:000}"; + } + + if (!string.IsNullOrWhiteSpace(_options.FileNamePrefix)) + filename = $"{_options.FileNamePrefix}_{filename}"; + + await ftpOrSftp.PutFileAsync(stream, $"{_options.TargetPath}/{filename}"); + + await ftpOrSftp.DisconnectAsync(); + return new XchangeFile(string.Empty); + } + + public string Name => "NativeRebexFtpUploadHandler"; + + public void InitializeStartupValues(IDictionary settings) + { + _options = settings.ConvertTo(); + } + + public Type StartupValuesType => typeof(RebexFtpUploadHandlerInput); +} diff --git a/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs new file mode 100644 index 00000000..92a41b17 --- /dev/null +++ b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SW.Bitween.NativeAdapters.RebexFtpUploadHandler; + +public class RebexFtpUploadHandlerInput +{ + [Required] + public string Host { get; set; } = string.Empty; + + public int? Port { get; set; } + + [Required] + public string Username { get; set; } = string.Empty; + + [Secure] + public string? Password { get; set; } + + public string? TargetPath { get; set; } + + public string? FileNamePrefix { get; set; } + + [DefaultValue("utf8")] + public string DataEncoding { get; set; } = "utf8"; + + [DefaultValue("sftp")] + public string Protocol { get; set; } = "sftp"; + + [Secure] + public string? PrivateKey { get; set; } +} diff --git a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj index cd939126..561103de 100644 --- a/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj +++ b/SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj @@ -17,8 +17,10 @@ - - + + + + diff --git a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs index 4af32258..aaae520c 100644 --- a/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs +++ b/SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using SW.Bitween.NativeAdapters.HttpReceiver; using SW.Bitween.NativeAdapters.Pop3Receiver; +using SW.Bitween.NativeAdapters.RebexFtpReceiver; +using SW.Bitween.NativeAdapters.RebexFtpUploadHandler; using SW.Bitween.NativeAdapters.RebexPop3Receiver; namespace SW.Bitween.NativeAdapters; @@ -40,6 +42,12 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection, { serviceCollection.AddScoped(_ => new NativeRebexPop3Receiver(rebexLicenseKey)); serviceCollection.AddScoped(_ => new NativeRebexPop3Receiver(rebexLicenseKey)); + + serviceCollection.AddScoped(_ => new NativeRebexFtpUploadHandler(rebexLicenseKey)); + serviceCollection.AddScoped(_ => new NativeRebexFtpUploadHandler(rebexLicenseKey)); + + serviceCollection.AddScoped(_ => new NativeRebexFtpReceiver(rebexLicenseKey)); + serviceCollection.AddScoped(_ => new NativeRebexFtpReceiver(rebexLicenseKey)); } } } \ No newline at end of file diff --git a/SW.Bitween.NativeAdapters/SshKeyNormalizer.cs b/SW.Bitween.NativeAdapters/SshKeyNormalizer.cs new file mode 100644 index 00000000..dc8acebc --- /dev/null +++ b/SW.Bitween.NativeAdapters/SshKeyNormalizer.cs @@ -0,0 +1,49 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace SW.Bitween.NativeAdapters; + +/// +/// Normalizes an SSH private key coming from adapter settings into a shape SSH clients accept. +/// Shared by every native adapter that does key-based auth, so their handling can never diverge. +/// +/// Rules: +/// - Blank input -> empty string. +/// - Already multi-line -> returned untouched (never risk corrupting a well-formed key). +/// - Flattened PEM (single line, has BEGIN/END markers) -> rebuilt with the header/footer +/// preserved exactly and the base64 body re-wrapped at 64 chars. +/// - Anything else (e.g. a flattened non-PEM format) -> returned as-is rather than mangled. +/// +public static class SshKeyNormalizer +{ + private static readonly Regex PemShape = + new(@"^(-----BEGIN [^-]+-----)(.*?)(-----END [^-]+-----)$", RegexOptions.Singleline | RegexOptions.Compiled); + + public static string Normalize(string? rawKey) + { + if (string.IsNullOrWhiteSpace(rawKey)) + return string.Empty; + + var key = rawKey.Trim(); + + // Well-formed multi-line key: trust it exactly as given. + if (key.Contains('\n')) + return key; + + // Single flattened line: only reconstruct if it's a PEM key we recognize. + var match = PemShape.Match(key); + if (!match.Success) + return key; + + var header = match.Groups[1].Value.Trim(); + var body = Regex.Replace(match.Groups[2].Value, @"\s+", string.Empty); + var footer = match.Groups[3].Value.Trim(); + + var sb = new StringBuilder(); + sb.Append(header).Append('\n'); + for (var i = 0; i < body.Length; i += 64) + sb.Append(body.Substring(i, Math.Min(64, body.Length - i))).Append('\n'); + sb.Append(footer).Append('\n'); + return sb.ToString(); + } +} diff --git a/SW.Bitween.UnitTests/SshKeyNormalizerTests.cs b/SW.Bitween.UnitTests/SshKeyNormalizerTests.cs new file mode 100644 index 00000000..a0f0a12e --- /dev/null +++ b/SW.Bitween.UnitTests/SshKeyNormalizerTests.cs @@ -0,0 +1,69 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.NativeAdapters; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class SshKeyNormalizerTests +{ + private const string Header = "-----BEGIN OPENSSH PRIVATE KEY-----"; + private const string Footer = "-----END OPENSSH PRIVATE KEY-----"; + private const string Body = "AAAAB3NzaC1yc2EAAAADAQABAAABAQCabc123def456ghi789jkl012mno34"; + + [TestMethod] + public void WellFormedMultiLineKey_IsReturnedUnchanged() + { + var key = $"{Header}\n{Body}\n{Footer}"; + + var result = SshKeyNormalizer.Normalize(key); + + Assert.AreEqual(key, result); + // Regression: the header's internal spaces must survive (the old regex destroyed them). + StringAssert.Contains(result, Header); + } + + [TestMethod] + public void FlattenedPemKey_IsRebuiltWithHeaderFooterAndBody() + { + var flattened = $"{Header} {Body} {Footer}"; + + var result = SshKeyNormalizer.Normalize(flattened); + var lines = result.Split('\n').Where(l => l.Length > 0).ToList(); + + Assert.AreEqual(Header, lines.First()); // header intact, spaces preserved + Assert.AreEqual(Footer, lines.Last()); // footer intact + // Body reassembled with no whitespace, wrapped across the middle lines. + var rebuiltBody = string.Concat(lines.Skip(1).Take(lines.Count - 2)); + Assert.AreEqual(Body, rebuiltBody); + } + + [TestMethod] + public void FlattenedPemKey_WrapsBodyAt64Chars() + { + var longBody = new string('A', 200); + var flattened = $"{Header} {longBody} {Footer}"; + + var result = SshKeyNormalizer.Normalize(flattened); + var bodyLines = result.Split('\n').Where(l => l.Length > 0).Skip(1).SkipLast(1).ToList(); + + Assert.IsTrue(bodyLines.All(l => l.Length <= 64)); + Assert.AreEqual(longBody, string.Concat(bodyLines)); + } + + [TestMethod] + public void NullOrEmpty_ReturnsEmptyString() + { + Assert.AreEqual(string.Empty, SshKeyNormalizer.Normalize(null)); + Assert.AreEqual(string.Empty, SshKeyNormalizer.Normalize("")); + Assert.AreEqual(string.Empty, SshKeyNormalizer.Normalize(" ")); + } + + [TestMethod] + public void UnrecognizedSingleLine_IsReturnedTrimmedButUnmangled() + { + var result = SshKeyNormalizer.Normalize(" not-a-pem-key-just-text "); + + Assert.AreEqual("not-a-pem-key-just-text", result); + } +} From 0a432d0a8bda722eab7e90bed059e4367f38644f Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Tue, 7 Jul 2026 12:55:04 +0300 Subject: [PATCH 2/2] fix: require a private key for the sftpssh FTP protocol Add FtpProtocol.EnsurePrivateKeyProvided and call it in both the FTP receiver and upload handler before connecting. A missing PrivateKey on sftpssh now fails fast with a clear message instead of surfacing as an opaque Rebex exception from SshPrivateKey deep in the stack. --- SW.Bitween.NativeAdapters/FtpProtocol.cs | 10 ++++++++++ .../RebexFtpReceiver/NativeRebexFtpReceiver.cs | 1 + .../NativeRebexFtpUploadHandler.cs | 1 + 3 files changed, 12 insertions(+) diff --git a/SW.Bitween.NativeAdapters/FtpProtocol.cs b/SW.Bitween.NativeAdapters/FtpProtocol.cs index 961896c6..68fb7437 100644 --- a/SW.Bitween.NativeAdapters/FtpProtocol.cs +++ b/SW.Bitween.NativeAdapters/FtpProtocol.cs @@ -15,4 +15,14 @@ public static void EnsurePasswordProvided(string protocol, string? password) if (protocol.ToLower() is "ftp" or "sftp" && string.IsNullOrEmpty(password)) throw new ArgumentException($"Password is required for the '{protocol}' protocol."); } + + /// + /// sftpssh authenticates with a private key, so one must be provided. ftp/sftp authenticate + /// with a password instead, so no key is required for them. + /// + public static void EnsurePrivateKeyProvided(string protocol, string? privateKey) + { + if (protocol.ToLower() is "sftpssh" && string.IsNullOrWhiteSpace(privateKey)) + throw new ArgumentException($"A private key is required for the '{protocol}' protocol."); + } } diff --git a/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs b/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs index b4d327a0..18e8335b 100644 --- a/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs +++ b/SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs @@ -19,6 +19,7 @@ public async Task Initialize() { Rebex.Licensing.Key = _licenseKey; FtpProtocol.EnsurePasswordProvided(_options.Protocol, _options.Password); + FtpProtocol.EnsurePrivateKeyProvided(_options.Protocol, _options.PrivateKey); switch (_options.Protocol.ToLower()) { diff --git a/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs index 8e5113c7..bee8c876 100644 --- a/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs +++ b/SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs @@ -18,6 +18,7 @@ public async Task Handle(XchangeFile xchangeFile) { Rebex.Licensing.Key = _licenseKey; FtpProtocol.EnsurePasswordProvided(_options.Protocol, _options.Password); + FtpProtocol.EnsurePrivateKeyProvided(_options.Protocol, _options.PrivateKey); IFtp ftpOrSftp; switch (_options.Protocol.ToLower())