Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions SW.Bitween.NativeAdapters/FtpProtocol.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace SW.Bitween.NativeAdapters;

/// <summary>
/// Shared FTP protocol rules used by the native FTP adapters, so the handler and receiver
/// can never disagree on them.
/// </summary>
public static class FtpProtocol
{
/// <summary>
/// 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.
/// </summary>
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.");
}
Comment on lines +13 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add unit tests for EnsurePasswordProvided.

This guard is the sole validation gate shared by both Rebex adapters (ftp/sftp require password, sftpssh doesn't), but no test file covers it (only SshKeyNormalizerTests.cs was added). A regression here silently breaks auth validation for both adapters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SW.Bitween.NativeAdapters/FtpProtocol.cs` around lines 13 - 17, Add unit
tests for EnsurePasswordProvided to cover the shared auth guard used by the
Rebex adapters. Test that it throws for ftp and sftp when password is null or
empty, and that it does not throw for sftpssh or when a valid password is
provided. Place the tests near the existing protocol validation coverage and
reference the FtpProtocol.EnsurePasswordProvided method so the guard remains
protected against regressions.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated connect/login switch across both adapters is a good candidate for consolidation here.

NativeRebexFtpReceiver.Initialize and NativeRebexFtpUploadHandler.Handle both implement a nearly identical sftpssh/sftp/ftp switch (connect, key setup, login, unknown-protocol throw). This file already centralizes the shared password rule — extending it with a shared Connect(protocol, host, port, username, password, privateKey) : Task<IFtp> factory would remove ~30 duplicated lines per adapter and prevent the two implementations from silently diverging (see the assignment-order and target-path issues flagged in the receiver/handler files, which only exist in one of the two copies).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SW.Bitween.NativeAdapters/FtpProtocol.cs` around lines 13 - 17, The protocol
connect/login logic is duplicated in both NativeRebexFtpReceiver.Initialize and
NativeRebexFtpUploadHandler.Handle, and should be consolidated behind a shared
factory in FtpProtocol. Add a reusable Connect(protocol, host, port, username,
password, privateKey) method that performs the protocol switch, key setup, and
login once, then update both adapters to call it instead of maintaining separate
sftpssh/sftp/ftp branches. Keep the existing EnsurePasswordProvided rule in
FtpProtocol and ensure the shared factory preserves the same unknown-protocol
behavior and connection semantics.


/// <summary>
/// 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.
/// </summary>
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.");
}
}
111 changes: 111 additions & 0 deletions SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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);
FtpProtocol.EnsurePrivateKeyProvided(_options.Protocol, _options.PrivateKey);

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;
Comment thread
hamzahalq marked this conversation as resolved.

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();
}
Comment on lines +26 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Connection leaked and Finalize() throws NRE if sftpssh login fails.

_ftpOrSftp = sftpssh (line 33) only happens after LoginAsync succeeds (line 31) — unlike the sftp/ftp branches, which assign before login. If login throws (bad credentials, malformed key), the already-connected sftpssh socket is never assigned to _ftpOrSftp, so it's orphaned (never disposed), and _ftpOrSftp stays null!. If the caller then invokes Finalize() in a cleanup path, _ftpOrSftp.DisconnectAsync() (line 60) throws a NullReferenceException that masks the real login failure.

🔒 Proposed fix
             case "sftpssh":
                 var sftpssh = new Sftp();
                 await sftpssh.ConnectAsync(_options.Host, _options.Port ?? 22);
+                _ftpOrSftp = sftpssh;
 
                 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;
     public async Task Finalize()
     {
+        if (_ftpOrSftp is null)
+            return;
         await _ftpOrSftp.DisconnectAsync();
         _ftpOrSftp.Dispose();
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
}
case "sftpssh":
var sftpssh = new Sftp();
await sftpssh.ConnectAsync(_options.Host, _options.Port ?? 22);
_ftpOrSftp = sftpssh;
var keyBytes = Encoding.UTF8.GetBytes(SshKeyNormalizer.Normalize(_options.PrivateKey));
var privateKey = new SshPrivateKey(keyBytes, _options.Password);
await sftpssh.LoginAsync(_options.Username, privateKey);
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()
{
if (_ftpOrSftp is null)
return;
await _ftpOrSftp.DisconnectAsync();
_ftpOrSftp.Dispose();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs` around
lines 25 - 62, The sftpssh branch in NativeRebexFtpReceiver.ConnectAsync assigns
_ftpOrSftp only after LoginAsync succeeds, unlike the sftp and ftp branches, so
a failed login can leave an open Sftp connection orphaned and _ftpOrSftp null
for Finalize(). Assign the connected Sftp instance to _ftpOrSftp immediately
after ConnectAsync, and make Finalize() safely handle a null or uninitialized
_ftpOrSftp before calling DisconnectAsync/Dispose so cleanup still works when
login fails.


public async Task<IEnumerable<string>> ListFiles()
{
var files = await _ftpOrSftp.GetListAsync();

return files
.Where(i => i.IsFile)
.Take(_options.BatchSize)
.Select(i => i.Name)
.ToList();
}

public async Task<XchangeFile> 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<string, string> settings)
{
_options = settings.ConvertTo<RebexFtpReceiverInput>();
}

public Type StartupValuesType => typeof(RebexFtpReceiverInput);
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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<XchangeFile> 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())
{
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;
Comment thread
hamzahalq marked this conversation as resolved.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Uploads land at the FTP root when TargetPath is unset.

$"{_options.TargetPath}/{filename}" evaluates to "/filename" when TargetPath is null (it's optional, no default). This uploads to the server root instead of the connected/current directory, unlike the receiver which only calls ChangeDirectoryAsync when TargetPath is non-empty.

🐛 Proposed fix
-        await ftpOrSftp.PutFileAsync(stream, $"{_options.TargetPath}/{filename}");
+        var remotePath = string.IsNullOrWhiteSpace(_options.TargetPath)
+            ? filename
+            : $"{_options.TargetPath.TrimEnd('/')}/{filename}";
+        await ftpOrSftp.PutFileAsync(stream, remotePath);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await ftpOrSftp.PutFileAsync(stream, $"{_options.TargetPath}/{filename}");
var remotePath = string.IsNullOrWhiteSpace(_options.TargetPath)
? filename
: $"{_options.TargetPath.TrimEnd('/')}/{filename}";
await ftpOrSftp.PutFileAsync(stream, remotePath);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs`
at line 75, The upload path in NativeRebexFtpUploadHandler currently prefixes
the filename with a slash when _options.TargetPath is unset, which sends files
to the FTP root instead of the current directory. Update the path construction
around ftpOrSftp.PutFileAsync so it only combines TargetPath with filename when
TargetPath is non-empty, and otherwise uploads using just the filename; keep the
behavior aligned with the receiver’s directory handling.


await ftpOrSftp.DisconnectAsync();
return new XchangeFile(string.Empty);
Comment on lines +23 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Connection is never disposed, and leaks entirely on any failure between connect and upload.

ftpOrSftp is only disconnected on the success path (line 77) — Dispose() is never called at all, and if PutFileAsync (or anything after connect) throws, neither DisconnectAsync nor Dispose runs. Combined with the sftpssh branch assigning ftpOrSftp = sftpssh only after LoginAsync succeeds (line 33, same issue as the receiver), a failed login also leaks the already-open socket with no reference left to clean it up. Under repeated upload failures this accumulates open connections until GC finalizers eventually catch up (unreliable) or the FTP server's connection limit is hit.

🔒 Proposed fix (wrap in try/finally, assign before login)
-        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;
-            ...
-        }
-        ...
-        await ftpOrSftp.PutFileAsync(stream, $"{_options.TargetPath}/{filename}");
-        await ftpOrSftp.DisconnectAsync();
-        return new XchangeFile(string.Empty);
+        IFtp? ftpOrSftp = null;
+        try
+        {
+            switch (_options.Protocol.ToLower())
+            {
+                case "sftpssh":
+                    var sftpssh = new Sftp();
+                    await sftpssh.ConnectAsync(_options.Host, _options.Port ?? 22);
+                    ftpOrSftp = sftpssh;
+
+                    var keyBytes = Encoding.UTF8.GetBytes(SshKeyNormalizer.Normalize(_options.PrivateKey));
+                    var sshPrivateKey = new SshPrivateKey(keyBytes, _options.Password);
+                    await sftpssh.LoginAsync(_options.Username, sshPrivateKey);
+                    break;
+                // ... other cases, assign ftpOrSftp before login too
+            }
+            // ... build bytes/stream/filename
+            await ftpOrSftp.PutFileAsync(stream, remotePath);
+            return new XchangeFile(string.Empty);
+        }
+        finally
+        {
+            if (ftpOrSftp is not null)
+            {
+                await ftpOrSftp.DisconnectAsync();
+                ftpOrSftp.Dispose();
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs`
around lines 22 - 78, The upload handler leaves FTP/SFTP connections open on
failures because `ftpOrSftp` is only disconnected on the success path and never
disposed. In `NativeRebexFtpUploadHandler`, assign the concrete client to
`ftpOrSftp` before any login/connect branch completes, wrap the whole
connect/login/upload flow in a try/finally, and ensure the finally block always
calls both `DisconnectAsync()` and `Dispose()` (guarding for null/connected
state as needed). Make sure this cleanup runs for all protocol cases, including
`sftpssh`, `sftp`, and `ftp`, so failed logins or `PutFileAsync` exceptions do
not leak sockets.

}

public string Name => "NativeRebexFtpUploadHandler";

public void InitializeStartupValues(IDictionary<string, string> settings)
{
_options = settings.ConvertTo<RebexFtpUploadHandlerInput>();
}

public Type StartupValuesType => typeof(RebexFtpUploadHandlerInput);
}
Original file line number Diff line number Diff line change
@@ -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; }
}
6 changes: 4 additions & 2 deletions SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
<PackageReference Include="DotLiquid" Version="2.2.692" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Rebex.Mail" Version="6.0.8060" />
<PackageReference Include="Rebex.Pop3" Version="6.0.8060" />
<PackageReference Include="Rebex.Ftp" Version="8.0.9673" />
<PackageReference Include="Rebex.Mail" Version="8.0.9673" />
<PackageReference Include="Rebex.Pop3" Version="8.0.9673" />
<PackageReference Include="Rebex.Sftp" Version="8.0.9673" />
<PackageReference Include="Scriban" Version="7.0.6" />
<PackageReference Include="SimplyWorks.PrimitiveTypes" Version="8.1.2" />
</ItemGroup>
Expand Down
8 changes: 8 additions & 0 deletions SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -40,6 +42,12 @@ public static void AddNativeAdapters(this IServiceCollection serviceCollection,
{
serviceCollection.AddScoped<INativeInfolinkReceiver>(_ => new NativeRebexPop3Receiver(rebexLicenseKey));
serviceCollection.AddScoped<INativeAdapter>(_ => new NativeRebexPop3Receiver(rebexLicenseKey));

serviceCollection.AddScoped<INativeInfolinkHandler>(_ => new NativeRebexFtpUploadHandler(rebexLicenseKey));
serviceCollection.AddScoped<INativeAdapter>(_ => new NativeRebexFtpUploadHandler(rebexLicenseKey));

serviceCollection.AddScoped<INativeInfolinkReceiver>(_ => new NativeRebexFtpReceiver(rebexLicenseKey));
serviceCollection.AddScoped<INativeAdapter>(_ => new NativeRebexFtpReceiver(rebexLicenseKey));
}
}
}
49 changes: 49 additions & 0 deletions SW.Bitween.NativeAdapters/SshKeyNormalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Text;
using System.Text.RegularExpressions;

namespace SW.Bitween.NativeAdapters;

/// <summary>
/// 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.
/// </summary>
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();
}
}
Loading