Skip to content

feat: add native Rebex FTP upload handler and receiver - #190

Merged
AhmadRAbuhussein merged 2 commits into
releases/r8.0from
hamza/feature/native-rebex-ftp-adapters
Jul 7, 2026
Merged

feat: add native Rebex FTP upload handler and receiver#190
AhmadRAbuhussein merged 2 commits into
releases/r8.0from
hamza/feature/native-rebex-ftp-adapters

Conversation

@hamzahalq

Copy link
Copy Markdown
Contributor

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

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

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@hamzahalq, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository: simplify9/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0790f4ee-6019-40c3-9ad8-dc06a2099af1

📥 Commits

Reviewing files that changed from the base of the PR and between bf06449 and 0a432d0.

📒 Files selected for processing (3)
  • SW.Bitween.NativeAdapters/FtpProtocol.cs
  • SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs
  • SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs
📝 Walkthrough

Walkthrough

Adds Rebex-based FTP/SFTP native adapters: a receiver (list/get/delete files) and an upload handler, both supporting ftp/sftp/sftpssh protocols with private-key login. Adds SSH key normalization and password-validation utilities, DI registrations, Rebex package upgrades, unit tests, and a .gitignore update.

Changes

Rebex FTP/SFTP Adapters

Layer / File(s) Summary
Shared validation and SSH key normalization
SW.Bitween.NativeAdapters/FtpProtocol.cs, SW.Bitween.NativeAdapters/SshKeyNormalizer.cs, SW.Bitween.UnitTests/SshKeyNormalizerTests.cs
FtpProtocol.EnsurePasswordProvided enforces passwords for ftp/sftp; SshKeyNormalizer.Normalize reconstructs flattened PEM keys into multi-line format with 64-char body wrapping, validated by unit tests.
Rebex FTP receiver
SW.Bitween.NativeAdapters/RebexFtpReceiver/*.cs
RebexFtpReceiverInput defines connection/config options; NativeRebexFtpReceiver implements Initialize, ListFiles, GetFile, DeleteFile, Finalize, and startup value plumbing for ftp/sftp/sftpssh.
Rebex FTP upload handler
SW.Bitween.NativeAdapters/RebexFtpUploadHandler/*.cs
RebexFtpUploadHandlerInput defines upload config options; NativeRebexFtpUploadHandler.Handle connects, decodes payload (base64/utf8), and uploads via PutFileAsync.
DI wiring and dependency updates
SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs, SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj, .gitignore
Registers new receiver/handler under rebexLicenseKey gate, upgrades Rebex.Mail/Rebex.Pop3 to 8.0.9673, adds Rebex.Ftp/Rebex.Sftp packages, and updates ignored appsettings filename.

Estimated code review effort: 3 (Moderate) | ~35 minutes

Possibly related PRs

Suggested labels: security, risk:high

Suggested reviewers: AhmadRAbuhussein

Security notes: RebexFtpReceiverInput/RebexFtpUploadHandlerInput mark Password/PrivateKey as [Secure], but plaintext private keys and passwords still flow through IDictionary<string,string> settings and in-memory strings without redaction in logs — verify no logging of these fields exists. Confirm EnsurePasswordProvided correctly excludes sftpssh (key-based auth) from password enforcement given key-only login is supported. Validate DeleteFile's rename-based "soft delete" path handles path separators correctly across FTP server implementations.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the new native Rebex FTP upload handler and receiver, which is the main change.
Description check ✅ Passed The description matches the implemented native adapters, shared helpers, and encoding/password behavior changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with 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.

Inline comments:
In `@SW.Bitween.NativeAdapters/FtpProtocol.cs`:
- Around line 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.
- Around line 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.

In `@SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs`:
- Around line 21-34: The NativeRebexFtpReceiver.ReceiveAsync flow for the
"sftpssh" branch is missing boundary validation for _options.PrivateKey before
calling SshKeyNormalizer.Normalize and constructing SshPrivateKey. Add an
explicit check alongside the existing FtpProtocol.EnsurePasswordProvided
validation so that empty or null private keys are rejected early with a clear
message before connect/login logic runs. Keep the fix localized to the switch
branch that creates the Sftp instance and uses LoginAsync.
- Around line 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.

In
`@SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs`:
- Around line 20-34: The sftpssh branch in NativeRebexFtpUploadHandler should
validate that _options.PrivateKey is present before creating SshPrivateKey and
calling LoginAsync. Add the same preflight check used in
NativeRebexFtpReceiver.Initialize (or equivalent validation path) near the
FtpProtocol.EnsurePasswordProvided call, and fail fast with a clear validation
error if the private key is missing or empty.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: simplify9/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 775096b8-0194-4ec2-b794-10956d8ae6fe

📥 Commits

Reviewing files that changed from the base of the PR and between cb5990f and bf06449.

📒 Files selected for processing (10)
  • .gitignore
  • SW.Bitween.NativeAdapters/FtpProtocol.cs
  • SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs
  • SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs
  • SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs
  • SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs
  • SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj
  • SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs
  • SW.Bitween.NativeAdapters/SshKeyNormalizer.cs
  • SW.Bitween.UnitTests/SshKeyNormalizerTests.cs
📜 Review details
🔇 Additional comments (9)
SW.Bitween.NativeAdapters/ServiceCollectionExtensions.cs (1)

4-5: LGTM!

Also applies to: 45-50

.gitignore (1)

361-361: LGTM!

SW.Bitween.NativeAdapters/SW.Bitween.NativeAdapters.csproj (1)

20-23: 📐 Maintainability & Code Quality

Check Rebex 8.x compatibility in the native adapters. Rebex.Mail and Rebex.Pop3 jump from 6.0.8060 to 8.0.9673, and SW.Bitween.NativeAdapters uses Rebex APIs directly; make sure the existing FTP/POP3 handlers still compile and behave correctly with the new version.

SW.Bitween.NativeAdapters/SshKeyNormalizer.cs (1)

1-49: LGTM!

SW.Bitween.UnitTests/SshKeyNormalizerTests.cs (1)

1-69: LGTM!

SW.Bitween.NativeAdapters/RebexFtpReceiver/RebexFtpReceiverInput.cs (1)

1-37: LGTM!

SW.Bitween.NativeAdapters/RebexFtpReceiver/NativeRebexFtpReceiver.cs (1)

64-110: LGTM!

SW.Bitween.NativeAdapters/RebexFtpUploadHandler/RebexFtpUploadHandlerInput.cs (1)

1-31: LGTM!

SW.Bitween.NativeAdapters/RebexFtpUploadHandler/NativeRebexFtpUploadHandler.cs (1)

81-89: LGTM!

Comment on lines +13 to +17
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.");
}

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.

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

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.

Comment on lines +22 to +78
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);

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.

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.

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.
@AhmadRAbuhussein
AhmadRAbuhussein merged commit 6e8e386 into releases/r8.0 Jul 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants