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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,33 @@ using ( var ftpClient = new FtpClient( new FtpClientConfiguration

```

### Advanced Configuration ###
CoreFTP provides several configuration overrides in `FtpClientConfiguration` to assist with connecting to legacy, obfuscated, or non-compliant FTP servers:

```csharp
using ( var ftpClient = new FtpClient( new FtpClientConfiguration
{
Host = "legacy-server.local",
Username = "user",
Password = "password",

// Force the control stream encoding for servers that don't support UTF8 (e.g. Chinese GBK or Japanese Shift_JIS)
BaseEncoding = System.Text.Encoding.GetEncoding("GBK"),

// Force the directory listing parser if the server hides its OS in the FEAT response
ForceFileSystem = FtpFileSystemType.Windows,

// Provide a custom callback to validate specific self-signed certificates
IgnoreCertificateErrors = false,
ServerCertificateValidationCallback = (cert, chain, errors) =>
{
return cert.GetCertHashString() == "EXPECTED_THUMBPRINT_HERE";
}
} ) )
{
await ftpClient.LoginAsync();
}
```

### Integration Tests ###
Integration tests can be run against most FTP servers with passive mode enabled, credentials can be configured in appsettings.json of CoreFtp.Tests.Integration.
14 changes: 13 additions & 1 deletion src/CoreFtp/Components/DirectoryListing/ListDirectoryProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ public ListDirectoryProvider( FtpClient ftpClient, ILogger logger, FtpClientConf
};
}

internal void ClearParsers()
{
directoryParsers.Clear();
}

internal void AddParser(IListDirectoryParser parser)
{
directoryParsers.Add(parser);
}

private void EnsureLoggedIn()
{
if ( !ftpClient.IsConnected || !ftpClient.IsAuthenticated )
Expand Down Expand Up @@ -112,7 +122,9 @@ private IEnumerable<FtpNodeInformation> ParseLines( IReadOnlyList<string> lines
if ( !lines.Any() )
yield break;

var parser = directoryParsers.FirstOrDefault( x => x.Test( lines[ 0 ] ) );
var parser = directoryParsers.Count == 1
? directoryParsers[ 0 ]
: directoryParsers.FirstOrDefault( x => x.Test( lines[ 0 ] ) );

if ( parser == null )
yield break;
Expand Down
8 changes: 8 additions & 0 deletions src/CoreFtp/Enum/FtpFileSystemType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CoreFtp.Enum
{
public enum FtpFileSystemType
{
Windows,
Unix
}
}
27 changes: 25 additions & 2 deletions src/CoreFtp/FtpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ public void Configure( FtpClientConfiguration configuration )
configuration.Host = new Uri( configuration.Host ).Host;
}


ControlStream = new FtpControlStream( Configuration, new DnsResolver() );
ControlStream = new FtpControlStream( Configuration, new DnsResolver() )
{
Encoding = Configuration.BaseEncoding
};
Configuration.BaseDirectory = $"/{Configuration.BaseDirectory.TrimStart( '/' )}";
}

Expand Down Expand Up @@ -490,9 +492,30 @@ public async Task<long> GetFileSizeAsync( string fileName )
private IDirectoryProvider DetermineDirectoryProvider()
{
Logger?.LogTrace( "[FtpClient] Determining directory provider" );

if ( this.UsesMlsd() )
return new MlsdDirectoryProvider( this, Logger, Configuration );

if ( Configuration.ForceFileSystem.HasValue )
{
var forcedProvider = new ListDirectoryProvider( this, Logger, Configuration );
forcedProvider.ClearParsers();

switch ( Configuration.ForceFileSystem.Value )
{
case FtpFileSystemType.Windows:
forcedProvider.AddParser( new Components.DirectoryListing.Parser.DosDirectoryParser( Logger ) );
break;
case FtpFileSystemType.Unix:
forcedProvider.AddParser( new Components.DirectoryListing.Parser.UnixDirectoryParser( Logger ) );
break;
default:
throw new NotSupportedException( $"Unsupported file system type: {Configuration.ForceFileSystem.Value}" );
}

return forcedProvider;
}
Comment on lines +499 to +517
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

When ForceFileSystem is set and the server also supports MLSD, the code bypasses MlsdDirectoryProvider entirely and falls back to ListDirectoryProvider. Since ForceFileSystem is checked first (before the UsesMlsd() check), setting it on a server that does advertise MLSD causes a regression: the client uses the less-capable LIST-based provider instead of the more reliable MLSD-based one.

The intent from issue #41 is to handle the case where MLSD is not available and auto-detection of the LIST format fails. The fix should only apply ForceFileSystem when MLSD is not available — either by changing the condition to !this.UsesMlsd() && Configuration.ForceFileSystem.HasValue, or by restructuring the check so ForceFileSystem only influences the parser selection within the LIST-based path, not the MLSD selection.

Copilot uses AI. Check for mistakes.

return new ListDirectoryProvider( this, Logger, Configuration );
}

Expand Down
21 changes: 21 additions & 0 deletions src/CoreFtp/FtpClientConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
namespace CoreFtp
{
using System;
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using Enum;
Expand All @@ -26,5 +28,24 @@ public class FtpClientConfiguration

public X509CertificateCollection ClientCertificates { get; set; } = new X509CertificateCollection();
public SslProtocols SslProtocols { get; set; } = SslProtocols.None;

/// <summary>
/// Base encoding to use for the control stream. Useful for legacy servers that use Shift_JIS, GBK, etc.
/// </summary>
public System.Text.Encoding BaseEncoding { get; set; } = System.Text.Encoding.ASCII;

/// <summary>
/// Allows overriding the server certificate validation logic (e.g., verifying a specific self-signed certificate thumbprint).
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The ServerCertificateValidationCallback is silently bypassed when IgnoreCertificateErrors is true (which is the default). The OnValidateCertificate method returns true immediately in that case, so the callback is never invoked.

This means a user who sets ServerCertificateValidationCallback without also setting IgnoreCertificateErrors = false will have their callback silently ignored — their custom validation logic will never run. This is particularly problematic given that the default value of IgnoreCertificateErrors is true, making the callback effectively dead code unless the user knows to change the flag.

The docstring on ServerCertificateValidationCallback should explicitly note that it requires IgnoreCertificateErrors to be set to false to take effect.

Suggested change
/// Allows overriding the server certificate validation logic (e.g., verifying a specific self-signed certificate thumbprint).
/// Allows overriding the server certificate validation logic (e.g., verifying a specific self-signed certificate thumbprint).
/// Note: This callback is only invoked when <see cref="IgnoreCertificateErrors"/> is set to <c>false</c>.
/// When <see cref="IgnoreCertificateErrors"/> is <c>true</c> (the default), certificate errors are ignored and this callback is not used.

Copilot uses AI. Check for mistakes.
/// Note: This callback is only invoked when <see cref="IgnoreCertificateErrors"/> is set to <c>false</c>.
/// When <see cref="IgnoreCertificateErrors"/> is <c>true</c> (the default), certificate errors are ignored and this callback is not used.
/// </summary>
public Func<X509Certificate, X509Chain, SslPolicyErrors, bool> ServerCertificateValidationCallback { get; set; }

/// <summary>
/// Allows overriding the detected server file system / directory listing format.
/// Useful when the server does not advertise MLSD in its FEAT response (causing a fallback to LIST),
/// and the automatic detection of the LIST output format fails (e.g. force FtpFileSystemType.Windows).
/// </summary>
public FtpFileSystemType? ForceFileSystem { get; set; }
}
}
3 changes: 3 additions & 0 deletions src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,9 @@ private bool OnValidateCertificate(X509Certificate certificate, X509Chain chain,
if (Configuration.IgnoreCertificateErrors)
return true;

if (Configuration.ServerCertificateValidationCallback != null)
return Configuration.ServerCertificateValidationCallback(certificate, chain, errors);
Comment on lines +472 to +473
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Run custom cert validator before ignore-all certificate path

OnValidateCertificate checks IgnoreCertificateErrors first, so with the default config (IgnoreCertificateErrors = true in FtpClientConfiguration) a provided ServerCertificateValidationCallback is never called and any server certificate is accepted. This is a security footgun for callers who set a callback for pinning or selective trust, because their validation logic is silently bypassed unless they also know to toggle a separate flag.

Useful? React with 👍 / 👎.


return errors == SslPolicyErrors.None;
}

Expand Down